"""Tests for media downloads and Joplin resource rewriting.""" from pathlib import Path import pytest from src.blocks import ( make_file_placeholder, make_image_placeholder, render_blocks_to_markdown, ) from src.loss_report import LossReport from src.media import resolve_media, resolve_media_policy from src.providers.base import ProviderError from src.providers.chatgpt import parse_asset_file_id # --------------------------------------------------------------------------- # Asset reference parsing # --------------------------------------------------------------------------- class TestParseAssetFileId: def test_plain_sediment(self): assert parse_asset_file_id("sediment://file_00000000245c71fda5") == "file_00000000245c71fda5" def test_generated_image_with_hash_and_page(self): ref = "sediment://8456107fc383a53#file_00000000979c71f685#p_6.png" assert parse_asset_file_id(ref) == "file_00000000979c71f685" def test_file_service_scheme(self): assert parse_asset_file_id("file-service://file-AbCdEf") == "file-AbCdEf" def test_unrecognised(self): assert parse_asset_file_id("https://example.com/x.png") is None assert parse_asset_file_id("") is None assert parse_asset_file_id(None) is None # --------------------------------------------------------------------------- # Media policy # --------------------------------------------------------------------------- class TestMediaPolicy: def test_default(self, monkeypatch): monkeypatch.delenv("EXPORTER_DOWNLOAD_MEDIA", raising=False) assert resolve_media_policy() == "images" def test_valid(self, monkeypatch): monkeypatch.setenv("EXPORTER_DOWNLOAD_MEDIA", "all") assert resolve_media_policy() == "all" def test_invalid_falls_back(self, monkeypatch, caplog): monkeypatch.setenv("EXPORTER_DOWNLOAD_MEDIA", "bogus") assert resolve_media_policy() == "images" # --------------------------------------------------------------------------- # resolve_media # --------------------------------------------------------------------------- class _FakeProvider: """Minimal provider exposing download_asset + the ref parser.""" def __init__(self, assets=None, fail_refs=None): self._assets = assets or {} self._fail_refs = fail_refs or {} self.calls = [] def parse_asset_file_id(self, ref): return parse_asset_file_id(ref) def download_asset(self, ref): self.calls.append(ref) if ref in self._fail_refs: raise ProviderError("chatgpt", "download_asset", self._fail_refs[ref]) return self._assets[ref] # (content, mime, file_name) def _conv_with(blocks): return { "id": "conv-1", "title": "Has Media", "provider": "chatgpt", "project": None, "created_at": "2026-05-20T00:00:00+00:00", "messages": [{"role": "user", "blocks": blocks}], } class TestResolveMedia: def test_downloads_image_and_inlines(self, tmp_path): ref = "sediment://file_img1" provider = _FakeProvider({ref: (b"\x89PNG\r\n", "image/png", "x.png")}) block = make_image_placeholder(ref=ref, source="user_upload") conv = _conv_with([block]) report = LossReport() n = resolve_media(conv, provider, tmp_path, "provider/project/year", "images", report) assert n == 1 assert report.media_downloaded == 1 assert block["local_path"] == "media/file_img1.png" rendered = render_blocks_to_markdown([block]) assert rendered == "![user_upload](media/file_img1.png)" # File written under the conversation's media/ dir written = list(tmp_path.rglob("media/file_img1.png")) assert written and written[0].read_bytes() == b"\x89PNG\r\n" def test_images_policy_skips_files(self, tmp_path): ref = "sediment://file_audio1" provider = _FakeProvider({ref: (b"RIFF", "audio/wav", "a.wav")}) block = make_file_placeholder(ref=ref, mime="audio/wav", size_bytes=1000) conv = _conv_with([block]) report = LossReport() n = resolve_media(conv, provider, tmp_path, "provider/project/year", "images", report) assert n == 0 assert "local_path" not in block assert provider.calls == [] def test_all_policy_downloads_files(self, tmp_path): ref = "sediment://file_audio1" provider = _FakeProvider({ref: (b"RIFFdata", "audio/wav", "a.wav")}) block = make_file_placeholder(ref=ref, mime="audio/wav", size_bytes=8) conv = _conv_with([block]) report = LossReport() n = resolve_media(conv, provider, tmp_path, "provider/project/year", "all", report) assert n == 1 assert block["local_path"] == "media/file_audio1.wav" rendered = render_blocks_to_markdown([block]) assert "media/file_audio1.wav" in rendered and rendered.startswith("> 📎") def test_off_policy_noop(self, tmp_path): provider = _FakeProvider({"sediment://file_x": (b"x", "image/png", None)}) block = make_image_placeholder(ref="sediment://file_x", source="user_upload") conv = _conv_with([block]) report = LossReport() assert resolve_media(conv, provider, tmp_path, "provider/project/year", "off", report) == 0 assert provider.calls == [] def test_idempotent_uses_disk(self, tmp_path): ref = "sediment://file_img1" provider = _FakeProvider({ref: (b"\x89PNG", "image/png", "x.png")}) conv = _conv_with([make_image_placeholder(ref=ref, source="user_upload")]) report = LossReport() resolve_media(conv, provider, tmp_path, "provider/project/year", "images", report) assert len(provider.calls) == 1 # Second run, fresh blocks: file already on disk → no new API call. conv2 = _conv_with([make_image_placeholder(ref=ref, source="user_upload")]) resolve_media(conv2, provider, tmp_path, "provider/project/year", "images", LossReport()) assert len(provider.calls) == 1 # unchanged assert conv2["messages"][0]["blocks"][0]["local_path"] == "media/file_img1.png" def test_failure_keeps_placeholder_and_counts(self, tmp_path): ref = "sediment://file_gone" provider = _FakeProvider(fail_refs={ref: RuntimeError("Signed URL returned HTTP 404")}) block = make_image_placeholder(ref=ref, source="model_generated") conv = _conv_with([block]) report = LossReport() n = resolve_media(conv, provider, tmp_path, "provider/project/year", "images", report) assert n == 0 assert "local_path" not in block assert report.media_failed["expired-or-missing"] == 1 # Still renders as a placeholder, not a broken image link assert render_blocks_to_markdown([block]).startswith("> 🖼️") def test_provider_without_download_asset(self, tmp_path): """claude-code has no remote assets — resolve_media must no-op.""" class NoDownload: pass block = make_image_placeholder(ref="sediment://file_x", source="user_upload") conv = _conv_with([block]) assert resolve_media(conv, NoDownload(), tmp_path, "provider/project/year", "images", LossReport()) == 0 # --------------------------------------------------------------------------- # Joplin media rewriting # --------------------------------------------------------------------------- class _FakeJoplin: def __init__(self): self.uploaded = [] self._n = 0 def create_resource(self, file_path, title=None): self.uploaded.append(Path(file_path).name) self._n += 1 return f"res{self._n}" class TestUploadMediaAndRewrite: def _note(self, tmp_path, body): media = tmp_path / "media" media.mkdir() (media / "file_img1.png").write_bytes(b"\x89PNG") (media / "clip.wav").write_bytes(b"RIFF") return body def test_rewrites_image_and_file_links(self, tmp_path): from src.joplin import upload_media_and_rewrite body = self._note( tmp_path, "Look: ![user_upload](media/file_img1.png)\n" "> 📎 **File attached** — [clip.wav](media/clip.wav) (audio/wav)", ) client = _FakeJoplin() new_body, res_map = upload_media_and_rewrite(body, tmp_path, client, {}) assert "![user_upload](:/res1)" in new_body assert "(:/res2)" in new_body assert "media/" not in new_body assert res_map == {"media/file_img1.png": "res1", "media/clip.wav": "res2"} assert len(client.uploaded) == 2 def test_reuses_known_resource_ids(self, tmp_path): from src.joplin import upload_media_and_rewrite body = self._note(tmp_path, "![x](media/file_img1.png)") client = _FakeJoplin() existing = {"media/file_img1.png": "existing-res"} new_body, res_map = upload_media_and_rewrite(body, tmp_path, client, existing) assert "(:/existing-res)" in new_body assert client.uploaded == [] # no re-upload assert res_map == existing def test_missing_file_left_as_link(self, tmp_path): from src.joplin import upload_media_and_rewrite (tmp_path / "media").mkdir() body = "![x](media/gone.png)" client = _FakeJoplin() new_body, res_map = upload_media_and_rewrite(body, tmp_path, client, {}) assert new_body == body assert res_map == {} assert client.uploaded == [] def test_no_media_links_untouched(self, tmp_path): from src.joplin import upload_media_and_rewrite body = "Just text with [a link](https://example.com) and `media/foo` in code." client = _FakeJoplin() new_body, res_map = upload_media_and_rewrite(body, tmp_path, client, {}) assert new_body == body assert res_map == {}