""" Unit tests for firmware version listing. Covers: - Wiki-page version extraction is restricted to section-heading anchors (incidental version-like strings in release-note prose must be ignored). - Merging wiki + download-page versions produces a single list where wiki-only versions are flagged as unavailable (no download URL). - buildId disk-persistence + 403 fallback for #1350. """ import json from unittest.mock import AsyncMock, MagicMock, patch import pytest from backend.app.services.firmware_check import FirmwareCheckService, FirmwareVersion WIKI_SAMPLE = """

01.03.00.00 (20260303)

Released 20260303

01.02.10.00 (20260209)

Bug fixes.

01.02.02.00 (20251105)

Some more text referencing 00.00.00.00 incidentally.

""" @pytest.mark.asyncio async def test_wiki_extraction_ignores_prose_version_mentions(): """02.00.19.47 appears only in release notes prose — it must not be listed.""" svc = FirmwareCheckService() mock_resp = AsyncMock() mock_resp.status_code = 200 mock_resp.text = WIKI_SAMPLE with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)): versions = await svc._fetch_all_versions_from_wiki("h2d") version_strs = [v for v, _ in versions] assert version_strs == ["01.03.00.00", "01.02.10.00", "01.02.02.00"] # The AMS firmware mentioned in prose must not leak in: assert "02.00.19.47" not in version_strs assert "00.00.00.00" not in version_strs # Release dates are captured from the anchor id: assert versions[0][1] == "20260303" @pytest.mark.asyncio async def test_wiki_extraction_returns_empty_for_unknown_api_key(): svc = FirmwareCheckService() assert await svc._fetch_all_versions_from_wiki("no-such-key") == [] # P2S and X2D wiki pages publish anchor ids without a dash between the # version bytes and the date (e.g. h-0102000020260409). Regression for #1030 # where the anchor regex required a dash and silently returned no versions, # causing the UI to fall back to the stale download-page "Latest" value. P2S_NODASH_ANCHOR_SAMPLE = """

01.02.00.00(20260409)

01.01.03.00(20260209)

01.01.01.00(20251208)

""" @pytest.mark.asyncio async def test_wiki_extraction_accepts_nodash_anchors(): """P2S/X2D anchors concatenate version+date with no dash — must still parse.""" svc = FirmwareCheckService() mock_resp = AsyncMock() mock_resp.status_code = 200 mock_resp.text = P2S_NODASH_ANCHOR_SAMPLE with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)): versions = await svc._fetch_all_versions_from_wiki("p2s") assert [v for v, _ in versions] == ["01.02.00.00", "01.01.03.00", "01.01.01.00"] assert versions[0][1] == "20260409" # A1, A1-mini and P2S pages render dates in full-width parens (YYYYMMDD) # rather than ASCII parens (YYYYMMDD). Pages without version-anchors fall # through to the text-based regex, so it must accept both paren styles. FULLWIDTH_PAREN_FALLBACK_SAMPLE = """

01.04.00.01 (20260401)

01.03.00.00 (20260101)

