from backend.app.utils.sidecar import SidecarData, find_sidecar, parse_sidecar def test_find_sidecar_stem_json(tmp_path): media = tmp_path / "photo.jpg" media.write_bytes(b"x") sc = tmp_path / "photo.json" sc.write_text("{}") assert find_sidecar(media) == sc def test_find_sidecar_full_name_json(tmp_path): media = tmp_path / "photo.jpg" media.write_bytes(b"x") sc = tmp_path / "photo.jpg.json" sc.write_text("{}") assert find_sidecar(media) == sc def test_find_sidecar_none(tmp_path): media = tmp_path / "photo.jpg" media.write_bytes(b"x") assert find_sidecar(media) is None def test_find_sidecar_gallerydl_numbered_prefix(tmp_path): """gallery-dl prefixes media filenames with NN_ for in-post ordering (e.g. `01_HOLLOW-ICHIGO.png`) but writes the post-level sidecar under the attachment stem WITHOUT the prefix (`HOLLOW-ICHIGO.json`). Confirmed against real Patreon downloads 2026-05-26 — operator's deep scan produced 24 refresh calls but 0 Posts because the unprefixed sidecar was invisible to find_sidecar.""" media = tmp_path / "01_HOLLOW-ICHIGO.png" media.write_bytes(b"x") sc = tmp_path / "HOLLOW-ICHIGO.json" sc.write_text("{}") assert find_sidecar(media) == sc def test_find_sidecar_multidigit_prefix(tmp_path): """Numbering prefix can be wider than 2 digits (`001_...`); the strip handles any \\d+_ form.""" media = tmp_path / "001_mirko-sketch.png" media.write_bytes(b"x") sc = tmp_path / "mirko-sketch.json" sc.write_text("{}") assert find_sidecar(media) == sc def test_find_sidecar_prefers_attachment_level_over_post_level(tmp_path): """If BOTH a per-attachment sidecar and a post-level sidecar exist, the attachment-level one wins (it's more specific).""" media = tmp_path / "01_image.png" media.write_bytes(b"x") per_attachment = tmp_path / "01_image.json" per_attachment.write_text('{"specific": true}') post_level = tmp_path / "image.json" post_level.write_text('{"specific": false}') assert find_sidecar(media) == per_attachment def test_find_sidecar_no_underscore_not_treated_as_prefix(tmp_path): """`01.png` (just digits, no underscore-separated stem) shouldn't match. The regex requires NN_.""" media = tmp_path / "01.png" media.write_bytes(b"x") (tmp_path / ".json").write_text("{}") # would be matched only if buggy assert find_sidecar(media) is None def test_parse_empty_dict_all_none(): sd = parse_sidecar({}) assert isinstance(sd, SidecarData) assert (sd.platform, sd.external_post_id, sd.post_url, sd.post_title, sd.description, sd.attachment_count, sd.post_date) == ( None, None, None, None, None, None, None) assert sd.raw == {} def test_parse_core_fields_and_id_priority(): """`post_id` MUST win over `id` (SubscribeStar duplicate-post fix). Patreon sidecars in the wild don't expose post_id; this test uses `category=patreon` but synthetically sets both fields to pin the parser's precedence.""" sd = parse_sidecar({ "category": "patreon", "id": 12345, "post_id": 999, "url": "https://patreon.com/posts/12345", "title": " Hello ", "content": "

body

", "page_count": 4, "published_at": "2023-08-01T04:20:02Z", }) assert sd.platform == "patreon" assert sd.external_post_id == "999" # 'post_id' wins over 'id' assert sd.post_url == "https://patreon.com/posts/12345" assert sd.post_title == "Hello" assert sd.description == "

body

