test_firmware_versions.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. """
  2. Unit tests for firmware version listing.
  3. Covers:
  4. - Wiki-page version extraction is restricted to section-heading anchors
  5. (incidental version-like strings in release-note prose must be ignored).
  6. - Merging wiki + download-page versions produces a single list where
  7. wiki-only versions are flagged as unavailable (no download URL).
  8. """
  9. from unittest.mock import AsyncMock, patch
  10. import pytest
  11. from backend.app.services.firmware_check import FirmwareCheckService, FirmwareVersion
  12. WIKI_SAMPLE = """
  13. <h2 id="h-01030000-20260303" class="toc-header">01.03.00.00 (20260303)</h2>
  14. <p>Released 20260303</p>
  15. <ul><li>Optimized AMS 2 Pro (requires AMS firmware OTA v02.00.19.47 or newer).</li></ul>
  16. <h2 id="h-01021000-20260209" class="toc-header">01.02.10.00 (20260209)</h2>
  17. <p>Bug fixes.</p>
  18. <h2 id="h-01020200-20251105" class="toc-header">01.02.02.00 (20251105)</h2>
  19. <p>Some more text referencing 00.00.00.00 incidentally.</p>
  20. """
  21. @pytest.mark.asyncio
  22. async def test_wiki_extraction_ignores_prose_version_mentions():
  23. """02.00.19.47 appears only in release notes prose — it must not be listed."""
  24. svc = FirmwareCheckService()
  25. mock_resp = AsyncMock()
  26. mock_resp.status_code = 200
  27. mock_resp.text = WIKI_SAMPLE
  28. with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
  29. versions = await svc._fetch_all_versions_from_wiki("h2d")
  30. version_strs = [v for v, _ in versions]
  31. assert version_strs == ["01.03.00.00", "01.02.10.00", "01.02.02.00"]
  32. # The AMS firmware mentioned in prose must not leak in:
  33. assert "02.00.19.47" not in version_strs
  34. assert "00.00.00.00" not in version_strs
  35. # Release dates are captured from the anchor id:
  36. assert versions[0][1] == "20260303"
  37. @pytest.mark.asyncio
  38. async def test_wiki_extraction_returns_empty_for_unknown_api_key():
  39. svc = FirmwareCheckService()
  40. assert await svc._fetch_all_versions_from_wiki("no-such-key") == []
  41. # P2S and X2D wiki pages publish anchor ids without a dash between the
  42. # version bytes and the date (e.g. h-0102000020260409). Regression for #1030
  43. # where the anchor regex required a dash and silently returned no versions,
  44. # causing the UI to fall back to the stale download-page "Latest" value.
  45. P2S_NODASH_ANCHOR_SAMPLE = """
  46. <h2 id="h-0102000020260409" class="toc-header">01.02.00.00(20260409)</h2>
  47. <h2 id="h-0101030020260209" class="toc-header">01.01.03.00(20260209)</h2>
  48. <h2 id="h-0101010020251208" class="toc-header">01.01.01.00(20251208)</h2>
  49. """
  50. @pytest.mark.asyncio
  51. async def test_wiki_extraction_accepts_nodash_anchors():
  52. """P2S/X2D anchors concatenate version+date with no dash — must still parse."""
  53. svc = FirmwareCheckService()
  54. mock_resp = AsyncMock()
  55. mock_resp.status_code = 200
  56. mock_resp.text = P2S_NODASH_ANCHOR_SAMPLE
  57. with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
  58. versions = await svc._fetch_all_versions_from_wiki("p2s")
  59. assert [v for v, _ in versions] == ["01.02.00.00", "01.01.03.00", "01.01.01.00"]
  60. assert versions[0][1] == "20260409"
  61. # A1, A1-mini and P2S pages render dates in full-width parens (YYYYMMDD)
  62. # rather than ASCII parens (YYYYMMDD). Pages without version-anchors fall
  63. # through to the text-based regex, so it must accept both paren styles.
  64. FULLWIDTH_PAREN_FALLBACK_SAMPLE = """
  65. <h2>01.04.00.01 (20260401)</h2>
  66. <h2>01.03.00.00 (20260101)</h2>
  67. """
  68. @pytest.mark.asyncio
  69. async def test_wiki_extraction_fallback_accepts_fullwidth_parens():
  70. svc = FirmwareCheckService()
  71. mock_resp = AsyncMock()
  72. mock_resp.status_code = 200
  73. mock_resp.text = FULLWIDTH_PAREN_FALLBACK_SAMPLE
  74. with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
  75. versions = await svc._fetch_all_versions_from_wiki("a1")
  76. assert [v for v, _ in versions] == ["01.04.00.01", "01.03.00.00"]
  77. assert versions[0][1] == "20260401"
  78. @pytest.mark.asyncio
  79. async def test_get_available_versions_merges_sources():
  80. """
  81. Merged list must include all wiki versions (newest first), populating
  82. download URL + notes from the download-page JSON when present, and
  83. leaving download_url empty when the file is not published.
  84. """
  85. svc = FirmwareCheckService()
  86. wiki = [
  87. ("01.03.00.00", "20260303"),
  88. ("01.02.10.00", "20260209"), # wiki-only — should be "unavailable"
  89. ("01.02.02.00", "20251105"),
  90. ]
  91. download = [
  92. FirmwareVersion(
  93. version="01.03.00.00",
  94. download_url="https://cdn.example/1.bin",
  95. release_notes="notes 1.3",
  96. release_time="2026-03-03",
  97. ),
  98. FirmwareVersion(
  99. version="01.02.02.00",
  100. download_url="https://cdn.example/2.bin",
  101. release_notes="notes 1.2.2",
  102. release_time="2025-11-05",
  103. ),
  104. ]
  105. with (
  106. patch.object(svc, "_fetch_all_versions_from_wiki", AsyncMock(return_value=wiki)),
  107. patch.object(svc, "_fetch_all_versions_from_download_page", AsyncMock(return_value=download)),
  108. ):
  109. result = await svc.get_available_versions("H2D")
  110. assert [v.version for v in result] == ["01.03.00.00", "01.02.10.00", "01.02.02.00"]
  111. assert result[0].download_url == "https://cdn.example/1.bin"
  112. assert result[0].release_notes == "notes 1.3"
  113. # Wiki-only version has no download URL → treated as unavailable by callers.
  114. assert result[1].download_url == ""
  115. assert result[1].release_notes is None
  116. assert result[1].release_time == "20260209"
  117. assert result[2].download_url == "https://cdn.example/2.bin"
  118. @pytest.mark.asyncio
  119. async def test_get_available_versions_sorts_newest_first():
  120. """Merged list must be sorted descending by version tuple regardless of input order."""
  121. svc = FirmwareCheckService()
  122. wiki = [("01.02.02.00", None)]
  123. download = [
  124. FirmwareVersion(version="01.03.00.00", download_url="a"),
  125. FirmwareVersion(version="01.02.10.00", download_url="b"),
  126. ]
  127. with (
  128. patch.object(svc, "_fetch_all_versions_from_wiki", AsyncMock(return_value=wiki)),
  129. patch.object(svc, "_fetch_all_versions_from_download_page", AsyncMock(return_value=download)),
  130. ):
  131. result = await svc.get_available_versions("H2D")
  132. assert [v.version for v in result] == ["01.03.00.00", "01.02.10.00", "01.02.02.00"]
  133. @pytest.mark.asyncio
  134. async def test_check_for_update_includes_available_versions():
  135. svc = FirmwareCheckService()
  136. available = [
  137. FirmwareVersion(version="01.03.00.00", download_url="https://cdn/1.bin", release_notes="x"),
  138. FirmwareVersion(version="01.02.10.00", download_url=""), # unavailable
  139. ]
  140. with patch.object(svc, "get_available_versions", AsyncMock(return_value=available)):
  141. result = await svc.check_for_update("H2D", "01.02.02.00")
  142. assert result["update_available"] is True
  143. assert result["latest_version"] == "01.03.00.00"
  144. assert len(result["available_versions"]) == 2
  145. assert result["available_versions"][0]["file_available"] is True
  146. assert result["available_versions"][1]["file_available"] is False
  147. assert result["available_versions"][1]["download_url"] is None