""" @pytest.mark.asyncio async def test_wiki_extraction_fallback_accepts_fullwidth_parens(): svc = FirmwareCheckService() mock_resp = AsyncMock() mock_resp.status_code = 200 mock_resp.text = FULLWIDTH_PAREN_FALLBACK_SAMPLE with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)): versions = await svc._fetch_all_versions_from_wiki("a1") assert [v for v, _ in versions] == ["01.04.00.01", "01.03.00.00"] assert versions[0][1] == "20260401" @pytest.mark.asyncio async def test_get_available_versions_merges_sources(): """ Merged list must include all wiki versions (newest first), populating download URL + notes from the download-page JSON when present, and leaving download_url empty when the file is not published. """ svc = FirmwareCheckService() wiki = [ ("01.03.00.00", "20260303"), ("01.02.10.00", "20260209"), # wiki-only — should be "unavailable" ("01.02.02.00", "20251105"), ] download = [ FirmwareVersion( version="01.03.00.00", download_url="https://cdn.example/1.bin", release_notes="notes 1.3", release_time="2026-03-03", ), FirmwareVersion( version="01.02.02.00", download_url="https://cdn.example/2.bin", release_notes="notes 1.2.2", release_time="2025-11-05", ), ] with ( patch.object(svc, "_fetch_all_versions_from_wiki", AsyncMock(return_value=wiki)), patch.object(svc, "_fetch_all_versions_from_download_page", AsyncMock(return_value=download)), ): result = await svc.get_available_versions("H2D") assert [v.version for v in result] == ["01.03.00.00", "01.02.10.00", "01.02.02.00"] assert result[0].download_url == "https://cdn.example/1.bin" assert result[0].release_notes == "notes 1.3" # Wiki-only version has no download URL → treated as unavailable by callers. assert result[1].download_url == "" assert result[1].release_notes is None assert result[1].release_time == "20260209" assert result[2].download_url == "https://cdn.example/2.bin" @pytest.mark.asyncio async def test_get_available_versions_sorts_newest_first(): """Merged list must be sorted descending by version tuple regardless of input order.""" svc = FirmwareCheckService() wiki = [("01.02.02.00", None)] download = [ FirmwareVersion(version="01.03.00.00", download_url="a"), FirmwareVersion(version="01.02.10.00", download_url="b"), ] with ( patch.object(svc, "_fetch_all_versions_from_wiki", AsyncMock(return_value=wiki)), patch.object(svc, "_fetch_all_versions_from_download_page", AsyncMock(return_value=download)), ): result = await svc.get_available_versions("H2D") assert [v.version for v in result] == ["01.03.00.00", "01.02.10.00", "01.02.02.00"] @pytest.mark.asyncio async def test_client_headers_identify_honestly_and_send_browser_accept(): """ The httpx client must identify as Bambuddy (no Chrome impersonation) and must send Accept + Accept-Language so Cloudflare on bambulab.com doesn't 403 us for looking like a bare scraper (#1350). """ svc = FirmwareCheckService() headers = svc._client.headers assert headers["User-Agent"].startswith("Bambuddy/") assert "Chrome" not in headers["User-Agent"] assert "Accept" in headers assert "Accept-Language" in headers @pytest.mark.asyncio async def test_build_id_is_persisted_to_disk(tmp_path, monkeypatch): """Successful buildId fetch writes to disk so it survives restart (#1350).""" monkeypatch.setattr("backend.app.services.firmware_check._data_dir", tmp_path) svc = FirmwareCheckService() mock_resp = MagicMock() mock_resp.status_code = 200 mock_resp.text = 'window.__data = {"buildId":"abc123xyz","other":"stuff"}' with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)): build_id = await svc._get_build_id() assert build_id == "abc123xyz" cache_file = tmp_path / "firmware" / "build_id.json" assert cache_file.exists() data = json.loads(cache_file.read_text()) assert data["build_id"] == "abc123xyz" assert data["fetched_at"] > 0 @pytest.mark.asyncio async def test_build_id_falls_back_to_disk_on_403(tmp_path, monkeypatch): """ When bambulab.com 403s (Cloudflare block reported in #1350) we must fall back to the disk-cached buildId from the previous successful fetch. Without this the user's screenshots happen: wiki version is detected but the download URL stays empty forever. """ monkeypatch.setattr("backend.app.services.firmware_check._data_dir", tmp_path) # Pre-seed a previously-saved buildId cache_dir = tmp_path / "firmware" cache_dir.mkdir(parents=True) (cache_dir / "build_id.json").write_text(json.dumps({"build_id": "cached_id_42", "fetched_at": 1000.0})) svc = FirmwareCheckService() mock_resp = MagicMock() mock_resp.status_code = 403 mock_resp.text = "Access denied" with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)): build_id = await svc._get_build_id() assert build_id == "cached_id_42" assert svc.download_page_unreachable is True @pytest.mark.asyncio async def test_download_page_unreachable_flag_set_on_403_json(tmp_path, monkeypatch): """A 403 on the per-model JSON endpoint also marks the page unreachable.""" monkeypatch.setattr("backend.app.services.firmware_check._data_dir", tmp_path) svc = FirmwareCheckService() svc._build_id = "stale_id" svc._build_id_time = 9999999999.0 # never expires for this test mock_resp = MagicMock() mock_resp.status_code = 403 mock_resp.text = "Forbidden" with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)): result = await svc._fetch_all_versions_from_download_page("x1") assert result == [] assert svc.download_page_unreachable is True @pytest.mark.asyncio async def test_download_page_retries_once_when_buildid_stale(tmp_path, monkeypatch): """ If the cached buildId returns 404 (Bambu rebuilt the page), refresh the buildId once and retry — but don't churn on repeated failures. """ monkeypatch.setattr("backend.app.services.firmware_check._data_dir", tmp_path) svc = FirmwareCheckService() svc._build_id = "stale_id" svc._build_id_time = 9999999999.0 # First call (with stale buildId) → 404 # Second call (with fresh buildId after refresh) → 200 with versions stale_resp = MagicMock(status_code=404, text="not found") fresh_resp = MagicMock(status_code=200) fresh_resp.json = MagicMock( return_value={ "pageProps": { "printerMap": { "x1": { "versions": [ { "version": "01.11.02.00", "url": "https://cdn/fw.bin", "release_notes_en": "n", "release_time": "2025-12-10", } ] } } } } ) # Page fetch for the buildId refresh page_resp = MagicMock(status_code=200) page_resp.text = 'foo "buildId":"fresh_id" bar' # Sequence: stale 404 → page refresh 200 → fresh 200 get = AsyncMock(side_effect=[stale_resp, page_resp, fresh_resp]) with patch.object(svc._client, "get", get): result = await svc._fetch_all_versions_from_download_page("x1") assert len(result) == 1 assert result[0].version == "01.11.02.00" assert svc._build_id == "fresh_id" @pytest.mark.asyncio async def test_check_for_update_includes_available_versions(): svc = FirmwareCheckService() available = [ FirmwareVersion(version="01.03.00.00", download_url="https://cdn/1.bin", release_notes="x"), FirmwareVersion(version="01.02.10.00", download_url=""), # unavailable ] with patch.object(svc, "get_available_versions", AsyncMock(return_value=available)): result = await svc.check_for_update("H2D", "01.02.02.00") assert result["update_available"] is True assert result["latest_version"] == "01.03.00.00" assert len(result["available_versions"]) == 2 assert result["available_versions"][0]["file_available"] is True assert result["available_versions"][1]["file_available"] is False assert result["available_versions"][1]["download_url"] is None