" assert sd.attachment_count == 4 assert sd.post_date.year == 2023 and sd.post_date.tzinfo is not None def test_parse_id_used_when_no_post_id(): """Without post_id (Patreon/Pixiv/Discord real shape), `id` wins.""" sd = parse_sidecar({"id": 12345, "url": "https://example.test/p/1"}) assert sd.external_post_id == "12345" def test_parse_description_precedence_and_images_count(): sd = parse_sidecar({"description": "d", "caption": "c", "images": [1, 2, 3]}) assert sd.description == "d" # content>description>caption>message assert sd.attachment_count == 3 # len(images) fallback def test_parse_message_used_as_description_fallback(): """Discord posts have `message` not `content`; FC must surface it.""" sd = parse_sidecar({"category": "discord", "message": "hello channel"}) assert sd.description == "hello channel" def test_parse_source_url_present_and_absent(): """The per-media sidecar carries a `source_url` (#830 Phase 2); a post-only sidecar (no media file) omits it → None.""" sd = parse_sidecar({"category": "patreon", "id": "1", "source_url": "https://cdn.test/abc.png"}) assert sd.source_url == "https://cdn.test/abc.png" assert parse_sidecar({"category": "patreon", "id": "1"}).source_url is None def test_parse_date_epoch_and_unparseable_and_naive(): assert parse_sidecar({"timestamp": 1690857602}).post_date.tzinfo is not None assert parse_sidecar({"date": "not-a-date"}).post_date is None naive = parse_sidecar({"date": "2023-08-01T00:00:00"}).post_date assert naive is not None and naive.utcoffset().total_seconds() == 0 def test_parse_title_derived_from_content_when_empty(): """SubscribeStar gallery-dl writes `title: ""` and puts the leading sentence in `content` HTML. When `title` is empty, synthesize the post title from the content body's first non-empty text line.""" sd = parse_sidecar({ "title": "", "content": "\n
Lets say hello to you guys with my Belle


\n
\n", }) assert sd.post_title == "Lets say hello to you guys with my Belle" assert sd.description == ( "
Lets say hello to you guys with my Belle


\n
" ) def test_parse_title_derived_truncates_long_content(): long = "x" * 200 sd = parse_sidecar({"title": "", "content": long}) assert sd.post_title is not None assert len(sd.post_title) <= 120 assert sd.post_title.endswith("…") def test_parse_title_explicit_wins_over_content_fallback(): """If `title` is non-empty, the fallback never runs.""" sd = parse_sidecar({"title": "Real Title", "content": "

body line

"}) assert sd.post_title == "Real Title" def test_parse_title_no_fallback_when_no_content(): sd = parse_sidecar({"title": ""}) assert sd.post_title is None def test_parse_subscribestar_post_url_derived_and_post_id_wins(): """SubscribeStar gallery-dl puts the per-attachment id in `id` and the actual post id in `post_id`. The bare `url` is the file URL — must be ignored and a derived permalink used instead.""" sd = parse_sidecar({ "category": "subscribestar", "id": 711509, "post_id": 360360, "url": "/post_uploads?payload=opaque", "title": "", "content": "
hello
", }) assert sd.external_post_id == "360360" assert sd.post_url == "https://www.subscribestar.com/posts/360360" def test_parse_pixiv_post_url_derived(): """Pixiv's `url` is the image URL (i.pximg.net); must be replaced with the post permalink under /artworks/.""" sd = parse_sidecar({ "category": "pixiv", "id": 140466853, "url": "https://i.pximg.net/img-original/img/2026/01/28/10/28/24/140466853_p0.jpg", "title": "Nerissa x Jailbird", }) assert sd.external_post_id == "140466853" assert sd.post_url == "https://www.pixiv.net/artworks/140466853" def test_parse_hentaifoundry_post_url_derived(): """HF sidecars omit `url` entirely and use `index`+`user` for the post's natural key. Synthesize the canonical /pictures/user// permalink.""" sd = parse_sidecar({ "category": "hentaifoundry", "index": 1182595, "user": "HolyMeh", "title": "Annigosa", }) assert sd.external_post_id == "1182595" assert sd.post_url == "https://www.hentai-foundry.com/pictures/user/HolyMeh/1182595" def test_parse_discord_post_url_derived_and_message_id_wins(): """Discord posts use `message_id` for the post key and the server/channel/message triple for the permalink.""" sd = parse_sidecar({ "category": "discord", "message_id": "1195924119762505818", "channel_id": "968315530597498880", "server_id": "771088957849075793", "message": "channel body text", "url": "https://cdn.discordapp.com/attachments/file.png", }) assert sd.external_post_id == "1195924119762505818" assert sd.post_url == ( "https://discord.com/channels/771088957849075793/" "968315530597498880/1195924119762505818" ) assert sd.description == "channel body text" def test_parse_patreon_post_url_kept_as_is(): """Patreon's bare `url` IS a real permalink — must not be replaced.""" sd = parse_sidecar({ "category": "patreon", "id": 47074733, "url": "https://www.patreon.com/posts/barbara-genshin-47074733", }) assert sd.post_url == "https://www.patreon.com/posts/barbara-genshin-47074733" def test_parse_derived_url_returns_none_when_fields_missing(): """If the per-platform fields needed to derive the URL are missing, return None rather than fall back to the file `url`.""" sd = parse_sidecar({ "category": "subscribestar", "url": "/post_uploads?payload=opaque", # no post_id }) assert sd.post_url is None def test_parse_ignores_non_str_junk(): sd = parse_sidecar({"category": 5, "title": 7, "page_count": "x", "id": True}) assert sd.platform is None and sd.post_title is None assert sd.attachment_count is None assert sd.external_post_id is None # bool is not a valid id assert sd.raw == {"category": 5, "title": 7, "page_count": "x", "id": True}