"""Tests for the workshop overlay routes (add items, remove items, build, admin refresh).""" from __future__ import annotations from datetime import UTC, datetime from typing import Iterable from unittest.mock import patch import pytest from l4d2web.app import create_app from l4d2web.auth import hash_password from l4d2web.db import init_db, session_scope from l4d2web.models import ( Job, Overlay, OverlayWorkshopItem, User, WorkshopItem, ) from l4d2web.services import steam_workshop def _meta(steam_id: str, *, app_id: int = 550, result: int = 1) -> steam_workshop.WorkshopMetadata: return steam_workshop.WorkshopMetadata( steam_id=steam_id, title=f"Item {steam_id}", filename=f"{steam_id}.vpk", file_url=f"https://example.com/{steam_id}.vpk", file_size=42, time_updated=1700000000, preview_url=f"https://example.com/preview-{steam_id}.jpg", consumer_app_id=app_id, result=result, ) @pytest.fixture def env_user(tmp_path, monkeypatch): db_url = f"sqlite:///{tmp_path/'wr.db'}" monkeypatch.setenv("DATABASE_URL", db_url) monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) init_db() with session_scope() as session: user = User(username="alice", password_digest=hash_password("x"), admin=False) admin = User(username="admin", password_digest=hash_password("x"), admin=True) session.add_all([user, admin]) session.flush() user_id = user.id admin_id = admin.id def login(uid): c = app.test_client() with c.session_transaction() as sess: sess["user_id"] = uid sess["pw_changed_at"] = datetime.now(UTC).isoformat() sess["csrf_token"] = "test-token" return c return app, login, user_id, admin_id @pytest.fixture def overlay_for(env_user): app, login, user_id, admin_id = env_user user_client = login(user_id) response = user_client.post( "/overlays", data={"name": "my-maps", "type": "workshop"}, headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 302, response.get_data(as_text=True) with session_scope() as session: overlay = session.query(Overlay).filter_by(name="my-maps").one() overlay_id = overlay.id return app, login, user_id, admin_id, overlay_id def _patch_steam(metas: Iterable[steam_workshop.WorkshopMetadata]): return patch.object(steam_workshop, "fetch_metadata_batch", return_value=list(metas)) def test_add_single_item_creates_association_and_enqueues_build(overlay_for): app, login, user_id, _admin_id, overlay_id = overlay_for user_client = login(user_id) with _patch_steam([_meta("1001")]): response = user_client.post( f"/overlays/{overlay_id}/items", data={"input": "1001", "input_mode": "items"}, headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 302 assert response.headers["Location"].startswith("/jobs/") with session_scope() as session: n_assoc = session.query(OverlayWorkshopItem).count() assert n_assoc == 1 wi = session.query(WorkshopItem).filter_by(steam_id="1001").one() assert wi.title == "Item 1001" assert wi.preview_url.endswith("preview-1001.jpg") # Auto-enqueued build_overlay job. jobs = session.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all() assert len(jobs) == 1 assert jobs[0].state == "queued" assert response.headers["Location"] == f"/jobs/{jobs[0].id}" def test_add_multiline_batch_coalesces_into_one_build_job(overlay_for): app, login, user_id, _admin_id, overlay_id = overlay_for user_client = login(user_id) with _patch_steam([_meta(s) for s in ("1001", "1002", "1003")]): response = user_client.post( f"/overlays/{overlay_id}/items", data={"input": "1001\n1002\n1003", "input_mode": "items"}, headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 302 assert response.headers["Location"].startswith("/jobs/") with session_scope() as session: assert session.query(OverlayWorkshopItem).count() == 3 jobs = session.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all() assert len(jobs) == 1, "multi-item add should coalesce into a single build job" def test_add_collection_resolves_members(overlay_for): app, login, user_id, _admin_id, overlay_id = overlay_for user_client = login(user_id) with patch.object(steam_workshop, "resolve_collection", return_value=["1001", "1002"]) as resolve: with _patch_steam([_meta("1001"), _meta("1002")]): response = user_client.post( f"/overlays/{overlay_id}/items", data={"input": "555", "input_mode": "collection"}, headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 302 assert response.headers["Location"].startswith("/jobs/") resolve.assert_called_once_with("555") with session_scope() as session: assert session.query(OverlayWorkshopItem).count() == 2 def test_add_non_l4d2_item_returns_400(overlay_for): app, login, user_id, _admin_id, overlay_id = overlay_for user_client = login(user_id) def raise_validation(*args, **kwargs): raise steam_workshop.WorkshopValidationError("not L4D2") with patch.object(steam_workshop, "fetch_metadata_batch", side_effect=raise_validation): response = user_client.post( f"/overlays/{overlay_id}/items", data={"input": "9999", "input_mode": "items"}, headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 400 assert b"not L4D2" in response.data with session_scope() as session: assert session.query(WorkshopItem).count() == 0 assert session.query(OverlayWorkshopItem).count() == 0 def test_add_duplicate_item_does_not_500(overlay_for): app, login, user_id, _admin_id, overlay_id = overlay_for user_client = login(user_id) with _patch_steam([_meta("1001")]): first = user_client.post( f"/overlays/{overlay_id}/items", data={"input": "1001", "input_mode": "items"}, headers={"X-CSRF-Token": "test-token"}, ) assert first.status_code == 302 with _patch_steam([_meta("1001")]): second = user_client.post( f"/overlays/{overlay_id}/items", data={"input": "1001", "input_mode": "items"}, headers={"X-CSRF-Token": "test-token"}, ) assert second.status_code == 302 with session_scope() as session: assert session.query(OverlayWorkshopItem).count() == 1 def test_remove_item_drops_association_and_enqueues_rebuild(overlay_for): app, login, user_id, _admin_id, overlay_id = overlay_for user_client = login(user_id) with _patch_steam([_meta("1001")]): user_client.post( f"/overlays/{overlay_id}/items", data={"input": "1001", "input_mode": "items"}, headers={"X-CSRF-Token": "test-token"}, ) with session_scope() as session: wi = session.query(WorkshopItem).filter_by(steam_id="1001").one() item_id = wi.id response = user_client.post( f"/overlays/{overlay_id}/items/{item_id}/delete", headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 302 assert response.headers["Location"].startswith("/jobs/") with session_scope() as session: assert session.query(OverlayWorkshopItem).count() == 0 # WorkshopItem itself remains (cache survives the association removal). assert session.query(WorkshopItem).filter_by(steam_id="1001").one() is not None # Coalesced into the same queued build_overlay job. jobs = session.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all() assert len(jobs) == 1 def test_manual_build_button_enqueues_job(overlay_for): app, login, user_id, _admin_id, overlay_id = overlay_for user_client = login(user_id) response = user_client.post( f"/overlays/{overlay_id}/build", headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 302 assert response.headers["Location"].startswith("/jobs/") with session_scope() as session: jobs = session.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all() assert len(jobs) == 1 def test_admin_refresh_enqueues_global_job(env_user): app, login, user_id, admin_id = env_user admin_client = login(admin_id) response = admin_client.post( "/admin/workshop/refresh", headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 302 assert response.headers["Location"] == "/admin/jobs" with session_scope() as session: jobs = session.query(Job).filter_by(operation="refresh_workshop_items").all() assert len(jobs) == 1 assert jobs[0].state == "queued" def test_non_admin_cannot_refresh(env_user): app, login, user_id, _admin_id = env_user user_client = login(user_id) response = user_client.post( "/admin/workshop/refresh", headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 403 def test_other_user_cannot_modify_workshop_overlay(overlay_for): app, login, user_id, _admin_id, overlay_id = overlay_for with session_scope() as session: intruder = User(username="bob", password_digest=hash_password("x"), admin=False) session.add(intruder) session.flush() intruder_id = intruder.id intruder_client = login(intruder_id) response = intruder_client.post( f"/overlays/{overlay_id}/items", data={"input": "1001", "input_mode": "items"}, headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 403 def test_overlay_refresh_owner_can_refresh_and_enqueues_build(overlay_for): app, login, user_id, _admin_id, overlay_id = overlay_for user_client = login(user_id) with _patch_steam([_meta("1001")]): user_client.post( f"/overlays/{overlay_id}/items", data={"input": "1001", "input_mode": "items"}, headers={"X-CSRF-Token": "test-token"}, ) with session_scope() as session: for job in session.query(Job).filter_by(operation="build_overlay", state="queued"): job.state = "succeeded" fresh_meta = steam_workshop.WorkshopMetadata( steam_id="1001", title="Item 1001 (updated)", filename="1001.vpk", file_url="https://example.com/1001.vpk", file_size=99, time_updated=1800000000, preview_url="https://example.com/preview-1001.jpg", consumer_app_id=550, result=1, ) with patch.object(steam_workshop, "fetch_metadata_batch", return_value=[fresh_meta]) as fetch: response = user_client.post( f"/overlays/{overlay_id}/refresh", headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 302 assert response.headers["Location"].startswith("/jobs/") fetch.assert_called_once_with(["1001"], mode="refresh") with session_scope() as session: wi = session.query(WorkshopItem).filter_by(steam_id="1001").one() assert wi.title == "Item 1001 (updated)" assert wi.time_updated == 1800000000 new_jobs = session.query(Job).filter_by( operation="build_overlay", overlay_id=overlay_id, state="queued" ).all() assert len(new_jobs) == 1 def test_overlay_refresh_returns_400_when_overlay_empty(overlay_for): app, login, user_id, _admin_id, overlay_id = overlay_for user_client = login(user_id) with patch.object(steam_workshop, "fetch_metadata_batch") as fetch: response = user_client.post( f"/overlays/{overlay_id}/refresh", headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 400 fetch.assert_not_called() def test_overlay_refresh_forbidden_for_non_owner(overlay_for, env_user): app, login, _user_id, _admin_id, overlay_id = overlay_for with session_scope() as session: bob = User(username="bob", password_digest=hash_password("x"), admin=False) session.add(bob) session.flush() bob_id = bob.id bob_client = login(bob_id) response = bob_client.post( f"/overlays/{overlay_id}/refresh", headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 403 def test_overlay_refresh_admin_can_refresh_anyone(overlay_for): app, login, user_id, admin_id, overlay_id = overlay_for user_client = login(user_id) with _patch_steam([_meta("1001")]): user_client.post( f"/overlays/{overlay_id}/items", data={"input": "1001", "input_mode": "items"}, headers={"X-CSRF-Token": "test-token"}, ) admin_client = login(admin_id) with _patch_steam([_meta("1001")]): response = admin_client.post( f"/overlays/{overlay_id}/refresh", headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 302 def test_overlay_refresh_502_on_steam_error(overlay_for): app, login, user_id, _admin_id, overlay_id = overlay_for user_client = login(user_id) with _patch_steam([_meta("1001")]): user_client.post( f"/overlays/{overlay_id}/items", data={"input": "1001", "input_mode": "items"}, headers={"X-CSRF-Token": "test-token"}, ) with session_scope() as session: for job in session.query(Job).filter_by(operation="build_overlay", state="queued"): job.state = "succeeded" baseline_job_count = session.query(Job).filter_by( operation="build_overlay", overlay_id=overlay_id, state="queued" ).count() import requests as _requests with patch.object(steam_workshop, "fetch_metadata_batch", side_effect=_requests.ConnectionError("boom")): response = user_client.post( f"/overlays/{overlay_id}/refresh", headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 502 assert b"steam api error" in response.data with session_scope() as session: n = session.query(Job).filter_by( operation="build_overlay", overlay_id=overlay_id, state="queued" ).count() assert n == baseline_job_count def test_overlay_refresh_non_requests_exception_propagates(overlay_for): """A non-requests exception (e.g., ValueError from a server-side data issue) must not be disguised as a 502 — let it bubble up as 500.""" app, login, user_id, _admin_id, overlay_id = overlay_for user_client = login(user_id) with _patch_steam([_meta("1001")]): user_client.post( f"/overlays/{overlay_id}/items", data={"input": "1001", "input_mode": "items"}, headers={"X-CSRF-Token": "test-token"}, ) with session_scope() as session: for job in session.query(Job).filter_by(operation="build_overlay", state="queued"): job.state = "succeeded" # Flask in TESTING mode re-raises uncaught exceptions instead of returning # 500, so we assert the ValueError propagates rather than checking a status. with patch.object(steam_workshop, "fetch_metadata_batch", side_effect=ValueError("bad steam_id in db")): with pytest.raises(ValueError, match="bad steam_id in db"): user_client.post( f"/overlays/{overlay_id}/refresh", headers={"X-CSRF-Token": "test-token"}, ) def test_overlay_refresh_missing_item_records_last_error(overlay_for): app, login, user_id, _admin_id, overlay_id = overlay_for user_client = login(user_id) with _patch_steam([_meta("1001")]): user_client.post( f"/overlays/{overlay_id}/items", data={"input": "1001", "input_mode": "items"}, headers={"X-CSRF-Token": "test-token"}, ) with session_scope() as session: for job in session.query(Job).filter_by(operation="build_overlay", state="queued"): job.state = "succeeded" with patch.object(steam_workshop, "fetch_metadata_batch", return_value=[]): response = user_client.post( f"/overlays/{overlay_id}/refresh", headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 302 with session_scope() as session: wi = session.query(WorkshopItem).filter_by(steam_id="1001").one() assert "no entry" in wi.last_error new_jobs = session.query(Job).filter_by( operation="build_overlay", overlay_id=overlay_id, state="queued" ).all() assert len(new_jobs) == 1, "refresh should enqueue build even when Steam returns no entries"