fix(cleanup): artist cascade preview counts posts and attachments (#3067)
`project_artist_cascade` is documented as "a read-only projection of what delete_artist_cascade would touch" and drives the Tier-C confirm dialog, but it counted only images, sources, thumbs, import_tasks and bytes. It never counted posts or attachments, both of which the apply destroys. That is silent in the worst case. `Post.artist_id` is ondelete=CASCADE, so every post goes whether or not it carried an image — and FC has a large body-only post population (#1288 measured 694 pixiv posts with text and no images). Such an artist previewed as `images: 0`, reading as "empty, safe to remove", while the apply destroyed every captured body, description, external-link set and raw_metadata snapshot. The danger-zone card already promised "every image, source, post, and attachment" — the copy was honest and the numbers were not. Root of the drift: the preview re-derived its own predicates instead of sharing the apply's, the same shape as the 2026-06-08 fandom-tag deletion that rule 93 exists for. So rather than bolt on two counts, both halves now build from shared `_artist_{images,posts,attachments}_conditions` helpers, following the `_unused_tag_conditions` / `_bare_post_conditions` style already in the file. Sources keep no helper — the apply doesn't query them either, it gets them from the Artist.sources ORM cascade. The apply also now reports `posts_deleted` (counted before the delete, since the CASCADE leaves nothing to count after). Rule 93's second half asks for the apply to be tested, and parity is only assertable if both halves state the number. Adds a preview/apply parity test that runs both against one artist and asserts the three pairs agree AND that the rows actually went, plus a body-only-artist test covering the case that motivated this. The confirm dialog's counts grid renders every key, so posts and attachments surface there automatically; the prose summary line names posts explicitly, since that is the number that changes how an artist reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -68,6 +68,8 @@ def test_project_artist_cascade_returns_zeroes_for_empty_artist(db_sync):
|
||||
assert result["artist"]["slug"] == "empty"
|
||||
assert result["projected"] == {
|
||||
"images": 0,
|
||||
"posts": 0,
|
||||
"attachments": 0,
|
||||
"sources": 0,
|
||||
"thumbs": 0,
|
||||
"import_tasks": 0,
|
||||
@@ -92,6 +94,87 @@ def test_project_artist_cascade_counts_images_and_thumbs_and_bytes(db_sync, tmp_
|
||||
assert result["projected"]["bytes_on_disk"] == 3500
|
||||
|
||||
|
||||
def test_project_artist_cascade_counts_posts_and_attachments(db_sync, tmp_path):
|
||||
"""The body-only artist: zero images, but posts and attachments that the
|
||||
apply destroys. Previewing this as `images: 0` alone is what made a
|
||||
content-only artist read as an empty one (#3067)."""
|
||||
a = _make_artist(db_sync, slug="bodyonly")
|
||||
p1 = Post(artist_id=a.id, external_post_id="bo-1", description="a body")
|
||||
p2 = Post(artist_id=a.id, external_post_id="bo-2", description="another")
|
||||
db_sync.add_all([p1, p2])
|
||||
db_sync.flush()
|
||||
db_sync.add(PostAttachment(
|
||||
post_id=p1.id, artist_id=a.id, sha256="b0d1".ljust(64, "0"),
|
||||
path="/store/b0d1/a.pdf", original_filename="a.pdf",
|
||||
ext=".pdf", size_bytes=5,
|
||||
))
|
||||
# artist_id NULL, reachable only through its post — the second arm of
|
||||
# _artist_attachments_conditions.
|
||||
db_sync.add(PostAttachment(
|
||||
post_id=p2.id, artist_id=None, sha256="b0d2".ljust(64, "0"),
|
||||
path="/store/b0d2/b.pdf", original_filename="b.pdf",
|
||||
ext=".pdf", size_bytes=5,
|
||||
))
|
||||
db_sync.commit()
|
||||
|
||||
projected = cleanup_service.project_artist_cascade(
|
||||
db_sync, slug="bodyonly",
|
||||
)["projected"]
|
||||
assert projected["images"] == 0
|
||||
assert projected["posts"] == 2
|
||||
assert projected["attachments"] == 2
|
||||
|
||||
|
||||
def test_artist_cascade_preview_matches_apply(db_sync, tmp_path):
|
||||
"""Rule 93: the preview's numbers must be what the apply actually does.
|
||||
|
||||
Guards the drift directly rather than trusting that both halves happen to
|
||||
use the same predicate — the preview and apply are asserted against each
|
||||
other on one artist carrying all three row kinds.
|
||||
"""
|
||||
a = _make_artist(db_sync, slug="parity")
|
||||
for i in range(3):
|
||||
f = tmp_path / f"par{i}.jpg"
|
||||
f.write_bytes(b"x")
|
||||
_make_image(
|
||||
db_sync, artist=a, path=str(f), sha256=f"{i:064x}", size=10,
|
||||
)
|
||||
posts = [
|
||||
Post(artist_id=a.id, external_post_id=f"par-{i}") for i in range(4)
|
||||
]
|
||||
db_sync.add_all(posts)
|
||||
db_sync.flush()
|
||||
for i, p in enumerate(posts[:2]):
|
||||
db_sync.add(PostAttachment(
|
||||
post_id=p.id, artist_id=a.id, sha256=f"par{i}".ljust(64, "0"),
|
||||
path=f"/store/par{i}/f.zip", original_filename="f.zip",
|
||||
ext=".zip", size_bytes=9,
|
||||
))
|
||||
db_sync.commit()
|
||||
artist_id = a.id
|
||||
|
||||
projected = cleanup_service.project_artist_cascade(
|
||||
db_sync, slug="parity",
|
||||
)["projected"]
|
||||
summary = cleanup_service.delete_artist_cascade(
|
||||
db_sync, artist_id=artist_id, images_root=tmp_path,
|
||||
)["summary"]
|
||||
|
||||
assert projected["images"] == summary["images_deleted"] == 3
|
||||
assert projected["posts"] == summary["posts_deleted"] == 4
|
||||
assert projected["attachments"] == summary["attachments_deleted"] == 2
|
||||
|
||||
# And the apply really did remove them — a matching pair of numbers is
|
||||
# worth nothing if neither half touched the DB.
|
||||
assert db_sync.execute(
|
||||
select(func.count(Post.id)).where(Post.artist_id == artist_id)
|
||||
).scalar_one() == 0
|
||||
assert db_sync.execute(
|
||||
select(func.count(PostAttachment.id))
|
||||
.where(PostAttachment.artist_id == artist_id)
|
||||
).scalar_one() == 0
|
||||
|
||||
|
||||
def test_project_artist_cascade_raises_on_unknown_slug(db_sync):
|
||||
with pytest.raises(LookupError):
|
||||
cleanup_service.project_artist_cascade(db_sync, slug="nope")
|
||||
|
||||
Reference in New Issue
Block a user