"""Tests für die Lese-Schnittstelle der Handy-App. Konzentriert auf das, was leise falsch sein kann: der Zugang (ein zu weit geöffneter Endpunkt fällt niemandem auf), die Handbestimmung (die App würde sonst dauerhaft die korrigierte Art verschweigen), die Warteschleife nach einem Upload (ein falsches ``wartet`` dreht endlos) und die Bildadressen (relativ ausgeliefert ist in einer App schlicht kein Bild). """ from __future__ import annotations import json from datetime import timedelta from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase, override_settings from django.utils import timezone from . import scoring from .models import Badge, Camera, DexEntry, EarnedBadge, Sighting, Species from .tests import make_camera, make_species, make_sighting # Ein 1x1-PNG. Reicht, weil wir die Adresse prüfen, nicht das Bild. PNG = ( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00" b"\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" ) LESEN = "lese-token-fuer-tests" def bild(name="v.png") -> SimpleUploadedFile: return SimpleUploadedFile(name, PNG, content_type="image/png") @override_settings(LESE_TOKEN=LESEN) class ZugangTests(TestCase): """Wer darf lesen — und vor allem: wer nicht.""" def setUp(self): make_camera(token="kamera-geheim") def hole(self, token=None): kopf = {} if token is not None: kopf["HTTP_AUTHORIZATION"] = f"Bearer {token}" return self.client.get("/api/app/start/", **kopf) def test_ohne_header_abgelehnt(self): self.assertEqual(self.hole().status_code, 401) def test_falscher_token_abgelehnt(self): self.assertEqual(self.hole("nein").status_code, 401) def test_lese_token_darf(self): self.assertEqual(self.hole(LESEN).status_code, 200) def test_kamera_token_darf_auch(self): """Die App soll mit EINEM Geheimnis auskommen — dem ihrer Kamera.""" self.assertEqual(self.hole("kamera-geheim").status_code, 200) def test_header_ohne_bearer_abgelehnt(self): resp = self.client.get("/api/app/start/", HTTP_AUTHORIZATION=LESEN) self.assertEqual(resp.status_code, 401) @override_settings(LESE_TOKEN="") def test_leerer_lese_token_heisst_zu_nicht_offen(self): """⚠️ Die Regel aus api.py gilt hier genauso. Ein leerer Token ist ein vergessenes Geheimnis, kein Freibrief. Ohne diese Prüfung stünde die Sammlung im Netz, sobald jemand die Umgebungsvariable nicht setzt — und ein leerer Bearer-Header käme durch. """ self.assertEqual(self.hole("").status_code, 401) self.assertEqual(self.hole("irgendwas").status_code, 401) # Die Kamera bleibt der Weg hinein, auch ohne Lese-Token. self.assertEqual(self.hole("kamera-geheim").status_code, 200) def test_alle_endpunkte_sind_geschuetzt(self): """Ein neuer Endpunkt ohne Prüfung ist der Fehler, der nie auffällt.""" art = make_species() s = make_sighting(art) for pfad in ( "/api/app/start/", "/api/app/album/", f"/api/app/album/{art.slug}/", "/api/app/sichtungen/", f"/api/app/sichtungen/{s.id}/", "/api/app/abzeichen/", ): with self.subTest(pfad=pfad): self.assertEqual(self.client.get(pfad).status_code, 401) @override_settings(LESE_TOKEN=LESEN) class AppTestBasis(TestCase): def setUp(self): scoring.sync_badges() def json(self, pfad, **params): resp = self.client.get( pfad, params, HTTP_AUTHORIZATION=f"Bearer {LESEN}" ) self.assertEqual(resp.status_code, 200, resp.content[:400]) return json.loads(resp.content) class AlbumTests(AppTestBasis): def setUp(self): super().setUp() self.kohlmeise = make_species( slug="kohlmeise", german="Kohlmeise", number=3, lebensraum_typ="garten", typ="meise", ) self.blaumeise = make_species( slug="blaumeise", german="Blaumeise", scientific="Cyanistes caeruleus", number=1, lebensraum_typ="garten", typ="meise", ) self.reiher = make_species( slug="graureiher", german="Graureiher", scientific="Ardea cinerea", number=2, lebensraum_typ="wasser", typ="schreitvogel", ) def test_vorgabe_ist_garten(self): """Ohne Angabe nur der Garten — 148 Arten sind eine Wand, kein Ziel.""" daten = self.json("/api/app/album/") self.assertEqual(daten["lebensraum"], "garten") self.assertEqual( {a["slug"] for a in daten["arten"]}, {"kohlmeise", "blaumeise"} ) def test_alle_holt_jeden_lebensraum(self): daten = self.json("/api/app/album/", lebensraum="alle") self.assertEqual(len(daten["arten"]), 3) self.assertEqual(daten["lebensraum"], "") def test_sortierung_nach_albumnummer_nicht_nach_fund(self): """Die Nummer ist die Seitenzahl und bleibt, wo sie ist.""" scoring.register(make_sighting(self.kohlmeise)) # Nummer 3, gefunden daten = self.json("/api/app/album/", lebensraum="alle") self.assertEqual( [a["nummer"] for a in daten["arten"]], [1, 2, 3] ) def test_ungefundene_art_ist_schatten_ohne_foto(self): daten = self.json("/api/app/album/") seite = next(a for a in daten["arten"] if a["slug"] == "kohlmeise") self.assertFalse(seite["gefunden"]) self.assertNotIn("foto", seite) self.assertNotIn("anzahl", seite) def test_gefundene_art_bringt_zaehler_und_foto(self): s = make_sighting(self.kohlmeise) s.image = bild() s.save() scoring.register(s) daten = self.json("/api/app/album/") seite = next(a for a in daten["arten"] if a["slug"] == "kohlmeise") self.assertTrue(seite["gefunden"]) self.assertEqual(seite["anzahl"], 1) self.assertTrue(seite["neu"]) self.assertTrue(seite["foto"].startswith("http://")) def test_zaehler_je_lebensraum(self): scoring.register(make_sighting(self.blaumeise)) daten = self.json("/api/app/album/") self.assertEqual(daten["gefunden"], 1) self.assertEqual(daten["gesamt"], 2) garten = next( h for h in daten["lebensraeume"] if h["code"] == "garten" ) wasser = next( h for h in daten["lebensraeume"] if h["code"] == "wasser" ) self.assertEqual((garten["gefunden"], garten["gesamt"]), (1, 2)) self.assertEqual((wasser["gefunden"], wasser["gesamt"]), (0, 1)) def test_leere_lebensraeume_fehlen_in_den_reitern(self): codes = {h["code"] for h in self.json("/api/app/album/")["lebensraeume"]} self.assertEqual(codes, {"garten", "wasser"}) def test_farben_kommen_mit(self): """Die App soll die Palette nicht nachbauen müssen.""" seite = next( a for a in self.json("/api/app/album/")["arten"] if a["slug"] == "kohlmeise" ) self.assertEqual(seite["typ_farbe"], "#f2c744") self.assertEqual(seite["seltenheit_farbe"], "#7f9b6a") class ArtTests(AppTestBasis): def setUp(self): super().setUp() self.art = make_species( number=5, gewicht_g=18.0, spannweite_cm=24.0, size_cm="14 cm", verwechslung=["blaumeise"], ) make_species(slug="blaumeise", german="Blaumeise", number=6) def test_unbekannte_art_ist_404(self): resp = self.client.get( "/api/app/album/gibtsnicht/", HTTP_AUTHORIZATION=f"Bearer {LESEN}" ) self.assertEqual(resp.status_code, 404) def test_steckbrief_und_balken(self): daten = self.json(f"/api/app/album/{self.art.slug}/") self.assertEqual(daten["art"]["slug"], "kohlmeise") self.assertTrue(daten["art"]["balken"]) labels = {b["label"] for b in daten["art"]["balken"]} self.assertIn("Gewicht", labels) def test_verwechslung_in_katalogreihenfolge(self): daten = self.json(f"/api/app/album/{self.art.slug}/") self.assertEqual( [a["slug"] for a in daten["verwechslung"]], ["blaumeise"] ) def test_blaettern_zeigt_die_nachbarn(self): daten = self.json(f"/api/app/album/{self.art.slug}/") self.assertIsNone(daten["blaettern"]["vorher"]) self.assertEqual(daten["blaettern"]["nachher"], "blaumeise") def test_tagesverlauf_hat_24_stunden_in_ortszeit(self): wann = timezone.localtime(timezone.now()).replace(hour=6, minute=31) make_sighting(self.art, when=wann) daten = self.json(f"/api/app/album/{self.art.slug}/") self.assertEqual(len(daten["tagesverlauf"]), 24) self.assertEqual(daten["tagesverlauf"][6]["anzahl"], 1) self.assertEqual(daten["tagesverlauf"][6]["anteil"], 1.0) def test_handbestimmung_zaehlt_als_besuch_dieser_art(self): """Eine Korrektur im Admin muss in der App ankommen.""" andere = make_species(slug="amsel", german="Amsel", number=9) s = make_sighting(andere) s.confirmed_species = self.art s.save() daten = self.json(f"/api/app/album/{self.art.slug}/") self.assertEqual(len(daten["besuche"]), 1) self.assertEqual(daten["besuche"][0]["art"]["slug"], "kohlmeise") self.assertTrue(daten["besuche"][0]["handbestimmt"]) class SichtungenTests(AppTestBasis): def setUp(self): super().setUp() self.art = make_species() self.kamera = make_camera(token="kamera-geheim") def test_juengste_zuerst(self): jetzt = timezone.now() alt = make_sighting(self.art, when=jetzt - timedelta(hours=2)) neu = make_sighting(self.art, when=jetzt) daten = self.json("/api/app/sichtungen/") self.assertEqual([s["id"] for s in daten["sichtungen"]], [neu.id, alt.id]) def test_nur_bestimmt_blendet_artlose_aus(self): make_sighting(self.art) make_sighting(None) self.assertEqual(self.json("/api/app/sichtungen/")["gesamt"], 2) self.assertEqual( self.json("/api/app/sichtungen/", nur_bestimmt="1")["gesamt"], 1 ) def test_filter_nach_art(self): andere = make_species(slug="amsel", german="Amsel") make_sighting(self.art) make_sighting(andere) daten = self.json("/api/app/sichtungen/", art="amsel") self.assertEqual(daten["gesamt"], 1) self.assertEqual(daten["sichtungen"][0]["art"]["slug"], "amsel") def test_filter_nach_kamera(self): s = make_sighting(self.art) s.camera = self.kamera s.save() make_sighting(self.art) self.assertEqual( self.json("/api/app/sichtungen/", kamera=self.kamera.slug)["gesamt"], 1 ) def test_seiten(self): for _ in range(5): make_sighting(self.art) erste = self.json("/api/app/sichtungen/", anzahl=2) self.assertEqual(len(erste["sichtungen"]), 2) self.assertTrue(erste["weiter"]) letzte = self.json("/api/app/sichtungen/", anzahl=2, seite=3) self.assertEqual(len(letzte["sichtungen"]), 1) self.assertFalse(letzte["weiter"]) def test_anzahl_ist_gedeckelt(self): """Ein Feed über Mobilfunk darf nicht alles auf einmal ziehen.""" daten = self.json("/api/app/sichtungen/", anzahl=5000) self.assertEqual(daten["pro_seite"], 60) def test_unsinnige_seite_fuehrt_nicht_zum_fehler(self): make_sighting(self.art) daten = self.json("/api/app/sichtungen/", seite="haha", anzahl="auch") self.assertEqual(daten["seite"], 1) self.assertEqual(daten["pro_seite"], 24) def test_art_bringt_ihr_referenzbild_mit(self): """Damit eine Sichtung ohne eigenes Foto nicht als Fragezeichen endet. Gefunden am gerenderten Feed: Die Demo-Sichtungen hatten keine Bilder, und die App zeigte neben "Haussperling" ein Fragezeichen — obwohl sie den Vogel abbilden könnte. """ self.art.reference_image.name = "referenz/kohlmeise.jpg" self.art.save(update_fields=["reference_image"]) make_sighting(self.art) zeile = self.json("/api/app/sichtungen/")["sichtungen"][0] self.assertTrue(zeile["art"]["referenzbild"].startswith("http://")) def test_bildadressen_sind_absolut(self): s = make_sighting(self.art) s.image = bild("ganz.png") s.crop = bild("aus.png") s.save() zeile = self.json("/api/app/sichtungen/")["sichtungen"][0] self.assertTrue(zeile["foto"].startswith("http://")) self.assertTrue(zeile["ausschnitt"].startswith("http://")) def test_bildadressen_folgen_dem_proxy_auf_https(self): """🔴 Hinter Traefik spricht niemand HTTPS mit uns — Django baut sonst jede Bildadresse mit ``http://``. Im Browser fällt das nicht auf (Traefik leitet um). Auf dem Telefon ist es ein Ausfall: iOS blockt einfaches HTTP, ohne der Umleitung zu folgen, und das Album bleibt bilderlos — genau auf dem Gerät, für das es gebaut ist. Gefunden an den echten Antworten der Live-Instanz, nicht im Test. """ s = make_sighting(self.art) s.image = bild("ganz.png") s.save() self.art.reference_image.name = "referenz/kohlmeise.jpg" self.art.save(update_fields=["reference_image"]) resp = self.client.get( "/api/app/sichtungen/", HTTP_AUTHORIZATION=f"Bearer {LESEN}", # Genau der Header, den Traefik setzt. HTTP_X_FORWARDED_PROTO="https", secure=False, ) self.assertEqual(resp.status_code, 200) zeile = json.loads(resp.content)["sichtungen"][0] self.assertTrue(zeile["foto"].startswith("https://"), zeile["foto"]) self.assertTrue( zeile["art"]["referenzbild"].startswith("https://"), zeile["art"]["referenzbild"], ) def test_bildadressen_sind_absolut(self): s = make_sighting(self.art) s.image = bild("ganz.png") s.crop = bild("aus.png") s.save() zeile = self.json("/api/app/sichtungen/")["sichtungen"][0] self.assertTrue(zeile["foto"].startswith("http://")) self.assertTrue(zeile["ausschnitt"].startswith("http://")) def test_bildadressen_folgen_dem_proxy_auf_https(self): """🔴 Hinter Traefik spricht niemand HTTPS mit uns — Django baut sonst jede Bildadresse mit ``http://``. Im Browser fällt das nicht auf (Traefik leitet um). Auf dem Telefon ist es ein Ausfall: iOS blockt einfaches HTTP, ohne der Umleitung zu folgen, und das Album bleibt bilderlos — genau auf dem Gerät, für das es gebaut ist. Gefunden an den echten Antworten der Live-Instanz, nicht im Test. """ s = make_sighting(self.art) s.image = bild("ganz.png") s.save() self.art.reference_image.name = "referenz/kohlmeise.jpg" self.art.save(update_fields=["reference_image"]) resp = self.client.get( "/api/app/sichtungen/", HTTP_AUTHORIZATION=f"Bearer {LESEN}", # Genau der Header, den Traefik setzt. HTTP_X_FORWARDED_PROTO="https", secure=False, ) self.assertEqual(resp.status_code, 200) zeile = json.loads(resp.content)["sichtungen"][0] self.assertTrue(zeile["foto"].startswith("https://"), zeile["foto"]) self.assertTrue( zeile["art"]["referenzbild"].startswith("https://"), zeile["art"]["referenzbild"], ) def test_abzeichenbilder_folgen_dem_proxy_auf_https(self): resp = self.client.get( "/api/app/abzeichen/", HTTP_AUTHORIZATION=f"Bearer {LESEN}", HTTP_X_FORWARDED_PROTO="https", ) pins = [z["bild"] for z in json.loads(resp.content)["abzeichen"] if z["bild"]] self.assertTrue(pins) self.assertTrue(all(p.startswith("https://") for p in pins), pins[:2]) class EinzelneSichtungTests(AppTestBasis): """Der Endpunkt, mit dem die App nach einem Upload nachfragt.""" def setUp(self): super().setUp() self.art = make_species() def test_unbekannte_kennung_ist_404(self): resp = self.client.get( "/api/app/sichtungen/9999/", HTTP_AUTHORIZATION=f"Bearer {LESEN}" ) self.assertEqual(resp.status_code, 404) def test_wartendes_foto_meldet_wartet(self): jetzt = timezone.now() s = Sighting.objects.create( started_at=jetzt, ended_at=jetzt, frames_total=1, wartet_auf_bestimmung=True, ) daten = self.json(f"/api/app/sichtungen/{s.id}/") self.assertTrue(daten["wartet"]) self.assertIsNone(daten["art"]) def test_foto_ohne_vogel_endet_die_warteschleife(self): """⚠️ ``wartet: false`` mit ``art: null`` ist die Antwort, kein Fehler. Ohne diesen Fall dreht die App für ein Foto vom eigenen Daumen endlos. """ jetzt = timezone.now() s = Sighting.objects.create( started_at=jetzt, ended_at=jetzt, frames_total=1, wartet_auf_bestimmung=False, ) daten = self.json(f"/api/app/sichtungen/{s.id}/") self.assertFalse(daten["wartet"]) self.assertIsNone(daten["art"]) self.assertNotIn("album", daten) def test_bestimmtes_foto_bringt_album_und_fortschritt(self): s = make_sighting(self.art) scoring.register(s) daten = self.json(f"/api/app/sichtungen/{s.id}/") self.assertFalse(daten["wartet"]) self.assertEqual(daten["art"]["slug"], "kohlmeise") self.assertTrue(daten["neue_art"]) self.assertEqual(daten["punkte"], 30) # 10 XP x 3 beim Erstfang self.assertTrue(daten["album"]["gefunden"]) self.assertEqual(daten["fortschritt"]["arten_gesammelt"], 1) class StartTests(AppTestBasis): def setUp(self): super().setUp() self.art = make_species() self.kamera = make_camera(token="kamera-geheim", name="Futterhaus") def test_leerer_stand_ist_kein_fehler(self): """Bens Album ist am ersten Tag leer — und die App muss starten.""" daten = self.json("/api/app/start/") self.assertIsNone(daten["letzte_sichtung"]) self.assertEqual(daten["neueste_arten"], []) self.assertEqual(daten["fortschritt"]["punkte"], 0) self.assertEqual(daten["fortschritt"]["stufe"], 1) def test_letzte_sichtung_ueberspringt_unbestimmtes(self): """Eine Mondspiegelung ist kein Besuch und gehört nicht auf die Startseite.""" jetzt = timezone.now() echt = make_sighting(self.art, when=jetzt - timedelta(hours=1)) make_sighting(None, when=jetzt) daten = self.json("/api/app/start/") self.assertEqual(daten["letzte_sichtung"]["id"], echt.id) def test_wartende_fotos_werden_gezaehlt(self): jetzt = timezone.now() Sighting.objects.create( started_at=jetzt, ended_at=jetzt, frames_total=1, wartet_auf_bestimmung=True, ) self.assertEqual(self.json("/api/app/start/")["wartende_fotos"], 1) def test_kameras_ohne_geheimnisse(self): daten = self.json("/api/app/start/") kamera = daten["kameras"][0] self.assertEqual(kamera["name"], "Futterhaus") # ⚠️ Kein Token, kein Live-Bild, keine Geräteadresse — die App ist # kein Wartungswerkzeug. for verboten in ("token", "token_hash", "bild_url", "geraet_url", "stream_url", "live_url"): self.assertNotIn(verboten, kamera) def test_neueste_arten_sind_die_letzten_erstfaenge(self): zweite = make_species(slug="amsel", german="Amsel", number=2) scoring.register(make_sighting(self.art)) scoring.register(make_sighting(zweite)) daten = self.json("/api/app/start/") self.assertEqual( [a["slug"] for a in daten["neueste_arten"]], ["amsel", "kohlmeise"] ) class AbzeichenTests(AppTestBasis): def test_verdient_und_offen(self): art = make_species() scoring.register(make_sighting(art)) daten = self.json("/api/app/abzeichen/") self.assertEqual(daten["gesamt"], Badge.objects.count()) self.assertGreaterEqual(daten["verdient"], 1) verdiente = [z for z in daten["abzeichen"] if z["verdient"]] self.assertTrue(all(z["am"] for z in verdiente)) # 🔑 Verdiente Abzeichen haben keinen Balken — der stuende dauerhaft # auf 100 % und sagt nichts. self.assertTrue(all("anteil" not in z for z in verdiente)) def test_abzeichen_liefern_den_pin_nicht_nur_das_emoji(self): """🔑 Die Website zeigt gezeichnete Emaille-Pins, die App muss dieselben zeigen. Der Anlass ist derselbe, aus dem die Pins überhaupt entstanden sind: Auf einem System ohne Emoji-Schrift steht statt des Abzeichens ein leeres Rechteck. Beim Ansehen der gerenderten App stand genau das da, fünfzehnmal untereinander. """ daten = self.json("/api/app/abzeichen/") mit_pin = [z for z in daten["abzeichen"] if z["bild"]] self.assertTrue(mit_pin, "kein einziges Abzeichen mit Pin") self.assertTrue(mit_pin[0]["bild"].startswith("http://")) self.assertIn("/img/pins/", mit_pin[0]["bild"]) # ⚠️ Das Emoji bleibt als Rückfall erhalten — ein später # hinzugefügtes Abzeichen hat noch keinen Pin. self.assertTrue(all(z["symbol"] for z in daten["abzeichen"])) def test_abzeichen_ohne_pin_liefert_bild_none_statt_kaputter_adresse(self): from .models import Badge Badge.objects.create( code="ganz-neu", name="Ganz neu", description="Kommt später.", emoji="🆕" ) daten = self.json("/api/app/abzeichen/") neu = next(z for z in daten["abzeichen"] if z["code"] == "ganz-neu") self.assertIsNone(neu["bild"]) self.assertEqual(neu["symbol"], "🆕") def test_offene_zaehlbare_abzeichen_haben_einen_anteil(self): art = make_species() scoring.register(make_sighting(art)) daten = self.json("/api/app/abzeichen/") mit_balken = [ z for z in daten["abzeichen"] if not z["verdient"] and "anteil" in z ] self.assertTrue(mit_balken, "kein offenes Abzeichen mit Fortschritt") for z in mit_balken: self.assertLessEqual(z["anteil"], 1.0) self.assertLessEqual(z["ist"], z["ziel"]) class VogelOhneArtTests(AppTestBasis): """🔴 „Vogel ja, Art nein" darf nicht als „kein Vogel" ankommen. Am 14.10.2026 erkannte der Detektor auf einem Handyfoto mit 93 % einen Vogel, konnte die Art aber nicht bestimmen (Gegenlicht, Silhouette). In der App stand „Kein Vogel erkannt. Auf dem Foto war nichts zu finden" — und daraufhin wurde zugeschnitten, gegen ein Problem, das nie der Zuschnitt war. Eine falsche Auskunft ist teurer als gar keine. """ def setUp(self): super().setUp() self.kamera = make_camera(token="kamera-geheim", mobil=True) def _wartendes_foto(self): jetzt = timezone.now() s = Sighting.objects.create( camera=self.kamera, started_at=jetzt, ended_at=jetzt, frames_total=1, wartet_auf_bestimmung=True, ) return s def melde(self, s, nutzlast): return self.client.post( f"/api/offen/{s.id}/", {"data": json.dumps(nutzlast)}, HTTP_AUTHORIZATION="Bearer kamera-geheim", ) def test_vogel_ohne_art_behaelt_seinen_vogelwert(self): s = self._wartendes_foto() resp = self.melde(s, {"species": None, "confidence": 0.0, "mean_bird_score": 0.93}) self.assertEqual(resp.status_code, 200, resp.content) daten = self.json(f"/api/app/sichtungen/{s.id}/") self.assertFalse(daten["wartet"]) self.assertIsNone(daten["art"]) # Das ist der Unterschied, an dem die App ihren Text waehlt. self.assertAlmostEqual(daten["vogelwert"], 0.93, places=3) def test_wirklich_kein_vogel_bleibt_bei_null(self): s = self._wartendes_foto() self.melde(s, {"species": None, "confidence": 0.0, "mean_bird_score": 0.0}) daten = self.json(f"/api/app/sichtungen/{s.id}/") self.assertIsNone(daten["art"]) self.assertEqual(daten["vogelwert"], 0.0) def test_vogel_ohne_art_zaehlt_als_bild_mit_vogel(self): """``frames_with_bird`` hing an der ART statt am Vogel.""" s = self._wartendes_foto() self.melde(s, {"species": None, "mean_bird_score": 0.93}) s.refresh_from_db() self.assertEqual(s.frames_with_bird, 1) def test_ohne_vogel_kein_bild_mit_vogel(self): s = self._wartendes_foto() self.melde(s, {"species": None, "mean_bird_score": 0.0}) s.refresh_from_db() self.assertEqual(s.frames_with_bird, 0)