diff --git a/l4d2web/routes/workshop_routes.py b/l4d2web/routes/workshop_routes.py index 4fae85a..7a318ff 100644 --- a/l4d2web/routes/workshop_routes.py +++ b/l4d2web/routes/workshop_routes.py @@ -120,6 +120,60 @@ def remove_item(overlay_id: int, item_id: int) -> Response: return redirect(f"/jobs/{job_id}") +@bp.post("/overlays//refresh") +@require_login +def refresh_overlay(overlay_id: int) -> Response: + user = current_user() + assert user is not None + with session_scope() as db: + overlay, err = _check_workshop_overlay_access(overlay_id, user, db) + if err is not None: + return err + steam_ids = list( + db.scalars( + select(WorkshopItem.steam_id) + .join( + OverlayWorkshopItem, + OverlayWorkshopItem.workshop_item_id == WorkshopItem.id, + ) + .where(OverlayWorkshopItem.overlay_id == overlay_id) + ).all() + ) + + if not steam_ids: + return Response("overlay has no items", status=400) + + try: + metas = steam_workshop.fetch_metadata_batch(steam_ids, mode="refresh") + except Exception as exc: + return Response(f"steam api error: {exc}", status=502) + + metas_by_id = {m.steam_id: m for m in metas} + with session_scope() as db: + overlay, err = _check_workshop_overlay_access(overlay_id, user, db) + if err is not None: + return err + for steam_id in steam_ids: + wi = db.scalar(select(WorkshopItem).where(WorkshopItem.steam_id == steam_id)) + if wi is None: + continue + meta = metas_by_id.get(steam_id) + if meta is None: + wi.last_error = "steam returned no entry for this item" + continue + wi.title = meta.title + wi.filename = meta.filename + wi.file_url = meta.file_url + wi.file_size = meta.file_size + wi.time_updated = meta.time_updated + wi.preview_url = meta.preview_url + wi.last_error = "" if meta.result == 1 else f"steam result {meta.result}" + job = enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id) + job_id = job.id + + return redirect(f"/jobs/{job_id}") + + @bp.post("/admin/workshop/refresh") @require_admin def admin_refresh() -> Response: diff --git a/l4d2web/tests/test_workshop_routes.py b/l4d2web/tests/test_workshop_routes.py index e66e957..99b2198 100644 --- a/l4d2web/tests/test_workshop_routes.py +++ b/l4d2web/tests/test_workshop_routes.py @@ -283,3 +283,142 @@ def test_other_user_cannot_modify_workshop_overlay(overlay_for): 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() + + with patch.object(steam_workshop, "fetch_metadata_batch", side_effect=Exception("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_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