fix: a non-image file lands on the source it was downloaded for, and the undated shells it left are folded into their real posts (#4435)
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 18s
CI and images / frontend-build (push) Successful in 22s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m22s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 1m39s
CI and images / smoke-web (push) Successful in 54s
CI and images / promote (push) Successful in 1s
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 18s
CI and images / frontend-build (push) Successful in 22s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m22s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 1m39s
CI and images / smoke-web (push) Successful in 54s
CI and images / promote (push) Successful in 1s
The attachment path looked the post's source up by (artist, platform), taking the artist's lowest-id source. A Discord artist has one source per channel, so every archive or pdf from a later channel became an undated second post on the first channel's source. _post_for_sidecar now takes the downloading source, as upsert_post_record and _apply_sidecar already did. Migration 0114 re-points each shell's attachments to its dated twin and deletes the shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"""Migration 0114 (#4435): the undated shell post a misfiled attachment made on
|
||||
the artist's first Discord source is folded into the real, dated post."""
|
||||
import importlib.util
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models import (
|
||||
Artist,
|
||||
ImageProvenance,
|
||||
Post,
|
||||
PostAttachment,
|
||||
Source,
|
||||
)
|
||||
from tests.factories import make_image as _img
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_MIGRATION = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "alembic" / "versions" / "0114_fold_misfiled_attachment_posts.py"
|
||||
)
|
||||
|
||||
|
||||
def _fold():
|
||||
spec = importlib.util.spec_from_file_location("m0114", _MIGRATION)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod.fold_misfiled_attachment_posts
|
||||
|
||||
|
||||
def _source(db, artist, channel):
|
||||
s = Source(
|
||||
artist_id=artist.id, platform="discord",
|
||||
url=f"https://discord.com/channels/1/{channel}",
|
||||
)
|
||||
db.add(s)
|
||||
db.flush()
|
||||
return s
|
||||
|
||||
|
||||
def _post(db, artist, source, epid, when=None):
|
||||
p = Post(
|
||||
artist_id=artist.id, source_id=source.id, external_post_id=epid,
|
||||
post_date=when,
|
||||
post_url=f"https://discord.com/channels/1/x/{epid}" if when else None,
|
||||
)
|
||||
db.add(p)
|
||||
db.flush()
|
||||
return p
|
||||
|
||||
|
||||
def _attach(db, post, sha, name):
|
||||
db.add(PostAttachment(
|
||||
post_id=post.id, artist_id=post.artist_id, sha256=sha,
|
||||
path=f"/att/{sha}", original_filename=name, ext=".rar", size_bytes=1,
|
||||
))
|
||||
db.flush()
|
||||
|
||||
|
||||
def test_shells_fold_into_their_dated_twin_and_nothing_else_moves(db_sync):
|
||||
sent = datetime(2024, 12, 26, 1, 42, tzinfo=UTC)
|
||||
artist = Artist(name="Yellow", slug="yellow")
|
||||
db_sync.add(artist)
|
||||
db_sync.flush()
|
||||
first = _source(db_sync, artist, 100)
|
||||
second = _source(db_sync, artist, 200)
|
||||
|
||||
# The #4435 shape: shell on the first source, real post on the second.
|
||||
shell = _post(db_sync, artist, first, "555")
|
||||
real = _post(db_sync, artist, second, "555", sent)
|
||||
_attach(db_sync, shell, "a" * 64, "pack.rar")
|
||||
# A shell whose attachment the real post already has: dropped, not doubled.
|
||||
shell2 = _post(db_sync, artist, first, "556")
|
||||
real2 = _post(db_sync, artist, second, "556", sent)
|
||||
_attach(db_sync, shell2, "b" * 64, "same.rar")
|
||||
_attach(db_sync, real2, "b" * 64, "same.rar")
|
||||
# Left alone: no dated twin, and an undated post that holds an image.
|
||||
lonely = _post(db_sync, artist, first, "557")
|
||||
_attach(db_sync, lonely, "c" * 64, "lonely.rar")
|
||||
with_image = _post(db_sync, artist, first, "558")
|
||||
_post(db_sync, artist, second, "558", sent)
|
||||
img = _img(db_sync, "d" * 64)
|
||||
db_sync.add(ImageProvenance(image_record_id=img.id, post_id=with_image.id))
|
||||
db_sync.flush()
|
||||
shell_id, shell2_id = shell.id, shell2.id
|
||||
|
||||
folded = _fold()(db_sync.connection())
|
||||
db_sync.expire_all()
|
||||
|
||||
assert folded == 2
|
||||
assert db_sync.get(Post, shell_id) is None
|
||||
assert db_sync.get(Post, shell2_id) is None
|
||||
owners = dict(db_sync.execute(
|
||||
select(PostAttachment.original_filename, PostAttachment.post_id)
|
||||
).all())
|
||||
assert owners["pack.rar"] == real.id
|
||||
assert owners["lonely.rar"] == lonely.id
|
||||
kept = db_sync.execute(
|
||||
select(PostAttachment.id).where(PostAttachment.post_id == real2.id)
|
||||
).scalars().all()
|
||||
assert len(kept) == 1
|
||||
assert db_sync.get(Post, lonely.id) is not None
|
||||
assert db_sync.get(Post, with_image.id) is not None
|
||||
@@ -435,3 +435,48 @@ def test_attach_in_place_non_media_routes_to_attachment(importer, db_sync):
|
||||
).scalar_one()
|
||||
assert row.ext == ".txt"
|
||||
assert row.artist_id == artist.id
|
||||
|
||||
|
||||
def test_a_non_media_file_lands_on_the_source_being_downloaded(importer, db_sync):
|
||||
"""#4435: a Discord artist has one source per channel. A non-media file
|
||||
downloaded for the SECOND channel was filed under the artist's first
|
||||
Discord source (the (artist, platform) lookup takes the lowest id), as an
|
||||
undated shell post; the real post record then made a second post under
|
||||
the right source. The attachment must follow the source it was fetched for."""
|
||||
from backend.app.models import Post, PostAttachment, Source
|
||||
|
||||
images_root = importer.images_root
|
||||
artist = Artist(name="Yara", slug="yara")
|
||||
db_sync.add(artist)
|
||||
db_sync.flush()
|
||||
first = Source(
|
||||
artist_id=artist.id, platform="discord",
|
||||
url="https://discord.com/channels/1/100",
|
||||
)
|
||||
second = Source(
|
||||
artist_id=artist.id, platform="discord",
|
||||
url="https://discord.com/channels/1/200",
|
||||
)
|
||||
db_sync.add_all([first, second])
|
||||
db_sync.flush()
|
||||
|
||||
rar = images_root / "yara" / "discord" / "rewards" / "20241226_555_01_pack.rar"
|
||||
rar.parent.mkdir(parents=True, exist_ok=True)
|
||||
rar.write_bytes(b"not a real archive, so it is kept as an attachment")
|
||||
rar.with_suffix(rar.suffix + ".json").write_text(
|
||||
'{"category": "discord", "id": "555", "message_id": "555"}'
|
||||
)
|
||||
|
||||
result = importer.attach_in_place(rar, artist=artist, source=second)
|
||||
assert result.status == "attached"
|
||||
|
||||
owner = db_sync.execute(
|
||||
select(Post.source_id, Post.external_post_id)
|
||||
.join(PostAttachment, PostAttachment.post_id == Post.id)
|
||||
.where(PostAttachment.original_filename == rar.name)
|
||||
).one()
|
||||
assert owner.source_id == second.id
|
||||
assert owner.external_post_id == "555"
|
||||
assert db_sync.execute(
|
||||
select(func.count()).select_from(Post).where(Post.source_id == first.id)
|
||||
).scalar_one() == 0
|
||||
|
||||
Reference in New Issue
Block a user