Skip to content

Commit 2203faa

Browse files
authored
Merge pull request #3403 from rommapp/claude/exciting-heisenberg-61PF9
Integrate Hypothesis property-based testing for the backend
2 parents 1e97483 + e657948 commit 2203faa

11 files changed

Lines changed: 352 additions & 5 deletions

File tree

.github/workflows/pytest.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ jobs:
8080
ROMM_DB_DRIVER: ${{ matrix.db }}
8181
REDIS_HOST: 127.0.0.1
8282
REDIS_PORT: ${{ job.services.valkey.ports['6379'] }}
83+
HYPOTHESIS_PROFILE: ci
8384
run: |
8485
cd backend
8586
uv run pytest -vv --maxfail=10 --junitxml=pytest-report.xml --cov --cov-report xml:coverage.xml --cov-config=.coveragerc .

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ romm_mock
5454
backend/romm_test/resources
5555
backend/romm_test/logs
5656
.pytest_cache
57+
.hypothesis
5758
/romm_test
5859
.coverage
5960
pytest-report.xml

backend/tests/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import os
12
from datetime import datetime, timedelta, timezone
23

34
import alembic.config
45
import pytest
6+
from hypothesis import settings
57
from joserfc import jwt
68
from sqlalchemy import create_engine
79
from sqlalchemy.orm import sessionmaker
@@ -30,6 +32,10 @@
3032
engine = create_engine(ConfigManager.get_db_engine(), pool_pre_ping=True)
3133
session = sessionmaker(bind=engine, expire_on_commit=False)
3234

35+
settings.register_profile("ci", max_examples=200, deadline=None)
36+
settings.register_profile("dev", max_examples=50, deadline=None)
37+
settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "dev"))
38+
3339

3440
@pytest.fixture(scope="session", autouse=True)
3541
def setup_database():

backend/tests/handler/filesystem/test_roms_handler.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import os
22
import shutil
3+
import tempfile
34
from pathlib import Path
45
from unittest.mock import Mock, patch
56

67
import pytest
8+
from hypothesis import assume, given
9+
from hypothesis import strategies as st
710
from tests._zipfile_shim import reload_zipfile
811

