Files
romm/backend/tests/handler/test_fastapi.py
Georges-Antoine Assi 99e8f08513 Use mD5/shA1 keys in Hasheous lookup payload
Match the json-patch camelCase key names the Hasheous endpoint expects,
and update the lookup_rom test assertions accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:36:31 -04:00

379 lines
12 KiB
Python

from unittest.mock import AsyncMock, patch
import pytest
from handler.database import db_platform_handler, db_rom_handler
from handler.metadata import (
meta_hasheous_handler,
meta_playmatch_handler,
meta_ra_handler,
)
from handler.metadata.hasheous_handler import HasheousRom
from handler.metadata.ra_handler import RAGameRom
from handler.scan_handler import MetadataSource, ScanType, scan_platform, scan_rom
from models.platform import Platform
from models.rom import Rom, RomFile
from utils.context import initialize_context
@pytest.mark.vcr
async def test_scan_platform():
async with initialize_context():
platform = await scan_platform("n64", ["n64"])
assert type(platform) is Platform
assert platform.fs_slug == "n64"
assert platform.slug == "n64"
assert platform.name == "Nintendo 64"
assert platform.igdb_id == 4
assert platform.hasheous_id == 64
# Hasheous returns tgdb_id=None and Moby has no tgdb_id for n64, so
# this value must come from the TGDB handler fallback.
assert platform.tgdb_id == 3
async with initialize_context():
platform = await scan_platform("", [])
assert platform.fs_slug == ""
assert platform.slug == ""
assert platform.name == ""
assert platform.igdb_id is None
assert platform.hasheous_id is None
assert platform.tgdb_id is None
@pytest.mark.vcr
async def test_scan_rom():
platform = Platform(
id=1, slug="n64", fs_slug="n64", name="Nintendo 64", igdb_id=4, hasheous_id=64
)
platform = db_platform_handler.add_platform(platform)
rom = Rom(
platform_id=platform.id,
fs_name="Paper Mario (USA).z64",
fs_name_no_tags="Paper Mario",
fs_name_no_ext="Paper Mario",
fs_extension="z64",
fs_path="n64/Paper Mario (USA)",
name="Paper Mario",
igdb_id=3340,
hasheous_id=4872,
fs_size_bytes=1024,
tags=[],
)
async with initialize_context():
rom = await scan_rom(
platform=platform,
scan_type=ScanType.QUICK,
rom=rom,
fs_rom={
"fs_name": "Paper Mario (USA).z64",
"flat": True,
"nested": False,
"files": [
RomFile(
rom=rom,
file_name="Paper Mario (USA).z64",
file_path="n64/Paper Mario (USA)",
file_size_bytes=23175094,
last_modified=1620000000,
crc_hash="d56d1c89",
md5_hash="7de64234ee20788b9d74d2fdb3462aed",
sha1_hash="77693a00418a9d8971b7a005f2001d997e359bff",
)
],
"crc_hash": "d56d1c89",
"md5_hash": "7de64234ee20788b9d74d2fdb3462aed",
"sha1_hash": "77693a00418a9d8971b7a005f2001d997e359bff",
"ra_hash": "",
},
metadata_sources=[MetadataSource.HASHEOUS],
newly_added=True,
)
assert type(rom) is Rom
assert rom.fs_name == "Paper Mario (USA).z64"
assert rom.fs_path == "n64/Paper Mario (USA)"
# Disabled until we can fix the tests
# assert rom.name == "Paper Mario"
# assert rom.igdb_id == 3340
# assert rom.hasheous_id == 4872
# assert rom.fs_size_bytes == 23175094
# assert rom.tags == []
@patch.object(meta_playmatch_handler, "is_enabled", return_value=False)
@patch.object(meta_hasheous_handler, "get_ra_game", new_callable=AsyncMock)
@patch.object(meta_hasheous_handler, "get_igdb_game", new_callable=AsyncMock)
@patch.object(meta_hasheous_handler, "lookup_rom", new_callable=AsyncMock)
async def test_scan_rom_complete_clears_unselected_metadata(
mock_lookup, mock_get_igdb, mock_get_ra, mock_playmatch_enabled
):
"""COMPLETE rescan with newly_added=False must clear id and *_metadata
fields for sources that are no longer in metadata_sources."""
hasheous_result = HasheousRom(
hasheous_id=999,
igdb_id=None,
tgdb_id=None,
ra_id=None,
name="Mock Hasheous Game",
)
mock_lookup.return_value = hasheous_result
mock_get_igdb.return_value = hasheous_result
mock_get_ra.return_value = hasheous_result
platform = Platform(
id=1,
slug="n64",
fs_slug="n64",
name="Nintendo 64",
igdb_id=4,
ra_id=2,
hasheous_id=64,
)
platform = db_platform_handler.add_platform(platform)
rom = Rom(
platform_id=platform.id,
fs_name="Paper Mario (USA).z64",
fs_name_no_tags="Paper Mario",
fs_name_no_ext="Paper Mario",
fs_extension="z64",
fs_path="n64/Paper Mario (USA)",
name="Paper Mario",
igdb_id=3340,
igdb_metadata={"summary": "stale IGDB metadata"},
ra_id=1234,
ra_metadata={"name": "stale RA metadata"},
hasheous_id=4872,
fs_size_bytes=1024,
tags=[],
)
rom = db_rom_handler.add_rom(rom)
async with initialize_context():
result = await scan_rom(
platform=platform,
scan_type=ScanType.COMPLETE,
rom=rom,
fs_rom={
"fs_name": "Paper Mario (USA).z64",
"flat": True,
"nested": False,
"files": [],
"crc_hash": "",
"md5_hash": "",
"sha1_hash": "",
"ra_hash": "",
},
metadata_sources=[MetadataSource.HASHEOUS],
newly_added=False,
)
# IGDB and RA were unselected — their id and metadata must be cleared.
assert result.igdb_id is None
assert result.igdb_metadata == {}
assert result.ra_id is None
assert result.ra_metadata == {}
# Hasheous is still selected and should remain populated.
assert result.hasheous_id == 999
@patch.object(meta_playmatch_handler, "is_enabled", return_value=False)
@patch.object(meta_ra_handler, "get_rom_by_id", new_callable=AsyncMock)
@patch.object(meta_ra_handler, "get_rom", new_callable=AsyncMock)
async def test_scan_rom_unmatched_fetches_ra_when_id_set_but_no_metadata(
mock_get_rom, mock_get_rom_by_id, mock_playmatch_enabled
):
"""UNMATCHED scan must fetch RA metadata when ra_id is set manually but
ra_metadata is empty (the user manually set the ID)."""
ra_result = RAGameRom(
ra_id=2774,
name="Jak and Daxter: The Precursor's Legacy",
url_cover="https://media.retroachievements.org/Images/jpg",
)
mock_get_rom_by_id.return_value = ra_result
mock_get_rom.return_value = RAGameRom(ra_id=None)
platform = Platform(
id=1,
slug="ps2",
fs_slug="ps2",
name="PlayStation 2",
igdb_id=8,
ra_id=21,
)
platform = db_platform_handler.add_platform(platform)
# ROM has ra_id set manually but no ra_metadata (never fetched before)
rom = Rom(
platform_id=platform.id,
fs_name="Jak and Daxter.chd",
fs_name_no_tags="Jak and Daxter",
fs_name_no_ext="Jak and Daxter",
fs_extension="chd",
fs_path="ps2",
name="Jak and Daxter",
ra_id=2774,
ra_metadata={}, # empty - never fetched
fs_size_bytes=1024,
tags=[],
)
rom = db_rom_handler.add_rom(rom)
async with initialize_context():
result = await scan_rom(
platform=platform,
scan_type=ScanType.UNMATCHED,
rom=rom,
fs_rom={
"fs_name": "Jak and Daxter.chd",
"flat": True,
"nested": False,
"files": [],
"crc_hash": "",
"md5_hash": "",
"sha1_hash": "",
"ra_hash": "",
},
metadata_sources=[MetadataSource.RA],
newly_added=False,
)
# ra_id was set manually - get_rom_by_id should be called, not get_rom
mock_get_rom_by_id.assert_called_once()
mock_get_rom.assert_not_called()
assert result.ra_id == 2774
@patch.object(meta_playmatch_handler, "is_enabled", return_value=False)
@patch.object(meta_ra_handler, "get_rom_by_id", new_callable=AsyncMock)
@patch.object(meta_ra_handler, "get_rom", new_callable=AsyncMock)
async def test_scan_rom_unmatched_skips_ra_when_id_and_metadata_exist(
mock_get_rom, mock_get_rom_by_id, mock_playmatch_enabled
):
"""UNMATCHED scan must NOT re-fetch RA metadata when both ra_id and
ra_metadata are already populated."""
mock_get_rom_by_id.return_value = RAGameRom(ra_id=None)
mock_get_rom.return_value = RAGameRom(ra_id=None)
platform = Platform(
id=1,
slug="ps2",
fs_slug="ps2",
name="PlayStation 2",
igdb_id=8,
ra_id=21,
)
platform = db_platform_handler.add_platform(platform)
# ROM has both ra_id and ra_metadata populated
rom = Rom(
platform_id=platform.id,
fs_name="Jak and Daxter.chd",
fs_name_no_tags="Jak and Daxter",
fs_name_no_ext="Jak and Daxter",
fs_extension="chd",
fs_path="ps2",
name="Jak and Daxter",
ra_id=2774,
ra_metadata={"achievements_count": 60}, # already populated
fs_size_bytes=1024,
tags=[],
)
rom = db_rom_handler.add_rom(rom)
async with initialize_context():
result = await scan_rom(
platform=platform,
scan_type=ScanType.UNMATCHED,
rom=rom,
fs_rom={
"fs_name": "Jak and Daxter.chd",
"flat": True,
"nested": False,
"files": [],
"crc_hash": "",
"md5_hash": "",
"sha1_hash": "",
"ra_hash": "",
},
metadata_sources=[MetadataSource.RA],
newly_added=False,
)
# Both ID and metadata exist - should not re-fetch
mock_get_rom_by_id.assert_not_called()
mock_get_rom.assert_not_called()
# Existing ra_id should be preserved
assert result.ra_id == 2774
def _top_level_rom_file(**kwargs) -> RomFile:
"""Build a RomFile whose `is_top_level` cached_property is pre-seeded to
True, so it passes lookup_rom's filtering without a persisted rom."""
file = RomFile(file_path="n64/Game", **kwargs)
file.__dict__["is_top_level"] = True
return file
@patch.object(meta_hasheous_handler, "_request", new_callable=AsyncMock)
@patch.object(meta_hasheous_handler, "is_enabled", return_value=True)
async def test_lookup_rom_sends_all_top_level_file_hashes(
mock_is_enabled, mock_request
):
"""lookup_rom must send the hashes of every top-level file as a list,
using chd_sha1_hash (and only it) for files that have one, and skipping
files with no hashes or zero size."""
mock_request.return_value = {}
files = [
_top_level_rom_file(
file_name="disc1.bin",
file_size_bytes=100,
md5_hash="md5one",
sha1_hash="sha1one",
crc_hash="crcone",
),
# CHD file: only chd_sha1_hash should be sent, raw md5/crc ignored.
_top_level_rom_file(
file_name="disc2.chd",
file_size_bytes=200,
md5_hash="ignoredmd5",
crc_hash="ignoredcrc",
chd_sha1_hash="chdsha1",
),
# Zero-size file: must be filtered out entirely.
_top_level_rom_file(
file_name="empty.bin",
file_size_bytes=0,
md5_hash="zeromd5",
),
# No hashes at all: must be skipped.
_top_level_rom_file(file_name="nohash.bin", file_size_bytes=50),
]
result = await meta_hasheous_handler.lookup_rom("n64", files)
assert result["hasheous_id"] is None
mock_request.assert_called_once()
sent_data = mock_request.call_args.kwargs["data"]
assert sent_data == [
{"mD5": "md5one", "shA1": "sha1one", "crc": "crcone"},
{"shA1": "chdsha1"},
]
@patch.object(meta_hasheous_handler, "_request", new_callable=AsyncMock)
@patch.object(meta_hasheous_handler, "is_enabled", return_value=True)
async def test_lookup_rom_skips_request_when_no_hashes(mock_is_enabled, mock_request):
"""lookup_rom must not hit the API when no file has any usable hash."""
files = [_top_level_rom_file(file_name="nohash.bin", file_size_bytes=50)]
result = await meta_hasheous_handler.lookup_rom("n64", files)
assert result["hasheous_id"] is None
mock_request.assert_not_called()