CI / lint (push) Failing after 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m11s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m4s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m50s
Step 1 of milestone #421. The images tree has 57 directory families for what the database says are single artists — `Conto`/`conto`, `InCaseArt`/`incaseart`, `StickySpoodge`/`Stickyspoodge`/`stickyspoodge`, and so on down to a four-way split for Pocket Ace Games. There was never a duplicate Artist row. `/api/artists/names` returns exactly one per artist. The files simply get written to two places for one row: `_copy_to_library` built its destination from `derive_subdir`, which mirrors the IMPORT tree's folder name verbatim, while the download path leaves files where the ingester wrote them — under the slug. Two writers, two conventions, one artist. This is the half that stops it re-growing, and it has to land before anything moves existing files: consolidate first and the next filesystem import out of a capitalised folder re-creates the directory that was just emptied. `canonical_subdir` replaces the top-level segment with the artist's slug and leaves everything below it alone — the post hierarchy is the downloader's business. Two deliberate pass-throughs: no resolved artist (nothing authoritative to canonicalise against) and an empty subdir (a file at the images root, whose fate is task #4247, not a side effect of this helper). `_supersede` resolves the KEPT row's artist for the same reason — a supersede rewrites `existing.path`, so writing it anywhere else would move a row back out of the tree being consolidated. ImageRecord carries `artist_id` with no relationship attribute, so that is a session lookup rather than an attribute. test_import_one_happy_path pinned the old `Alice/` destination and now pins `alice/` (rule 90). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
98 lines
3.3 KiB
Python
98 lines
3.3 KiB
Python
from pathlib import Path
|
|
|
|
from backend.app.utils.paths import (
|
|
canonical_subdir,
|
|
derive_subdir,
|
|
derive_top_level_artist,
|
|
filehash_from_url,
|
|
hash_suffixed_name,
|
|
)
|
|
|
|
IMPORT = Path("/import")
|
|
|
|
|
|
def test_filehash_from_url_extracts_lowercased_md5():
|
|
url = "https://c10.patreonusercontent.com/4/patreon-media/p/post/1/AbC123DeF456789012345678901234EF/1/img.png?token=x"
|
|
assert filehash_from_url(url) == "abc123def456789012345678901234ef"
|
|
|
|
|
|
def test_filehash_from_url_none_and_no_hash():
|
|
assert filehash_from_url(None) is None
|
|
assert filehash_from_url("") is None
|
|
assert filehash_from_url("https://example.test/a.png") is None
|
|
|
|
|
|
def test_filehash_from_url_query_unescaping_irrelevant():
|
|
# The hash sits in the path before the query, so entity-escaped ampersands
|
|
# in the query never affect the match (render-time matches escaped srcs).
|
|
base = "https://cdn.test/p/0123456789abcdef0123456789ABCDEF/x.jpg"
|
|
assert filehash_from_url(base + "?a=1&b=2") == "0123456789abcdef0123456789abcdef"
|
|
|
|
|
|
def test_derive_subdir_nested():
|
|
assert derive_subdir(Path("/import/Alice/sub/x.png"), IMPORT) == "Alice/sub"
|
|
|
|
|
|
def test_derive_subdir_one_level():
|
|
assert derive_subdir(Path("/import/Alice/x.png"), IMPORT) == "Alice"
|
|
|
|
|
|
def test_derive_subdir_root():
|
|
assert derive_subdir(Path("/import/x.png"), IMPORT) == ""
|
|
|
|
|
|
def test_derive_subdir_outside_root():
|
|
assert derive_subdir(Path("/elsewhere/x.png"), IMPORT) == ""
|
|
|
|
|
|
def test_hash_suffixed_name():
|
|
h = "abcdef1234567890" + "0" * 48
|
|
assert hash_suffixed_name("photo", h, ".png") == "photo__abcdef1234.png"
|
|
|
|
|
|
def test_derive_top_level_artist_present():
|
|
assert derive_top_level_artist(Path("/import/Alice/x.png"), IMPORT) == "Alice"
|
|
|
|
|
|
def test_derive_top_level_artist_nested():
|
|
assert derive_top_level_artist(Path("/import/Alice/sub/x.png"), IMPORT) == "Alice"
|
|
|
|
|
|
def test_derive_top_level_artist_root():
|
|
assert derive_top_level_artist(Path("/import/x.png"), IMPORT) is None
|
|
|
|
|
|
# --- canonical_subdir (milestone #421) --------------------------------------
|
|
|
|
|
|
def test_canonical_subdir_replaces_the_top_segment():
|
|
assert canonical_subdir("Conto/patreon", "conto") == "conto/patreon"
|
|
assert canonical_subdir("Conto", "conto") == "conto"
|
|
|
|
|
|
def test_canonical_subdir_keeps_everything_below_the_artist():
|
|
"""Only the artist segment is authoritative — post folders below it are
|
|
the downloader's business and must survive untouched."""
|
|
assert canonical_subdir(
|
|
"Big Bang/patreon/2026-01-02_123_A Post", "big-bang"
|
|
) == "big-bang/patreon/2026-01-02_123_A Post"
|
|
|
|
|
|
def test_canonical_subdir_passes_through_without_an_artist():
|
|
"""No resolved artist means nothing authoritative to canonicalise against,
|
|
so the import folder's own name stands."""
|
|
assert canonical_subdir("Conto/patreon", None) == "Conto/patreon"
|
|
assert canonical_subdir("Conto", "") == "Conto"
|
|
|
|
|
|
def test_canonical_subdir_leaves_the_images_root_alone():
|
|
"""An empty subdir is a file landing at the root — there is no artist
|
|
folder to correct, and what becomes of those is task #4247's call."""
|
|
assert canonical_subdir("", "conto") == ""
|
|
assert canonical_subdir("", None) == ""
|
|
|
|
|
|
def test_canonical_subdir_is_idempotent():
|
|
once = canonical_subdir("Conto/patreon", "conto")
|
|
assert canonical_subdir(once, "conto") == once
|