CI and images / lint (push) Successful in 1s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 20s
CI and images / extension-test (push) Successful in 23s
CI and images / backend-lint-and-test (push) Successful in 33s
CI and images / integration (push) Successful in 2m20s
CI and images / sign-extension (push) Successful in 2s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 2m1s
CI and images / smoke-web (push) Successful in 49s
CI and images / promote (push) Successful in 1s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
The daily recovery reset every dead link to a fresh three-attempt budget, so a link that can never succeed (an archive too big to import inside the fetch's hard limit, a file made private) was downloaded up to three times a day, every day. Recovery now gives a dead link one more attempt once it has been dead for 2**recoveries days (1, 2, 4, 8, 16), and stops after five. A recovered link that fails again goes straight back to dead. Retention prunes only links recovery has given up on, 30 days after their last attempt. A manual refetch of a post restores the full budgets. Migration 0118 adds external_link.recoveries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
476 lines
18 KiB
Python
476 lines
18 KiB
Python
"""Integration tests for the external-link download worker (tasks/external)."""
|
|
|
|
import io
|
|
import zipfile
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
from sqlalchemy import func, select, update
|
|
|
|
import backend.app.tasks.external as ext
|
|
from backend.app.models import (
|
|
Artist,
|
|
ExternalLink,
|
|
ImageProvenance,
|
|
ImageRecord,
|
|
Post,
|
|
PostAttachment,
|
|
Source,
|
|
)
|
|
from backend.app.services.external_fetch import FetchResult
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
def _structured_jpeg_bytes():
|
|
# A non-uniform image so it survives the import filters (a solid color would
|
|
# phash-collapse / fail thresholds).
|
|
im = Image.new("RGB", (256, 256))
|
|
px = im.load()
|
|
for y in range(256):
|
|
for x in range(256):
|
|
px[x, y] = (x, y, (x + y) % 256)
|
|
buf = io.BytesIO()
|
|
im.save(buf, "JPEG")
|
|
return buf.getvalue()
|
|
|
|
|
|
class _FakeLock:
|
|
def acquire(self, blocking=False):
|
|
return True
|
|
|
|
def release(self):
|
|
pass
|
|
|
|
|
|
class _FakeRedis:
|
|
def lock(self, name, timeout=None, blocking=False):
|
|
return _FakeLock()
|
|
|
|
|
|
def _seed(db_sync, *, host="pixeldrain", status="pending"):
|
|
# Key per host so a single test can seed several (artist name is UNIQUE).
|
|
artist = Artist(name=f"Ext {host}", slug=f"ext-{host}")
|
|
db_sync.add(artist)
|
|
db_sync.flush()
|
|
source = Source(
|
|
artist_id=artist.id, platform="patreon",
|
|
url=f"https://patreon.com/{host}", enabled=True, config_overrides={},
|
|
)
|
|
db_sync.add(source)
|
|
db_sync.flush()
|
|
post = Post(
|
|
source_id=source.id, artist_id=artist.id, external_post_id=f"EXT-{host}",
|
|
post_url=f"https://patreon.com/posts/{host}",
|
|
)
|
|
db_sync.add(post)
|
|
db_sync.flush()
|
|
link = ExternalLink(
|
|
post_id=post.id, artist_id=artist.id, host=host,
|
|
url=f"https://{host}.test/file", status=status,
|
|
)
|
|
db_sync.add(link)
|
|
db_sync.commit()
|
|
return post, link
|
|
|
|
|
|
def test_fetch_external_link_downloads_and_attaches(db_sync, tmp_path, monkeypatch):
|
|
post, link = _seed(db_sync)
|
|
link_id = link.id
|
|
|
|
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
|
|
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
|
|
|
|
def fake_fetch(host, url, dest_dir, **kwargs):
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
f = dest_dir / "film.bin" # non-art → PostAttachment (no thumb/ML enqueue)
|
|
f.write_bytes(b"a film pack")
|
|
return FetchResult(files=[f], bytes=f.stat().st_size)
|
|
|
|
monkeypatch.setattr(ext, "fetch_external", fake_fetch)
|
|
|
|
out = ext.fetch_external_link(link_id)
|
|
assert out.get("files") == 1
|
|
|
|
db_sync.expire_all()
|
|
refreshed = db_sync.get(ExternalLink, link_id)
|
|
assert refreshed.status == "downloaded"
|
|
assert refreshed.completed_at is not None
|
|
# The file was captured as a PostAttachment linked to the SAME post.
|
|
atts = db_sync.execute(
|
|
select(PostAttachment).where(PostAttachment.post_id == post.id)
|
|
).scalars().all()
|
|
assert len(atts) == 1
|
|
|
|
|
|
def test_refetch_same_link_keeps_canonical_file(db_sync, tmp_path, monkeypatch):
|
|
"""#859: re-fetching the SAME link (identical bytes into the same per-link
|
|
staging dir) must NOT unlink the canonical file out from under its record.
|
|
The first fetch imports the image in place; the second dedups on sha256, and
|
|
the path-safe unlink recognises the file IS the record's backing file and
|
|
keeps it — so the post never 404s on playback."""
|
|
_, link = _seed(db_sync, host="pixeldrain")
|
|
link_id = link.id
|
|
|
|
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
|
|
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
|
|
|
|
def fake_fetch(host, url, dest_dir, **kwargs):
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
f = dest_dir / "clip.jpg" # art → ImageRecord (imported in place)
|
|
f.write_bytes(_structured_jpeg_bytes())
|
|
return FetchResult(files=[f], bytes=f.stat().st_size)
|
|
|
|
monkeypatch.setattr(ext, "fetch_external", fake_fetch)
|
|
|
|
import backend.app.tasks.ml as ml_mod
|
|
import backend.app.tasks.thumbnail as thumb_mod
|
|
monkeypatch.setattr(ml_mod.embed_image, "delay", lambda i: None)
|
|
monkeypatch.setattr(thumb_mod.generate_thumbnail, "delay", lambda i: None)
|
|
|
|
out = ext.fetch_external_link(link_id)
|
|
assert out["images"] == 1
|
|
|
|
db_sync.expire_all()
|
|
rec = db_sync.execute(select(ImageRecord)).scalars().one()
|
|
canonical = tmp_path / rec.path # absolute path collapses the join
|
|
assert canonical.exists()
|
|
|
|
# Re-arm the SAME link and fetch again — same staging dir, identical bytes.
|
|
db_sync.execute(
|
|
update(ExternalLink).where(ExternalLink.id == link_id)
|
|
.values(status="pending", attempts=0, completed_at=None)
|
|
)
|
|
db_sync.commit()
|
|
|
|
out2 = ext.fetch_external_link(link_id)
|
|
assert out2["images"] == 0 # deduped, no new record
|
|
|
|
db_sync.expire_all()
|
|
# Exactly one record still, and its file survived the dedup unlink (the bug).
|
|
assert db_sync.execute(select(func.count(ImageRecord.id))).scalar() == 1
|
|
assert canonical.exists()
|
|
|
|
|
|
def test_fetch_external_link_records_failure(db_sync, tmp_path, monkeypatch):
|
|
_, link = _seed(db_sync)
|
|
link_id = link.id
|
|
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
|
|
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
|
|
monkeypatch.setattr(
|
|
ext, "fetch_external",
|
|
lambda *a, **k: FetchResult(error="host 503"),
|
|
)
|
|
|
|
ext.fetch_external_link(link_id)
|
|
db_sync.expire_all()
|
|
refreshed = db_sync.get(ExternalLink, link_id)
|
|
assert refreshed.status == "failed"
|
|
assert refreshed.attempts == 1
|
|
assert "503" in refreshed.last_error
|
|
|
|
|
|
def test_fetch_external_link_dead_letters_at_threshold(db_sync, tmp_path, monkeypatch):
|
|
_, link = _seed(db_sync)
|
|
link.attempts = ext.DEAD_LETTER_THRESHOLD - 1
|
|
db_sync.commit()
|
|
link_id = link.id
|
|
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
|
|
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
|
|
monkeypatch.setattr(ext, "fetch_external", lambda *a, **k: FetchResult(error="nope"))
|
|
|
|
ext.fetch_external_link(link_id)
|
|
db_sync.expire_all()
|
|
assert db_sync.get(ExternalLink, link_id).status == "dead"
|
|
|
|
|
|
def test_fetch_external_link_skips_non_claimable(db_sync, tmp_path, monkeypatch):
|
|
_, link = _seed(db_sync, status="downloaded")
|
|
link_id = link.id
|
|
called = []
|
|
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
|
|
monkeypatch.setattr(
|
|
ext, "fetch_external",
|
|
lambda *a, **k: called.append(1) or FetchResult(),
|
|
)
|
|
out = ext.fetch_external_link(link_id)
|
|
assert out.get("skipped") == "not claimable"
|
|
assert called == [] # never fetched
|
|
|
|
|
|
def test_sweep_enqueues_pending_and_retryable(db_sync, monkeypatch):
|
|
_, l1 = _seed(db_sync, host="pixeldrain")
|
|
_, l2 = _seed(db_sync, host="mega", status="failed")
|
|
# A dead one must NOT be swept.
|
|
_, l3 = _seed(db_sync, host="dropbox", status="dead")
|
|
|
|
enqueued = []
|
|
monkeypatch.setattr(ext.fetch_external_link, "delay", lambda lid: enqueued.append(lid))
|
|
|
|
out = ext.sweep_external_links()
|
|
assert out["enqueued"] == 2
|
|
assert set(enqueued) == {l1.id, l2.id}
|
|
|
|
|
|
def test_downloaded_archive_gets_provenance_and_tagging(db_sync, tmp_path, monkeypatch):
|
|
"""An archive downloaded by the worker is extracted, provenance-linked to the
|
|
post, AND queued for tagging + thumbnails — exactly like an extracted zip on
|
|
the normal download path."""
|
|
post, link = _seed(db_sync, host="mega")
|
|
link_id = link.id
|
|
|
|
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
|
|
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
|
|
|
|
def fake_fetch(host, url, dest_dir, **kwargs):
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
zpath = dest_dir / "pack.zip"
|
|
with zipfile.ZipFile(zpath, "w") as z:
|
|
z.writestr("art.jpg", _structured_jpeg_bytes())
|
|
return FetchResult(files=[zpath], bytes=zpath.stat().st_size)
|
|
|
|
monkeypatch.setattr(ext, "fetch_external", fake_fetch)
|
|
|
|
tagged, thumbed = [], []
|
|
import backend.app.tasks.ml as ml_mod
|
|
import backend.app.tasks.thumbnail as thumb_mod
|
|
monkeypatch.setattr(ml_mod.embed_image, "delay", lambda i: tagged.append(i))
|
|
monkeypatch.setattr(thumb_mod.generate_thumbnail, "delay", lambda i: thumbed.append(i))
|
|
|
|
out = ext.fetch_external_link(link_id)
|
|
assert out["images"] >= 1
|
|
|
|
db_sync.expire_all()
|
|
assert db_sync.get(ExternalLink, link_id).status == "downloaded"
|
|
# Extracted member is provenance-linked to the SAME post (as extracted zips).
|
|
prov = db_sync.execute(
|
|
select(ImageProvenance).where(ImageProvenance.post_id == post.id)
|
|
).scalars().all()
|
|
assert len(prov) >= 1
|
|
member_ids = {p.image_record_id for p in prov}
|
|
# Tagging + thumbnails queued for exactly those member images.
|
|
assert set(tagged) == member_ids
|
|
assert set(thumbed) == member_ids
|
|
|
|
|
|
def _link_dir(tmp_path, host, link_id):
|
|
return tmp_path / f"ext-{host}" / "patreon" / "external" / f"EXT-{host}" / str(link_id)
|
|
|
|
|
|
def test_extracted_archive_is_not_left_in_the_link_dir(db_sync, tmp_path, monkeypatch):
|
|
"""An archive whose images were extracted is in the attachments store, so
|
|
the downloaded copy is removed instead of sitting beside it."""
|
|
_, link = _seed(db_sync, host="mega")
|
|
link_id = link.id
|
|
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
|
|
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
|
|
written = []
|
|
|
|
def fake_fetch(host, url, dest_dir, **kwargs):
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
zpath = dest_dir / "pack.zip"
|
|
with zipfile.ZipFile(zpath, "w") as z:
|
|
z.writestr("art.jpg", _structured_jpeg_bytes())
|
|
written.append(zpath)
|
|
return FetchResult(files=[zpath], bytes=zpath.stat().st_size)
|
|
|
|
monkeypatch.setattr(ext, "fetch_external", fake_fetch)
|
|
import backend.app.tasks.ml as ml_mod
|
|
import backend.app.tasks.thumbnail as thumb_mod
|
|
monkeypatch.setattr(ml_mod.embed_image, "delay", lambda i: None)
|
|
monkeypatch.setattr(thumb_mod.generate_thumbnail, "delay", lambda i: None)
|
|
|
|
out = ext.fetch_external_link(link_id)
|
|
assert out["images"] >= 1
|
|
assert not written[0].exists()
|
|
db_sync.expire_all()
|
|
assert db_sync.execute(
|
|
select(func.count(PostAttachment.id))
|
|
.where(PostAttachment.original_filename == "pack.zip")
|
|
).scalar_one() == 1
|
|
|
|
|
|
def test_a_failed_fetch_leaves_no_partial_files(db_sync, tmp_path, monkeypatch):
|
|
"""A fetch that fails midway (megatools leaves `.megatmp.*` behind) clears
|
|
the link's staging dir, so a dead link leaves nothing on disk."""
|
|
_, link = _seed(db_sync, host="mega")
|
|
link_id = link.id
|
|
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
|
|
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
|
|
|
|
def fake_fetch(host, url, dest_dir, **kwargs):
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
(dest_dir / ".megatmp.AbCd").write_bytes(b"half a game build")
|
|
return FetchResult(error="megatools dl exit 1: timed out")
|
|
|
|
monkeypatch.setattr(ext, "fetch_external", fake_fetch)
|
|
ext.fetch_external_link(link_id)
|
|
|
|
link_dir = _link_dir(tmp_path, "mega", link_id)
|
|
assert not (link_dir / ".megatmp.AbCd").exists()
|
|
db_sync.expire_all()
|
|
assert db_sync.get(ExternalLink, link_id).status == "failed"
|
|
|
|
|
|
def test_a_retry_clears_what_a_killed_attempt_left(db_sync, tmp_path, monkeypatch):
|
|
"""Leftovers from an attempt whose worker was killed are removed before the
|
|
next fetch starts; an image imported in place by an earlier attempt stays."""
|
|
_, link = _seed(db_sync, host="pixeldrain")
|
|
link_id = link.id
|
|
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
|
|
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
|
|
link_dir = _link_dir(tmp_path, "pixeldrain", link_id)
|
|
link_dir.mkdir(parents=True)
|
|
leftover = link_dir / "build.zip.part"
|
|
leftover.write_bytes(b"partial")
|
|
imported = link_dir / "kept.jpg"
|
|
imported.write_bytes(_structured_jpeg_bytes())
|
|
db_sync.add(ImageRecord(
|
|
path=str(imported), sha256="ext-kept".ljust(64, "x"),
|
|
size_bytes=imported.stat().st_size, mime="image/jpeg",
|
|
width=256, height=256, origin="downloaded", integrity_status="ok",
|
|
))
|
|
db_sync.commit()
|
|
seen_at_fetch = []
|
|
|
|
def fake_fetch(host, url, dest_dir, **kwargs):
|
|
seen_at_fetch.extend(sorted(p.name for p in dest_dir.iterdir()))
|
|
return FetchResult(error="HTTP 503")
|
|
|
|
monkeypatch.setattr(ext, "fetch_external", fake_fetch)
|
|
ext.fetch_external_link(link_id)
|
|
|
|
assert seen_at_fetch == ["kept.jpg"]
|
|
assert imported.exists()
|
|
assert not leftover.exists()
|
|
|
|
|
|
def test_a_rejected_download_is_removed(db_sync, tmp_path, monkeypatch):
|
|
"""A downloaded image the import filters reject has no record, so it is
|
|
removed rather than left in the link's dir."""
|
|
from backend.app.models import ImportSettings
|
|
|
|
_, link = _seed(db_sync, host="dropbox")
|
|
link_id = link.id
|
|
settings = db_sync.execute(
|
|
select(ImportSettings).where(ImportSettings.id == 1)
|
|
).scalar_one()
|
|
settings.min_width = 512
|
|
db_sync.commit()
|
|
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
|
|
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
|
|
written = []
|
|
|
|
def fake_fetch(host, url, dest_dir, **kwargs):
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
f = dest_dir / "small.jpg" # 256px wide, below min_width
|
|
f.write_bytes(_structured_jpeg_bytes())
|
|
written.append(f)
|
|
return FetchResult(files=[f], bytes=f.stat().st_size)
|
|
|
|
monkeypatch.setattr(ext, "fetch_external", fake_fetch)
|
|
out = ext.fetch_external_link(link_id)
|
|
|
|
assert out["images"] == 0
|
|
assert not written[0].exists()
|
|
db_sync.expire_all()
|
|
assert db_sync.get(ExternalLink, link_id).status == "downloaded"
|
|
|
|
|
|
def test_sweep_recovers_a_link_whose_worker_died(db_sync, monkeypatch):
|
|
"""A link 'downloading' past the task's hard limit (or with no claim time)
|
|
counts as a failed attempt and is retried; a fresh claim is left alone."""
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
_, stale = _seed(db_sync, host="mega", status="downloading")
|
|
_, unclaimed = _seed(db_sync, host="gdrive", status="downloading")
|
|
_, fresh = _seed(db_sync, host="dropbox", status="downloading")
|
|
now = datetime.now(UTC)
|
|
stale.started_at = now - timedelta(minutes=ext._STALL_MINUTES + 5)
|
|
fresh.started_at = now - timedelta(minutes=5)
|
|
db_sync.commit()
|
|
stale_id, unclaimed_id, fresh_id = stale.id, unclaimed.id, fresh.id
|
|
|
|
enqueued = []
|
|
monkeypatch.setattr(ext.fetch_external_link, "delay", lambda lid: enqueued.append(lid))
|
|
out = ext.sweep_external_links()
|
|
|
|
assert out["recovered"] == 2
|
|
assert set(enqueued) == {stale_id, unclaimed_id}
|
|
db_sync.expire_all()
|
|
for lid in (stale_id, unclaimed_id):
|
|
row = db_sync.get(ExternalLink, lid)
|
|
assert (row.status, row.attempts) == ("failed", 1)
|
|
assert db_sync.get(ExternalLink, fresh_id).status == "downloading"
|
|
|
|
|
|
def test_recovery_gives_a_dead_link_one_more_attempt_at_growing_intervals(db_sync, monkeypatch):
|
|
"""A dead link is retried once after 2**recoveries days, not given a fresh
|
|
three-attempt budget, so a link that always fails isn't downloaded daily."""
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
now = datetime.now(UTC)
|
|
_, due = _seed(db_sync, host="mega", status="dead")
|
|
_, waiting = _seed(db_sync, host="gdrive", status="dead")
|
|
_, spent = _seed(db_sync, host="dropbox", status="dead")
|
|
due.attempts = waiting.attempts = spent.attempts = ext.DEAD_LETTER_THRESHOLD
|
|
due.recoveries, due.completed_at = 2, now - timedelta(days=4, hours=1)
|
|
waiting.recoveries, waiting.completed_at = 2, now - timedelta(days=3)
|
|
spent.recoveries, spent.completed_at = ext._MAX_RECOVERIES, now - timedelta(days=90)
|
|
db_sync.commit()
|
|
due_id, waiting_id, spent_id = due.id, waiting.id, spent.id
|
|
|
|
enqueued = []
|
|
monkeypatch.setattr(ext.fetch_external_link, "delay", lambda lid: enqueued.append(lid))
|
|
out = ext.recover_external_links()
|
|
|
|
assert out["revived"] == 1
|
|
assert enqueued == [due_id]
|
|
db_sync.expire_all()
|
|
row = db_sync.get(ExternalLink, due_id)
|
|
assert (row.status, row.attempts, row.recoveries) == (
|
|
"failed", ext.DEAD_LETTER_THRESHOLD - 1, 3,
|
|
)
|
|
assert db_sync.get(ExternalLink, waiting_id).status == "dead"
|
|
assert db_sync.get(ExternalLink, spent_id).status == "dead"
|
|
|
|
|
|
def test_a_recovered_link_that_fails_again_goes_straight_back_to_dead(
|
|
db_sync, tmp_path, monkeypatch,
|
|
):
|
|
_, link = _seed(db_sync, host="mega", status="dead")
|
|
link.attempts = ext.DEAD_LETTER_THRESHOLD
|
|
db_sync.commit()
|
|
link_id = link.id
|
|
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
|
|
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
|
|
monkeypatch.setattr(ext, "fetch_external", lambda *a, **k: FetchResult(error="too big"))
|
|
monkeypatch.setattr(ext.fetch_external_link, "delay", ext.fetch_external_link)
|
|
|
|
ext.recover_external_links()
|
|
|
|
db_sync.expire_all()
|
|
row = db_sync.get(ExternalLink, link_id)
|
|
assert (row.status, row.recoveries) == ("dead", 1)
|
|
|
|
|
|
def test_prune_removes_only_links_recovery_has_given_up_on(db_sync):
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
old = datetime.now(UTC) - timedelta(days=ext._RETENTION_DAYS + 1)
|
|
_, spent = _seed(db_sync, host="mega", status="dead")
|
|
_, recovering = _seed(db_sync, host="gdrive", status="dead")
|
|
_, recent = _seed(db_sync, host="dropbox", status="dead")
|
|
spent.recoveries, spent.completed_at = ext._MAX_RECOVERIES, old
|
|
recovering.recoveries, recovering.completed_at = 1, old
|
|
recent.recoveries = ext._MAX_RECOVERIES
|
|
recent.completed_at = datetime.now(UTC) - timedelta(days=1)
|
|
db_sync.commit()
|
|
spent_id, recovering_id, recent_id = spent.id, recovering.id, recent.id
|
|
|
|
assert ext.prune_external_links() == {"pruned": 1}
|
|
db_sync.expire_all()
|
|
assert db_sync.get(ExternalLink, spent_id) is None
|
|
assert db_sync.get(ExternalLink, recovering_id) is not None
|
|
assert db_sync.get(ExternalLink, recent_id) is not None
|