"""Tests for the Steam Workshop API client and downloader.""" from __future__ import annotations import os from pathlib import Path from unittest.mock import MagicMock, patch import pytest from l4d2web.services import steam_workshop def test_parse_workshop_input_single_numeric() -> None: assert steam_workshop.parse_workshop_input("12345") == ["12345"] def test_parse_workshop_input_single_url() -> None: url = "https://steamcommunity.com/sharedfiles/filedetails/?id=98765" assert steam_workshop.parse_workshop_input(url) == ["98765"] def test_parse_workshop_input_workshop_url_variant() -> None: url = "steamcommunity.com/workshop/filedetails/?id=42" assert steam_workshop.parse_workshop_input(url) == ["42"] def test_parse_workshop_input_multiline_batch() -> None: raw = """ 12345 https://steamcommunity.com/sharedfiles/filedetails/?id=67890 99999 """ assert steam_workshop.parse_workshop_input(raw) == ["12345", "67890", "99999"] def test_parse_workshop_input_deduplicates_preserving_order() -> None: raw = "100\n200\n100\n300" assert steam_workshop.parse_workshop_input(raw) == ["100", "200", "300"] def test_parse_workshop_input_rejects_garbage() -> None: with pytest.raises(ValueError): steam_workshop.parse_workshop_input("not-a-number") def test_parse_workshop_input_rejects_empty() -> None: with pytest.raises(ValueError): steam_workshop.parse_workshop_input("") def test_parse_workshop_input_rejects_non_steam_url() -> None: with pytest.raises(ValueError): steam_workshop.parse_workshop_input("https://example.com/?id=12345") def test_endpoints_are_https() -> None: assert steam_workshop.GET_PUBLISHED_FILE_DETAILS_URL.startswith("https://") assert steam_workshop.GET_COLLECTION_DETAILS_URL.startswith("https://") assert "api.steampowered.com" in steam_workshop.GET_PUBLISHED_FILE_DETAILS_URL def test_resolve_collection_returns_child_ids() -> None: fake_response = MagicMock(status_code=200) fake_response.raise_for_status = MagicMock() fake_response.json.return_value = { "response": { "collectiondetails": [ { "publishedfileid": "555", "result": 1, "children": [ {"publishedfileid": "1001", "filetype": 0}, {"publishedfileid": "1002", "filetype": 0}, {"publishedfileid": "9999", "filetype": 1}, # nested collection — skip ], } ] } } with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))): ids = steam_workshop.resolve_collection("555") assert ids == ["1001", "1002"] def test_fetch_metadata_batch_parses_published_file_details() -> None: fake_response = MagicMock(status_code=200) fake_response.raise_for_status = MagicMock() fake_response.json.return_value = { "response": { "publishedfiledetails": [ { "publishedfileid": "1001", "result": 1, "consumer_app_id": 550, "title": "Map A", "filename": "map_a.vpk", "file_url": "https://steamusercontent.com/abc/map_a.vpk", "file_size": "1024", "time_updated": 1700000000, "preview_url": "https://steamuserimages.com/preview_a.jpg", } ] } } with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))): metas = steam_workshop.fetch_metadata_batch(["1001"], mode="add") assert len(metas) == 1 m = metas[0] assert m.steam_id == "1001" assert m.title == "Map A" assert m.filename == "map_a.vpk" assert m.file_url == "https://steamusercontent.com/abc/map_a.vpk" assert m.file_size == 1024 assert m.time_updated == 1700000000 assert m.preview_url == "https://steamuserimages.com/preview_a.jpg" assert m.consumer_app_id == 550 assert m.result == 1 def test_fetch_metadata_batch_rejects_non_l4d2_in_add_mode() -> None: fake_response = MagicMock(status_code=200) fake_response.raise_for_status = MagicMock() fake_response.json.return_value = { "response": { "publishedfiledetails": [ { "publishedfileid": "1001", "result": 1, "consumer_app_id": 440, # TF2 "title": "Other", "filename": "x.vpk", "file_url": "https://example.com/x.vpk", "file_size": "0", "time_updated": 0, } ] } } with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))): with pytest.raises(steam_workshop.WorkshopValidationError): steam_workshop.fetch_metadata_batch(["1001"], mode="add") def test_fetch_metadata_batch_skips_non_l4d2_in_refresh_mode() -> None: fake_response = MagicMock(status_code=200) fake_response.raise_for_status = MagicMock() fake_response.json.return_value = { "response": { "publishedfiledetails": [ { "publishedfileid": "1001", "result": 1, "consumer_app_id": 440, "title": "Other", "filename": "x.vpk", "file_url": "https://example.com/x.vpk", "file_size": "0", "time_updated": 0, }, { "publishedfileid": "1002", "result": 1, "consumer_app_id": 550, "title": "Good", "filename": "g.vpk", "file_url": "https://example.com/g.vpk", "file_size": "100", "time_updated": 1, }, ] } } with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))): metas = steam_workshop.fetch_metadata_batch(["1001", "1002"], mode="refresh") # The non-L4D2 item is dropped; the L4D2 item is kept. assert [m.steam_id for m in metas] == ["1002"] def test_fetch_metadata_batch_captures_result_failure() -> None: fake_response = MagicMock(status_code=200) fake_response.raise_for_status = MagicMock() fake_response.json.return_value = { "response": { "publishedfiledetails": [ { "publishedfileid": "999", "result": 9, # not found / hidden / etc. } ] } } with patch.object(steam_workshop, "_session", return_value=MagicMock(post=MagicMock(return_value=fake_response))): metas = steam_workshop.fetch_metadata_batch(["999"], mode="refresh") # Item is kept but marked with the failing result; consumer app id never validated. assert len(metas) == 1 assert metas[0].result == 9 def test_download_to_cache_writes_atomically_and_sets_mtime(tmp_path: Path) -> None: cache_root = tmp_path / "workshop_cache" cache_root.mkdir() meta = steam_workshop.WorkshopMetadata( steam_id="1001", title="A", filename="a.vpk", file_url="https://example.com/a.vpk", file_size=11, time_updated=1700000000, preview_url="", consumer_app_id=550, result=1, ) fake_response = MagicMock(status_code=200) fake_response.raise_for_status = MagicMock() fake_response.iter_content.return_value = [b"hello world"] with patch.object(steam_workshop, "_session", return_value=MagicMock(get=MagicMock(return_value=fake_response))): path = steam_workshop.download_to_cache(meta, cache_root) assert path == cache_root / "1001.vpk" assert path.read_bytes() == b"hello world" assert int(path.stat().st_mtime) == 1700000000 # No leftover .partial file. assert not (cache_root / "1001.vpk.partial").exists() def test_download_to_cache_is_idempotent(tmp_path: Path) -> None: cache_root = tmp_path / "workshop_cache" cache_root.mkdir() target = cache_root / "1001.vpk" target.write_bytes(b"existing") os.utime(target, (1700000000, 1700000000)) meta = steam_workshop.WorkshopMetadata( steam_id="1001", title="A", filename="a.vpk", file_url="https://example.com/a.vpk", file_size=8, # matches existing time_updated=1700000000, # matches existing mtime preview_url="", consumer_app_id=550, result=1, ) fake_session = MagicMock() with patch.object(steam_workshop, "_session", return_value=fake_session): steam_workshop.download_to_cache(meta, cache_root) fake_session.get.assert_not_called() def test_download_to_cache_redownloads_when_mtime_or_size_differ(tmp_path: Path) -> None: cache_root = tmp_path / "workshop_cache" cache_root.mkdir() target = cache_root / "1001.vpk" target.write_bytes(b"old") os.utime(target, (1500000000, 1500000000)) meta = steam_workshop.WorkshopMetadata( steam_id="1001", title="A", filename="a.vpk", file_url="https://example.com/a.vpk", file_size=11, time_updated=1700000000, preview_url="", consumer_app_id=550, result=1, ) fake_response = MagicMock(status_code=200) fake_response.raise_for_status = MagicMock() fake_response.iter_content.return_value = [b"hello world"] with patch.object(steam_workshop, "_session", return_value=MagicMock(get=MagicMock(return_value=fake_response))): steam_workshop.download_to_cache(meta, cache_root) assert target.read_bytes() == b"hello world" assert int(target.stat().st_mtime) == 1700000000 def test_refresh_all_uses_thread_pool_and_collects_errors(tmp_path: Path) -> None: cache_root = tmp_path / "workshop_cache" cache_root.mkdir() metas = [ steam_workshop.WorkshopMetadata( steam_id=str(i), title=f"M{i}", filename=f"m{i}.vpk", file_url=f"https://example.com/m{i}.vpk", file_size=5, time_updated=1700000000, preview_url="", consumer_app_id=550, result=1, ) for i in (1, 2, 3) ] def fake_download(meta, cache_root_arg, **kwargs): if meta.steam_id == "2": raise RuntimeError("simulated download failure") return cache_root_arg / f"{meta.steam_id}.vpk" with patch.object(steam_workshop, "download_to_cache", side_effect=fake_download): report = steam_workshop.refresh_all(metas, cache_root, executor_workers=4) assert report.downloaded == 2 assert report.errors == 1 assert "2" in report.per_item_errors