912
from config.config_manager import LIBRARY_BASE_PATH, Config
13+
from handler.filesystem.base_handler import (
14+
LANGUAGES_BY_SHORTCODE,
15+
REGIONS_BY_SHORTCODE,
16+
)
1017
from handler.filesystem.roms_handler import (
1118
FileHash,
1219
FSRomsHandler,
@@ -1546,3 +1553,99 @@ def test_extract_chd_hash_max_sha1(self, tmp_path):
15461553
assert result
15471554
assert result == "f" * 40
15481555
assert len(result) == 40
1556+
1557+
1558+
KNOWN_REGION_NAMES = frozenset(REGIONS_BY_SHORTCODE.values())
1559+
KNOWN_LANGUAGE_NAMES = frozenset(LANGUAGES_BY_SHORTCODE.values())
1560+
1561+
region_code = st.sampled_from(sorted(REGIONS_BY_SHORTCODE))
1562+
language_code = st.sampled_from(sorted(LANGUAGES_BY_SHORTCODE))
1563+
1564+
1565+
class TestParseTagsProperties:
1566+
"""Property-based tests for FSRomsHandler.parse_tags."""
1567+
1568+
handler = FSRomsHandler()
1569+
1570+
@given(st.text())
1571+
def test_never_raises_on_arbitrary_input(self, fs_name: str):
1572+
self.handler.parse_tags(fs_name)
1573+
1574+
@given(st.text())
1575+
def test_is_deterministic(self, fs_name: str):
1576+
assert self.handler.parse_tags(fs_name) == self.handler.parse_tags(fs_name)
1577+
1578+
@given(st.lists(region_code), st.lists(language_code))
1579+
def test_known_codes_map_to_known_names(self, regions, languages):
1580+
fs_name = "Game"
1581+
for code in regions:
1582+
fs_name += f"({code})"
1583+
for code in languages:
1584+
fs_name += f"({code})"
1585+
fs_name += ".rom"
1586+
1587+
parsed = self.handler.parse_tags(fs_name)
1588+
1589+
assert set(parsed.regions) <= KNOWN_REGION_NAMES
1590+
assert set(parsed.languages) <= KNOWN_LANGUAGE_NAMES
1591+
# Every supplied code resolves to its mapped full name.
1592+
assert {REGIONS_BY_SHORTCODE[c] for c in regions} <= set(parsed.regions)
1593+
assert {LANGUAGES_BY_SHORTCODE[c] for c in languages} <= set(parsed.languages)
1594+
1595+
1596+
def _chd_header_bytes(version: int, sha1: bytes) -> bytes:
1597+
"""Build a 124-byte CHD header with the given version and combined SHA1."""
1598+
header = bytearray(124)
1599+
header[0:8] = b"MComprHD"
1600+
header[12:16] = version.to_bytes(4, "big")
1601+
header[84:104] = sha1
1602+
return bytes(header)
1603+
1604+
1605+
def _run_extract(data: bytes) -> str:
1606+
fd, path = tempfile.mkstemp(suffix=".chd")
1607+
try:
1608+
with os.fdopen(fd, "wb") as f:
1609+
f.write(data)
1610+
return extract_chd_hash(Path(path))
1611+
finally:
1612+
os.unlink(path)
1613+
1614+
1615+
class TestExtractCHDHashProperties:
1616+
"""Property-based tests for extract_chd_hash."""
1617+
1618+
@given(sha1=st.binary(min_size=20, max_size=20))
1619+
def test_valid_v5_returns_embedded_sha1(self, sha1: bytes):
1620+
result = _run_extract(_chd_header_bytes(5, sha1))
1621+
assert result == sha1.hex()
1622+
assert len(result) == 40
1623+
1624+
@given(
1625+
version=st.integers(min_value=0, max_value=2**32 - 1),
1626+
sha1=st.binary(min_size=20, max_size=20),
1627+
)
1628+
def test_non_v5_version_returns_empty(self, version: int, sha1: bytes):
1629+
assume(version != 5)
1630+
assert _run_extract(_chd_header_bytes(version, sha1)) == ""
1631+
1632+
@given(data=st.binary(min_size=16))
1633+
def test_wrong_signature_returns_empty(self, data: bytes):
1634+
assume(data[:8] != b"MComprHD")
1635+
assert _run_extract(data) == ""
1636+
1637+
@given(data=st.binary(max_size=15))
1638+
def test_too_short_for_signature_returns_empty(self, data: bytes):
1639+
assert _run_extract(data) == ""
1640+
1641+
@given(
1642+
sha1=st.binary(min_size=20, max_size=20),
1643+
truncate_len=st.integers(min_value=16, max_value=103),
1644+
)
1645+
def test_truncated_before_sha1_returns_empty(self, sha1: bytes, truncate_len: int):
1646+
truncated = _chd_header_bytes(5, sha1)[:truncate_len]
1647+
assert _run_extract(truncated) == ""
1648+
1649+
@given(data=st.binary())
1650+
def test_never_raises_on_arbitrary_bytes(self, data: bytes):
1651+
_run_extract(data)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Property-based tests for the LaunchBox metadata parsing helpers."""
2+
3+
from datetime import datetime
4+
5+
from hypothesis import assume, given
6+
from hypothesis import strategies as st
7+
8+
from handler.metadata.launchbox_handler.utils import (
9+
dedupe_words,
10+
parse_list,
11+
parse_release_date,
12+
sanitize_filename,
13+
)
14+
15+
LB_INVALID_CHARS = set("\\/|<>\"?*:'")
16+
17+
18+
class TestParseList:
19+
@given(st.text() | st.none())
20+
def test_elements_are_stripped_and_non_empty(self, value):
21+
result = parse_list(value)
22+
assert isinstance(result, list)
23+
for item in result:
24+
assert item == item.strip()
25+
assert item != ""
26+
27+
@given(st.lists(st.text(alphabet=st.characters(blacklist_characters=";,"))))
28+
def test_roundtrip_via_semicolon_join(self, items):
29+
cleaned = [i.strip() for i in items if i.strip()]
30+
assert parse_list(";".join(cleaned)) == cleaned
31+
32+
33+
class TestDedupeWords:
34+
@given(st.lists(st.text() | st.none()))
35+
def test_no_duplicate_lowercased_keys(self, values):
36+
result = dedupe_words(values)
37+
keys = [v.lower() for v in result]
38+
assert len(keys) == len(set(keys))
39+
40+
@given(st.lists(st.text() | st.none()))
41+
def test_result_is_subset_of_stripped_inputs(self, values):
42+
stripped = {v.strip() for v in values if v is not None and v.strip()}
43+
result = dedupe_words(values)
44+
for item in result:
45+
assert item in stripped
46+
47+
48+
class TestParseReleaseDate:
49+
@given(st.text() | st.none())
50+
def test_returns_none_or_int(self, value):
51+
result = parse_release_date(value)
52+
assert result is None or isinstance(result, int)
53+
54+
@given(
55+
st.datetimes(
56+
min_value=datetime(1971, 1, 1),
57+
max_value=datetime(2100, 1, 1),
58+
)
59+
)
60+
def test_valid_iso_dates_parse_to_timestamp(self, dt):
61+
result = parse_release_date(dt.isoformat())
62+
assert result == int(dt.timestamp())
63+
64+
@given(st.dates(min_value=datetime(1971, 1, 1).date()))
65+
def test_date_only_format_parses(self, d):
66+
result = parse_release_date(d.isoformat())
67+
expected = int(
68+
datetime(d.year, d.month, d.day).timestamp(),
69+
)
70+
assert result == expected
71+
72+
73+
class TestSanitizeFilename:
74+
@given(st.text())
75+
def test_output_contains_no_invalid_characters(self, stem):
76+
result = sanitize_filename(stem)
77+
assert not (set(result) & LB_INVALID_CHARS)
78+
79+
@given(st.text())
80+
def test_is_idempotent(self, stem):
81+
once = sanitize_filename(stem)
82+
assert sanitize_filename(once) == once
83+
84+
@given(st.text())
85+
def test_no_leading_or_trailing_space_or_dot(self, stem):
86+
result = sanitize_filename(stem)
87+
assume(result != "")
88+
assert result == result.strip(" .")

backend/tests/utils/test_filesystem.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@
55
from unittest.mock import patch
66

77
import pytest
8+
from hypothesis import assume, given
9+
from hypothesis import strategies as st
810

9-
from utils.filesystem import link_or_copy_file
11+
from utils.filesystem import link_or_copy_file, sanitize_filename
12+
13+
INVALID_AFTER_SANITIZE = set('\\/:|*?"<>+\0')
1014

1115

1216
class TestLinkOrCopyFile:
@@ -180,3 +184,35 @@ def test_helper_uses_os_link_first(self, tmp_path):
180184
assert args[0] == source
181185
assert args[1].parent == dest.parent
182186
assert args[1].name.startswith(".romm_link_tmp_")
187+
188+
189+
class TestSanitizeFilename:
190+
"""Property-based tests for the cross-filesystem filename sanitizer."""
191+
192+
def _sanitize_or_none(self, name: str) -> str | None:
193+
try:
194+
return sanitize_filename(name)
195+
except ValueError:
196+
return None
197+
198+
@given(st.text())
199+
def test_output_contains_no_invalid_characters(self, name: str):
200+
result = self._sanitize_or_none(name)
201+
assume(result is not None)
202+
assert result is not None
203+
assert not (set(result) & INVALID_AFTER_SANITIZE)
204+
205+
@given(st.text())
206+
def test_output_is_stripped(self, name: str):
207+
result = self._sanitize_or_none(name)
208+
assume(result is not None)
209+
assert result is not None
210+
assert result == result.strip()
211+
assert result != ""
212+
213+
@given(st.text())
214+
def test_is_idempotent(self, name: str):
215+
once = self._sanitize_or_none(name)
216+
assume(once is not None)
217+
assert once is not None
218+
assert sanitize_filename(once) == once
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import re
2+
3+
from hypothesis import given
4+
from hypothesis import strategies as st
5+
6+
from utils.hashing import crc32_to_hex
7+
8+
HEX_8 = re.compile(r"^[0-9a-f]{8}$")
9+
10+
11+
@given(st.integers(min_value=0, max_value=2**32 - 1))
12+
def test_crc32_to_hex_is_8_lowercase_hex_chars(value: int):
13+
result = crc32_to_hex(value)
14+
assert HEX_8.match(result)
15+
16+
17+
@given(st.integers())
18+
def test_crc32_to_hex_roundtrips_modulo_32_bits(value: int):
19+
result = crc32_to_hex(value)
20+
assert int(result, 16) == value & 0xFFFFFFFF

0 commit comments

Comments
 (0)