455 lines
21 KiB
Python
455 lines
21 KiB
Python
import asyncio
|
|
import base64
|
|
import io
|
|
import urllib.error
|
|
from email.message import Message
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from PIL import Image, UnidentifiedImageError
|
|
from pydantic import SecretStr, ValidationError
|
|
|
|
from app import f95
|
|
from app.blobs import blob_path
|
|
from app.config import get_settings
|
|
from app.f95_session import open_session, seal
|
|
from app.routers import games, metadata
|
|
from app.schemas import GameIn, GameUpdateIn
|
|
|
|
THREAD = "https://f95zone.to/threads/example.123/"
|
|
IMAGE = "https://attachments.f95zone.to/preview.jpg"
|
|
|
|
|
|
def page(meta="", post="", reply=""):
|
|
return (f'<html data-template="thread_view"><head><meta property="og:title" '
|
|
f'content="VN - Example [v1] [Author]">{meta}</head><body>'
|
|
f'<div class="bbWrapper">{post}<br>Overview: A story<br>Download: link</div>'
|
|
f'<div class="bbWrapper">{reply}</div></body></html>')
|
|
|
|
|
|
def test_og_preview_and_existing_metadata():
|
|
result = f95.parse(page(f'<meta property="og:image" content="{IMAGE}">'))
|
|
assert result == {"title": "Example", "description": "A story", "tags": [], "image_url": IMAGE,
|
|
"logged_in": None, "gallery_urls": []}
|
|
|
|
|
|
def test_gallery_uses_full_lightbox_urls_in_spoilers_without_noscript_or_reply_duplicates():
|
|
full = "https://attachments.f95zone.to/2021/07/1312176_bg_mmoralescene06_01.jpg?h=test&e=123&u=42"
|
|
escaped = full.replace("&", "&")
|
|
html = page(post='<img class="bbImage" src="/cover.png" alt="F95 Cover">'
|
|
'<div class="bbCodeSpoiler">'
|
|
f'<a href="{escaped}" class="js-lbImage" data-fancybox="lb-post-2799977">'
|
|
'<img class="bbImage" src="/thumb/screen.jpg" width="100" height="57">'
|
|
'<noscript><img class="bbImage" src="/thumb/screen.jpg"></noscript></a>'
|
|
f'<a class="js-lbImage" href="{escaped}">duplicate</a>'
|
|
'<a class="js-lbImage" href="https://evil.test/a.jpg">external</a></div>',
|
|
reply='<a class="js-lbImage" href="/reply.jpg">reply</a>')
|
|
result = f95.parse(html)
|
|
assert result["gallery_urls"] == [full]
|
|
assert result["image_url"] == "https://f95zone.to/cover.png"
|
|
|
|
|
|
def test_gallery_deduplicates_signed_urls_and_excludes_cover():
|
|
html = page(post='<a class="js-lbImage" href="/cover.png?h=one">'
|
|
'<img class="bbImage" src="/cover.png?h=two" alt="Cover"></a>'
|
|
'<a class="js-lbImage" href="/screen.jpg?h=one">screen</a>'
|
|
'<a class="js-lbImage" href="/screen.jpg?h=two">duplicate</a>')
|
|
assert f95.parse(html)["gallery_urls"] == ["https://f95zone.to/screen.jpg?h=one"]
|
|
|
|
|
|
def test_mist_banner_beats_metadata_thumbnail_and_earlier_inline_image():
|
|
banner = ("https://attachments.f95zone.to/2022/03/1736427_F95Banner_Sideversion.png"
|
|
"?h=test-signature&e=1793113200&u=42")
|
|
escaped = banner.replace("&", "&")
|
|
html = page('<meta property="og:image" content="/attachments/thumbnail.jpg">',
|
|
'<img class="bbImage" src="/attachments/screenshot.jpg">'
|
|
f'<img src="{escaped}" data-src="{escaped}" data-url="" '
|
|
'class="bbImage lazyloaded" data-zoom-target="1" alt="F95 Cover.png" '
|
|
'title="F95 Cover.png" width="1200" height="500" loading="lazy">',
|
|
'<img class="bbImage" src="/attachments/reply-cover.png">')
|
|
assert f95.parse(html)["image_url"] == banner
|
|
|
|
|
|
@pytest.mark.parametrize("attributes", [
|
|
'alt="F95 Cover.png"', 'title="Game Cover"',
|
|
'data-src="/attachments/123_F95Banner_Sideversion.png"',
|
|
])
|
|
def test_cover_identified_by_alt_title_or_filename(attributes):
|
|
html = page(post='<img class="bbImage" src="/first.jpg">'
|
|
f'<img class="bbImage" src="/chosen.jpg" {attributes}>')
|
|
expected = "/attachments/123_F95Banner_Sideversion.png" if "data-src" in attributes else "/chosen.jpg"
|
|
assert f95.parse(html)["image_url"] == "https://f95zone.to" + expected
|
|
|
|
|
|
def test_first_post_image_preferred_over_og_thumbnail_without_cover_marker():
|
|
html = page('<meta property="og:image" content="/thumbnail.jpg">',
|
|
'<div class="bbCodeSpoiler"><img class="bbImage" src="/hidden-cover.png"></div>'
|
|
'<img class="bbImage" src="/first.jpg"><img class="bbImage" src="/second.jpg">')
|
|
assert f95.parse(html)["image_url"] == "https://f95zone.to/first.jpg"
|
|
|
|
|
|
def test_first_post_lazy_image_fallback_ignores_logo_spoilers_and_replies():
|
|
html = page('<meta property="og:image" content="/styles/logo.png">',
|
|
'<img class="avatar" src="/avatar.jpg">'
|
|
'<div class="bbCodeSpoiler"><img class="bbImage" src="/hidden.jpg"></div>'
|
|
'<img class="bbImage" src="data:image/gif;base64,AA" '
|
|
'data-src="//attachments.f95zone.to/preview.jpg">',
|
|
'<img class="bbImage" src="/reply.jpg">')
|
|
assert f95.parse(html)["image_url"] == IMAGE
|
|
assert f95.parse(page(reply='<img class="bbImage" src="/reply.jpg">'))["image_url"] is None
|
|
|
|
|
|
@pytest.mark.parametrize("url", ["", "http://127.0.0.1/a", "https://f95zone.to.evil.test/a",
|
|
"https://evil.test/a", "file:///tmp/a", "data:image/png,AA",
|
|
"https://user:pass@f95zone.to/a", "https://f95zone.to:bad/a"])
|
|
def test_untrusted_image_urls_are_rejected(url):
|
|
assert f95.image_url(url) is None
|
|
|
|
|
|
def test_external_redirect_is_rejected_before_request():
|
|
request = f95.urllib.request.Request(IMAGE)
|
|
with pytest.raises(f95.F95Error):
|
|
f95._ImageRedirect().redirect_request(request, None, 302, "Found", {}, "http://127.0.0.1/a")
|
|
|
|
|
|
def test_session_cookie_used_for_thread_and_image_but_never_external_redirects(monkeypatch):
|
|
cookie = "xf_user=test; xf_session=example"
|
|
requests = []
|
|
|
|
def open_request(request, timeout):
|
|
requests.append(request)
|
|
response = io.BytesIO(page().encode() if len(requests) == 1 else b"image")
|
|
response.geturl = lambda: request.full_url
|
|
response.headers = Message()
|
|
return response
|
|
|
|
monkeypatch.setattr(f95.urllib.request, "build_opener",
|
|
lambda *_: SimpleNamespace(open=open_request))
|
|
assert f95.load(THREAD, cookie, "Test browser")["title"] == "Example"
|
|
assert f95.fetch_image(IMAGE, THREAD, cookie, "Test browser") == b"image"
|
|
for request in requests:
|
|
assert request.get_header("Cookie") == "xf_user=test; xf_session=example"
|
|
assert request.get_header("User-agent") == "Test browser"
|
|
assert "Cookie" not in request.headers # not copied automatically on redirects
|
|
assert requests[1].get_header("Referer") == THREAD
|
|
redirected = f95._ImageRedirect().redirect_request(requests[1], None, 302, "Found", {},
|
|
"https://f95zone.to/attachments/preview.jpg")
|
|
assert redirected.get_header("Cookie") == "xf_user=test; xf_session=example"
|
|
for handler in (f95._F95Redirect(), f95._ImageRedirect()):
|
|
with pytest.raises(f95.F95Error):
|
|
handler.redirect_request(requests[0], None, 302, "Found", {}, "https://evil.test/a")
|
|
|
|
|
|
def test_missing_session_sends_no_cookie(monkeypatch):
|
|
assert not f95._request(THREAD).has_header("Cookie")
|
|
|
|
|
|
def test_auth_failure_reports_session_requirement_without_cookie(monkeypatch):
|
|
def denied(*args, **kwargs):
|
|
raise urllib.error.HTTPError(IMAGE, 403, "Forbidden", {}, None)
|
|
|
|
monkeypatch.setattr(f95.urllib.request, "build_opener", lambda *_: SimpleNamespace(open=denied))
|
|
with pytest.raises(f95.F95AuthError, match="не принял cookie") as error:
|
|
f95.fetch_image(IMAGE, THREAD, "xf_user=secret")
|
|
assert "secret" not in str(error.value)
|
|
|
|
|
|
def test_image_download_is_bounded(monkeypatch):
|
|
monkeypatch.setattr(f95, "MAX_IMAGE_BYTES", 8)
|
|
response = io.BytesIO(b"x" * 9)
|
|
response.headers = Message()
|
|
monkeypatch.setattr(f95.urllib.request, "build_opener",
|
|
lambda *_: SimpleNamespace(open=lambda *a, **kw: response))
|
|
with pytest.raises(f95.F95Error, match="слишком большое"):
|
|
f95.fetch_image(IMAGE, THREAD)
|
|
|
|
|
|
def test_import_stores_local_webp_and_keeps_metadata_on_image_failure(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
get_settings.cache_clear()
|
|
monkeypatch.setattr(f95, "load", lambda *_: {**f95.parse(page(
|
|
f'<meta property="og:image" content="{IMAGE}">')), "url": THREAD})
|
|
image = io.BytesIO()
|
|
Image.new("RGB", (1200, 800), "red").save(image, "PNG")
|
|
monkeypatch.setattr(f95, "fetch_image", lambda *_: image.getvalue())
|
|
try:
|
|
result = metadata.load_metadata(THREAD)
|
|
with Image.open(blob_path(result["cover_hash"])) as cover:
|
|
assert cover.format == "WEBP"
|
|
assert cover.size == (640, 427)
|
|
assert "image_url" not in result
|
|
prefix, encoded = result["cover_preview"].split(",", 1)
|
|
assert prefix == "data:image/webp;base64"
|
|
assert base64.b64decode(encoded) == blob_path(result["cover_hash"]).read_bytes()
|
|
assert metadata.MetaOut(**result).cover_hash == result["cover_hash"]
|
|
monkeypatch.setattr(f95, "fetch_image", lambda *_: b"not an image")
|
|
failed = metadata.load_metadata(THREAD)
|
|
assert failed["title"] == "Example"
|
|
assert failed["cover_hash"] is None
|
|
assert failed["cover_warning"]
|
|
finally:
|
|
get_settings.cache_clear()
|
|
|
|
|
|
def test_missing_preview_is_nonfatal(monkeypatch):
|
|
monkeypatch.setattr(f95, "load", lambda *_: {**f95.parse(page()), "url": THREAD})
|
|
result = metadata.load_metadata(THREAD)
|
|
assert result["cover_hash"] is None
|
|
assert result["cover_warning"]
|
|
assert result["description"] == "A story"
|
|
assert result["auth_required"] is True
|
|
|
|
|
|
def test_avif_banner_with_png_filename_converts_to_webp(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
get_settings.cache_clear()
|
|
source = io.BytesIO()
|
|
Image.new("RGB", (1200, 500), "orange").save(source, "AVIF")
|
|
monkeypatch.setattr(f95, "load", lambda *_: {**f95.parse(page(post=(
|
|
'<img class="bbImage" src="https://attachments.f95zone.to/2022/03/1736427_F95Banner_Sideversion.png" '
|
|
'alt="F95 Cover.png">'))), "url": THREAD})
|
|
monkeypatch.setattr(f95, "fetch_image", lambda *_: source.getvalue())
|
|
try:
|
|
result = metadata.load_metadata(THREAD, "xf_user=test")
|
|
assert not result.get("cover_warning")
|
|
assert not result.get("auth_required")
|
|
with Image.open(blob_path(result["cover_hash"])) as cover:
|
|
assert cover.format == "WEBP"
|
|
assert cover.size == (640, 267)
|
|
finally:
|
|
get_settings.cache_clear()
|
|
|
|
|
|
def test_missing_avif_codec_reports_the_real_failure(monkeypatch):
|
|
monkeypatch.setattr(f95, "fetch_image", lambda *_: b"\x00\x00\x00\x1cftypavif\x00\x00\x00\x00avif")
|
|
|
|
def unsupported(_):
|
|
raise UnidentifiedImageError("unsupported")
|
|
|
|
monkeypatch.setattr(metadata, "encode_cover", unsupported)
|
|
with pytest.raises(f95.F95Error, match="AVIF.*зависимости backend"):
|
|
metadata.import_cover(IMAGE, THREAD, "", "")
|
|
|
|
|
|
def test_cover_storage_failure_has_a_distinct_error(monkeypatch):
|
|
monkeypatch.setattr(f95, "fetch_image", lambda *_: b"image")
|
|
monkeypatch.setattr(metadata, "encode_cover", lambda *_: b"webp")
|
|
|
|
def denied(*_):
|
|
raise PermissionError("private path")
|
|
|
|
monkeypatch.setattr(metadata, "put_bytes", denied)
|
|
with pytest.raises(f95.F95Error, match="доступ к хранилищу") as error:
|
|
metadata.import_cover(IMAGE, THREAD, "", "")
|
|
assert "private path" not in str(error.value)
|
|
|
|
|
|
def test_gallery_import_stores_large_image_and_private_preview_with_partial_failures(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
get_settings.cache_clear()
|
|
image = io.BytesIO()
|
|
Image.new("RGB", (1600, 900), "blue").save(image, "AVIF")
|
|
full_url = "https://attachments.f95zone.to/original.jpg?h=valid"
|
|
monkeypatch.setattr(f95, "load", lambda *_: {"title": "Example", "description": "Story", "tags": [],
|
|
"url": THREAD, "image_url": None, "logged_in": "true",
|
|
"gallery_urls": [full_url, full_url + "&duplicate=1", "https://attachments.f95zone.to/failed.jpg"]})
|
|
calls = []
|
|
|
|
def fetch(url, thread, cookie, agent):
|
|
calls.append((url, thread, cookie, agent))
|
|
if "failed" in url:
|
|
raise f95.F95Error("Ошибка скачивания")
|
|
return image.getvalue()
|
|
|
|
monkeypatch.setattr(f95, "fetch_image", fetch)
|
|
try:
|
|
result = metadata.load_metadata(THREAD, "xf_user=personal", "Browser")
|
|
assert len(result["gallery"]) == 1 # identical images are stored only once
|
|
entry = result["gallery"][0]
|
|
with Image.open(blob_path(entry["hash"])) as stored:
|
|
assert stored.format == "WEBP"
|
|
assert stored.size == (1600, 900)
|
|
with Image.open(io.BytesIO(base64.b64decode(entry["preview"].split(",", 1)[1]))) as preview:
|
|
assert preview.size == (640, 360)
|
|
assert result["gallery_warning"].startswith("Не удалось загрузить изображений галереи: 1.")
|
|
assert all(call[1:] == (THREAD, "xf_user=personal", "Browser") for call in calls)
|
|
assert "gallery_urls" not in result
|
|
assert metadata.MetaOut(**result).gallery[0].hash == entry["hash"]
|
|
finally:
|
|
get_settings.cache_clear()
|
|
|
|
|
|
def test_gallery_auth_failure_requests_replacement_cookie_and_keeps_successes(monkeypatch):
|
|
monkeypatch.setattr(f95, "load", lambda *_: {"url": THREAD, "image_url": None, "logged_in": "true",
|
|
"gallery_urls": ["https://f95zone.to/a.jpg", "https://f95zone.to/b.jpg"]})
|
|
|
|
def load_image(url, *_):
|
|
if url.endswith("b.jpg"):
|
|
raise f95.F95AuthError("Нужна авторизация")
|
|
return "a" * 64 + ".webp", "data:image/webp;base64,test"
|
|
|
|
monkeypatch.setattr(metadata, "import_cover", load_image)
|
|
result = metadata.load_metadata(THREAD, "xf_user=expired")
|
|
assert result["auth_required"] is True
|
|
assert len(result["gallery"]) == 1
|
|
|
|
|
|
def test_gallery_is_saved_preserved_and_removed_with_game(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
get_settings.cache_clear()
|
|
image_hash = "a" * 64 + ".webp"
|
|
db = SimpleNamespace(add=lambda _: None, commit=AsyncMock())
|
|
user = SimpleNamespace(id=1)
|
|
game = SimpleNamespace(gallery_hashes=[image_hash])
|
|
monkeypatch.setattr(games, "get_game", AsyncMock(return_value=game))
|
|
monkeypatch.setattr(games, "can_edit_game", lambda *_: True)
|
|
try:
|
|
with pytest.raises(games.HTTPException):
|
|
asyncio.run(games.create_game(GameIn(title="Example", gallery_hashes=[image_hash]), user, db))
|
|
path = blob_path(image_hash)
|
|
path.parent.mkdir(parents=True)
|
|
path.write_bytes(b"image")
|
|
created = asyncio.run(games.create_game(GameIn(title="Example", gallery_hashes=[image_hash, image_hash]), user, db))
|
|
assert created.gallery_hashes == [image_hash]
|
|
asyncio.run(games.update_game(1, GameUpdateIn(title="Renamed"), user, db))
|
|
assert game.gallery_hashes == [image_hash]
|
|
asyncio.run(games.update_game(1, GameUpdateIn(gallery_hashes=[]), user, db))
|
|
assert game.gallery_hashes == []
|
|
with pytest.raises(ValidationError):
|
|
GameIn(title="Example", gallery_hashes=["../secret"])
|
|
finally:
|
|
get_settings.cache_clear()
|
|
|
|
|
|
def test_personal_sessions_encrypted_and_bound_to_user():
|
|
token = seal(1, "xf_user=private", "Browser")
|
|
assert "private" not in token
|
|
assert open_session(1, token) == ("xf_user=private", "Browser")
|
|
assert open_session(2, token) == ("", "")
|
|
assert open_session(1, "corrupted") == ("", "")
|
|
|
|
|
|
def test_login_page_requests_cookie_instead_of_discarding_form(monkeypatch):
|
|
with pytest.raises(f95.F95AuthError):
|
|
f95.parse('<html data-template="login"></html>')
|
|
|
|
def need_login(*_):
|
|
raise f95.F95AuthError("Нужна авторизация")
|
|
|
|
monkeypatch.setattr(f95, "load", need_login)
|
|
result = metadata.MetaOut(**metadata.load_metadata(THREAD))
|
|
assert result.auth_required is True
|
|
assert result.metadata_available is False
|
|
|
|
|
|
def test_expired_cookie_image_preserves_text_and_requests_replacement(monkeypatch):
|
|
monkeypatch.setattr(f95, "load", lambda *_: {**f95.parse(page(
|
|
f'<meta property="og:image" content="{IMAGE}">')), "url": THREAD})
|
|
|
|
def expired(*_):
|
|
raise f95.F95AuthError("Cookie истекли")
|
|
|
|
monkeypatch.setattr(f95, "fetch_image", expired)
|
|
result = metadata.load_metadata(THREAD, "xf_user=expired")
|
|
assert result["title"] == "Example"
|
|
assert result["auth_required"] is True
|
|
assert result["cover_hash"] is None
|
|
|
|
|
|
def test_user_session_endpoints_and_isolated_import(monkeypatch):
|
|
db = SimpleNamespace(commit=AsyncMock())
|
|
first = SimpleNamespace(id=1, permissions=["games.upload"], f95_session=None)
|
|
second = SimpleNamespace(id=2, permissions=["games.upload"], f95_session=None)
|
|
asyncio.run(metadata.save_f95_session(metadata.F95SessionIn(
|
|
cookie=SecretStr("Cookie: xf_user=first"), user_agent="Browser"), first, db))
|
|
assert first.f95_session and "xf_user" not in first.f95_session
|
|
assert second.f95_session is None
|
|
calls = []
|
|
|
|
def load(url, cookie, agent):
|
|
calls.append((cookie, agent))
|
|
return {"url": url, "title": "Example", "auth_required": False}
|
|
|
|
monkeypatch.setattr(metadata, "load_metadata", load)
|
|
asyncio.run(metadata.from_f95(metadata.F95In(url=THREAD), first, db))
|
|
asyncio.run(metadata.from_f95(metadata.F95In(url=THREAD), second, db))
|
|
assert calls == [("xf_user=first", "Browser"), ("", "")]
|
|
monkeypatch.setattr(metadata, "load_metadata", lambda *args: {"url": THREAD, "auth_required": True})
|
|
asyncio.run(metadata.from_f95(metadata.F95In(url=THREAD), first, db))
|
|
assert first.f95_session is None
|
|
asyncio.run(metadata.save_f95_session(metadata.F95SessionIn(cookie=SecretStr("xf_user=new")), first, db))
|
|
asyncio.run(metadata.delete_f95_session(first, db))
|
|
assert first.f95_session is None
|
|
|
|
|
|
@pytest.mark.parametrize("cookie", ["", "secret", "xf_user=one\r\nOther: two", "x=" + "a" * 17000])
|
|
def test_invalid_session_not_saved_or_echoed(cookie):
|
|
user = SimpleNamespace(id=1, permissions=["games.upload"], f95_session=None)
|
|
db = SimpleNamespace(commit=AsyncMock())
|
|
with pytest.raises(metadata.HTTPException) as error:
|
|
asyncio.run(metadata.save_f95_session(metadata.F95SessionIn(cookie=SecretStr(cookie)), user, db))
|
|
assert user.f95_session is None
|
|
assert not db.commit.called
|
|
assert "xf_user" not in error.value.detail
|
|
|
|
|
|
@pytest.mark.parametrize(("values", "expected"), [
|
|
({"xf_user": "123,remember"}, "xf_user=123%2Cremember"),
|
|
({"xf_user": "123%2Cremember"}, "xf_user=123%2Cremember"),
|
|
({"xf_session": "session-token"}, "xf_session=session-token"),
|
|
({"xf_user": "remember", "xf_session": "session", "cf_clearance": "cf.signature=="},
|
|
"xf_user=remember; xf_session=session; cf_clearance=cf.signature=="),
|
|
])
|
|
def test_individual_cookie_values_preserve_encoded_and_decoded_devtools_values(values, expected):
|
|
user = SimpleNamespace(id=1, permissions=["games.upload"], f95_session=None)
|
|
db = SimpleNamespace(commit=AsyncMock())
|
|
asyncio.run(metadata.save_f95_session(metadata.F95SessionIn(**values), user, db))
|
|
assert open_session(1, user.f95_session) == (expected, "")
|
|
|
|
|
|
@pytest.mark.parametrize("values", [
|
|
{"cf_clearance": "only-cloudflare"},
|
|
{"xf_user": "one; xf_session=two"},
|
|
{"xf_user": "one\nsecond-line"},
|
|
])
|
|
def test_individual_cookie_fields_reject_missing_login_and_multiple_values(values):
|
|
user = SimpleNamespace(id=1, permissions=["games.upload"], f95_session=None)
|
|
db = SimpleNamespace(commit=AsyncMock())
|
|
with pytest.raises(metadata.HTTPException):
|
|
asyncio.run(metadata.save_f95_session(metadata.F95SessionIn(**values), user, db))
|
|
assert user.f95_session is None
|
|
|
|
|
|
@pytest.mark.parametrize("schema", [GameIn, GameUpdateIn])
|
|
def test_cover_hash_cannot_be_a_path_or_arbitrary_blob(schema):
|
|
for value in ("../cover.webp", "a" * 64 + ".html", "invalid"):
|
|
with pytest.raises(ValidationError):
|
|
schema(title="Example", cover_hash=value)
|
|
|
|
|
|
def test_cover_applied_only_when_saving_and_missing_blob_rejected(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
get_settings.cache_clear()
|
|
cover_hash = "a" * 64 + ".webp"
|
|
db = SimpleNamespace(add=lambda _: None, commit=AsyncMock())
|
|
user = SimpleNamespace(id=1)
|
|
game = SimpleNamespace(title="Example", cover_hash="old.webp")
|
|
monkeypatch.setattr(games, "get_game", AsyncMock(return_value=game))
|
|
monkeypatch.setattr(games, "can_edit_game", lambda *_: True)
|
|
try:
|
|
with pytest.raises(games.HTTPException) as error:
|
|
asyncio.run(games.create_game(GameIn(title="Example", cover_hash=cover_hash), user, db))
|
|
assert error.value.status_code == 422
|
|
path = blob_path(cover_hash)
|
|
path.parent.mkdir(parents=True)
|
|
path.write_bytes(b"cover")
|
|
created = asyncio.run(games.create_game(GameIn(title="Example", cover_hash=cover_hash), user, db))
|
|
assert created.cover_hash == cover_hash
|
|
asyncio.run(games.update_game(1, GameUpdateIn(title="Renamed"), user, db))
|
|
assert game.cover_hash == "old.webp"
|
|
asyncio.run(games.update_game(1, GameUpdateIn(cover_hash=cover_hash), user, db))
|
|
assert game.cover_hash == cover_hash
|
|
finally:
|
|
get_settings.cache_clear()
|