Adds l4d2web/services/steam_workshop.py: parse_workshop_input (single ID, URL, or multi-line batch), resolve_collection (HTTPS POST to GetCollectionDetails), fetch_metadata_batch (HTTPS POST to GetPublishedFileDetails with consumer_app_id == 550 enforcement that raises WorkshopValidationError in add-mode and silently skips in refresh-mode), download_to_cache (atomic + idempotent on mtime+size), and refresh_all (ThreadPoolExecutor with per-item error collection). Adds requests as an explicit dependency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
312 lines
11 KiB
Python
312 lines
11 KiB
Python
"""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
|