Warum eine angeklickte Artikelseite nur zur Haelfte lud: Die Bild-Route wird je Tabellenzeile einmal aufgerufen, und ein Browser haelt je Host nur rund sechs Verbindungen offen. Was diese Route aufhaelt, legt die ganze Oberflaeche lahm. Sie hielt gleich dreifach auf: 1. Fehlte die lokale Kopie, holte ``images.ensure`` das Bild MITTEN in der Anfrage von der fremden Adresse - bis zu 15 Sekunden, je Zeile, und bei jedem Aufruf erneut, weil ein Fehlschlag nirgends vermerkt wurde. Artikel aus einem Backup-Import haben genau diese Ausgangslage. Gemessen: 2,01 s je Aufruf, dreimal hintereinander. Jetzt: 0,01 s, das Nachholen laeuft nach der Antwort im Hintergrund und ein toter Link wird eine Stunde gesperrt. 2. Ein Vorschaubild ist 34x34 Pixel gross - ausgeliefert wurde das Original. Gemessen: 2530 KB je Briefmarke, bei 40 Zeilen 99 MB fuer eine Liste. Neu erzeugt die Bild-Route (Pillow) eine Vorschau und legt sie daneben ab; ``?thumb=1`` liefert sie aus. Gemessen: 0,6 KB. 3. If-None-Match wurde ignoriert. Nach den 5 Minuten Cache-Frist lud der Browser jedes Bild komplett neu - das erklaert, warum es "alle 20 Minuten" wieder losging. Jetzt 304 ohne Daten. Dazu im Web: laufende Bildabrufe werden beim Seitenwechsel abgebrochen (sonst steht die neue Seite hinter Bildern Schlange, die niemand mehr sieht), und wo ``image_version`` bekannt ist, entfaellt die Anfrage fuer bildlose Artikel ganz. Und der Verbindungsvorrat der Datenbank: 5 (+10) gegen 40 Arbeits-Threads von FastAPI. Ab der 16. gleichzeitigen Anfrage wartete eine Route stillschweigend 30 Sekunden - von aussen ein haengender Server. Jetzt 10 (+20) mit 10 Sekunden Frist: lieber ein Fehler als eine halbe Minute Stille. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
148 lines
4.7 KiB
Python
148 lines
4.7 KiB
Python
import json
|
|
|
|
import pytest
|
|
|
|
from app.models import BaseUnit, Product, ProductImage, Role, User
|
|
from app.routers import transfer
|
|
from app.services import images
|
|
|
|
EIN_PIXEL = b"\x89PNG\r\n\x1a\n-nicht-echt-aber-egal"
|
|
|
|
|
|
def _artikel(db, url=None):
|
|
p = Product(name="Pesto", base_unit=BaseUnit.gram, package_size=195, image_url=url)
|
|
db.add(p)
|
|
db.commit()
|
|
db.refresh(p)
|
|
return p
|
|
|
|
|
|
def test_image_version_spiegelt_bildzeitstempel(db):
|
|
from app.crud import product_to_out
|
|
|
|
p = _artikel(db)
|
|
assert product_to_out(db, p).image_version is None # kein Bild
|
|
img = ProductImage(product_id=p.id, content_type="image/png", data=EIN_PIXEL)
|
|
db.add(img)
|
|
db.commit()
|
|
db.refresh(img)
|
|
assert product_to_out(db, p).image_version == int(img.updated_at.timestamp())
|
|
|
|
|
|
def _im_hintergrund(db, monkeypatch, artikel):
|
|
"""``nachholen`` laeuft mit eigener Sitzung - im Test mit der des Fixtures."""
|
|
monkeypatch.setattr(db, "close", lambda: None)
|
|
monkeypatch.setattr("app.database.SessionLocal", lambda: db)
|
|
images.nachholen(artikel.id, artikel.image_url)
|
|
|
|
|
|
def test_bild_wird_einmal_geholt_und_danach_lokal_geliefert(db, monkeypatch):
|
|
aufrufe = []
|
|
|
|
def gefaelscht(url):
|
|
aufrufe.append(url)
|
|
return EIN_PIXEL, "image/png"
|
|
|
|
monkeypatch.setattr(images, "fetch", gefaelscht)
|
|
images.sperre_zuruecksetzen()
|
|
artikel = _artikel(db, "https://example.org/pesto.png")
|
|
|
|
_im_hintergrund(db, monkeypatch, artikel)
|
|
erstes = db.get(ProductImage, artikel.id)
|
|
assert erstes is not None
|
|
assert erstes.data == EIN_PIXEL
|
|
assert erstes.content_type == "image/png"
|
|
|
|
# Ab jetzt liegt die Kopie lokal - die Bild-Route greift gar nicht mehr zu
|
|
# ``nachholen``, siehe test_bild_auslieferung.py.
|
|
assert aufrufe == ["https://example.org/pesto.png"]
|
|
|
|
|
|
def test_ein_nicht_erreichbares_bild_bleibt_folgenlos(db, monkeypatch):
|
|
monkeypatch.setattr(images, "fetch", lambda url: None)
|
|
images.sperre_zuruecksetzen()
|
|
artikel = _artikel(db, "https://example.org/weg.png")
|
|
_im_hintergrund(db, monkeypatch, artikel)
|
|
assert db.get(ProductImage, artikel.id) is None
|
|
|
|
|
|
@pytest.fixture()
|
|
def user(db):
|
|
person = User(username="tester", password_hash="x", role=Role.admin)
|
|
db.add(person)
|
|
db.commit()
|
|
db.refresh(person)
|
|
return person
|
|
|
|
|
|
def test_backup_import_uebernimmt_die_bildadresse(db, user):
|
|
"""Sie stand im Backup, ging beim Einlesen aber verloren.
|
|
|
|
Der Test geht bewusst durch _import_json und nicht nur durch
|
|
_get_or_create_product: Genau zwischen beiden fiel die Adresse heraus.
|
|
"""
|
|
backup = {
|
|
"products": [
|
|
{
|
|
"name": "Pesto",
|
|
"brand": "Barilla",
|
|
"image_url": "https://example.org/pesto.jpg",
|
|
"unit": "Gramm",
|
|
"package_size": 195,
|
|
"lots": [],
|
|
}
|
|
]
|
|
}
|
|
transfer._import_json(db, json.dumps(backup).encode("utf-8"), user, "add")
|
|
db.commit()
|
|
|
|
artikel = db.query(Product).filter(Product.name == "Pesto").one()
|
|
assert artikel.image_url == "https://example.org/pesto.jpg"
|
|
|
|
|
|
def test_import_ueberschreibt_kein_vorhandenes_bild(db, user):
|
|
vorhanden = Product(
|
|
name="Pesto", base_unit=BaseUnit.gram, image_url="https://eigenes.example/bild.jpg"
|
|
)
|
|
db.add(vorhanden)
|
|
db.commit()
|
|
|
|
backup = {"products": [{"name": "Pesto", "image_url": "https://off.example/anderes.jpg",
|
|
"unit": "Gramm", "lots": []}]}
|
|
transfer._import_json(db, json.dumps(backup).encode("utf-8"), user, "add")
|
|
db.commit()
|
|
|
|
db.refresh(vorhanden)
|
|
assert vorhanden.image_url == "https://eigenes.example/bild.jpg"
|
|
|
|
|
|
def test_speichern_holt_nach_wenn_die_kopie_fehlt(db, user, monkeypatch):
|
|
"""Der haeufigste Fall: Adresse stimmt laengst, der Abruf schlug nur einmal fehl.
|
|
|
|
Wuerde nur auf eine geaenderte Adresse geachtet, bliebe so ein Artikel
|
|
dauerhaft ohne Bild - "Uebernehmen" waere dann folgenlos.
|
|
"""
|
|
from app.routers.products import update_product
|
|
from app.schemas import ProductUpdate
|
|
|
|
monkeypatch.setattr(images, "fetch", lambda url: (EIN_PIXEL, "image/png"))
|
|
artikel = _artikel(db, "https://example.org/pesto.png")
|
|
assert db.get(ProductImage, artikel.id) is None
|
|
|
|
update_product(
|
|
artikel.id,
|
|
ProductUpdate(image_url="https://example.org/pesto.png"),
|
|
db=db,
|
|
_=user,
|
|
)
|
|
assert db.get(ProductImage, artikel.id) is not None
|
|
|
|
|
|
def test_fetch_geht_nur_an_http_adressen(monkeypatch):
|
|
"""Ohne diese Schranke waere eine 'file://'-Adresse ein Weg ins Dateisystem."""
|
|
monkeypatch.setattr(
|
|
images.httpx, "get", lambda *a, **k: (_ for _ in ()).throw(AssertionError)
|
|
)
|
|
assert images.fetch("file:///etc/passwd") is None
|
|
assert images.fetch("") is None
|