diff --git a/.github/workflows/multi-species-vendored-client.yml b/.github/workflows/multi-species-vendored-client.yml deleted file mode 100644 index 9d46034..0000000 --- a/.github/workflows/multi-species-vendored-client.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: Multi-Species Vendored Client - -on: - workflow_dispatch: - schedule: - # Every 8 hours - - cron: "0 */8 * * *" - -permissions: - actions: read - -# Limits to executing one workflow in a concurrency group at any time -# Will queue 1 PENDING workflow run. New incoming runs cancel & replace the pending run -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - queue: single - -jobs: - run-cute-pets: - runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v6 - with: - ref: smoss/multi-species-vendored-client - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.14" - cache: 'pip' - - - name: Install dependencies - run: pip install --break-system-packages -r requirements.txt - - - name: Get Previous Run ID - continue-on-error: true - id: get_id - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Fetches the ID of this branch's last completed run for the current workflow - PREVIOUS_RUN_ID=$(gh run list \ - --branch "${{ github.ref_name }}" \ - --workflow "${{ github.workflow }}" \ - --status success \ - --limit 1 \ - --json databaseId \ - --jq '.[0].databaseId') - echo "previous_run_id=$PREVIOUS_RUN_ID" >> "$GITHUB_OUTPUT" - - - name: Download previous database artifact - uses: actions/download-artifact@v8 - with: - name: database.json - github-token: ${{ secrets.GITHUB_TOKEN }} - run-id: ${{ steps.get_id.outputs.previous_run_id }} - continue-on-error: true - - - name: Call RescueGroups API - env: - CUTEPETSBOSTON_RESCUEGROUPS_API_KEY: ${{ secrets.CUTEPETSBOSTON_RESCUEGROUPS_API_KEY }} - INSTAGRAM_BUSINESS_ACCOUNT_ID: ${{ secrets.INSTAGRAM_BUSINESS_ACCOUNT_ID }} - INSTAGRAM_PAGE_ACCESS_TOKEN: ${{ secrets.INSTAGRAM_PAGE_ACCESS_TOKEN }} - BLUESKY_HANDLE: ${{ secrets.BLUESKY_TEST_HANDLE }} - BLUESKY_PASSWORD: ${{ secrets.BLUESKY_TEST_PASSWORD }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - APP_ENV: dev - run: | - #In order to create posts on the test accounts remove the --debugposters debug flag - python ./main.py - - - name: Upload database artifact - uses: actions/upload-artifact@v7 - with: - path: database.json - retention-days: 1 - archive: false diff --git a/adoption_sources/rescue_groups.py b/adoption_sources/rescue_groups.py index 701dfd3..883a64f 100644 --- a/adoption_sources/rescue_groups.py +++ b/adoption_sources/rescue_groups.py @@ -9,6 +9,7 @@ import pprint import os import re +from collections.abc import Sequence from typing import Iterator import requests @@ -17,13 +18,20 @@ from abstractions import AdoptablePet, PetSource from adoption_sources.pet_links import reconstruct_adoption_url -from config import CITY_NAME, CITY_STATE, POSTAL_CODE +from config import CITY_NAME, CITY_STATE, PET_SPECIES, POSTAL_CODE, RESCUEGROUPS_LIMIT logger = logging.getLogger(__name__) # Some rescues publish entries like "More Dogs Soon!" to point users at their # website; those should never be posted. Add new names here as we encounter them. -PLACEHOLDER_NAMES: tuple[str, ...] = ("more dogs soon!",) +PLACEHOLDER_NAMES: tuple[str, ...] = ("more dogs soon!", "more cats soon!") + +# Values used by the rest of the application. +SPECIES_SINGULAR = {"dogs": "dog", "cats": "cat"} + +# RescueGroups filter criteria are case-sensitive and use title-cased values +# in the API's documented multi-species search example. +FILTER_SPECIES_SINGULAR = {"dogs": "Dog", "cats": "Cat"} # The RescueGroups API occasionally times out or returns a transient 5xx. A # single hiccup shouldn't fail the whole run, so retry a few times with @@ -47,6 +55,28 @@ def _session_with_retries() -> requests.Session: return session +def _build_species_filters(species: Sequence[str]) -> tuple[list[dict], str]: + """Build search filters and filterProcessing for an OR species search. + + Filters use the title-cased ``species.singular`` criteria from the + documented RescueGroups multi-species search example. #124 was silently + rejected by the live API (zero results), so any change here must be + re-verified against the real API (tests/test_rescue_groups_live.py). + """ + if not species: + raise ValueError("At least one species is required") + filters = [ + { + "fieldName": "species.singular", + "operation": "equals", + "criteria": FILTER_SPECIES_SINGULAR[plural], + } + for plural in species + ] + filter_processing = " OR ".join(str(index) for index in range(1, len(filters) + 1)) + return filters, filter_processing + + class SourceRescueGroups(PetSource): """ Fetches adoptable pets from RescueGroups.org API. @@ -61,20 +91,20 @@ def __init__( api_key: str | None = None, postal_code: str = POSTAL_CODE, radius_miles: int = 50, - species: str = "dogs", # "dogs" or "cats" - limit: int = 25, + species: Sequence[str] | None = None, + limit: int = RESCUEGROUPS_LIMIT, location_label: str = f"{CITY_NAME}, {CITY_STATE}", ): self._api_key = api_key or os.environ.get("CUTEPETSBOSTON_RESCUEGROUPS_API_KEY") self.postal_code = postal_code self.radius_miles = radius_miles - self.species = species + self.species = tuple(species if species is not None else PET_SPECIES) self.limit = limit self.location_label = location_label @property def source_name(self) -> str: - return f"RescueGroups ({self.species})" + return f"RescueGroups ({', '.join(self.species)})" def fetch_pets(self) -> Iterator[AdoptablePet]: """ @@ -92,10 +122,10 @@ def fetch_pets(self) -> Iterator[AdoptablePet]: "RescueGroups API key not configured. " "Set CUTEPETSBOSTON_RESCUEGROUPS_API_KEY environment variable." ) - + url = ( - f"{self.BASE_URL}/available/{self.species}/haspic" - f"?include=orgs,breeds,locations" + f"{self.BASE_URL}/available/haspic" + f"?include=orgs,breeds,locations,species" f"&sort=random" f"&limit={self.limit}" ) @@ -103,18 +133,24 @@ def fetch_pets(self) -> Iterator[AdoptablePet]: "Content-Type": "application/vnd.api+json", "Authorization": self._api_key, } + species_filters, filter_processing = _build_species_filters(self.species) + # filterRadius is the documented POST-body key for radius searches. + # See _build_species_filters for why body-shape changes need a live + # API check. payload = { "data": { + "filters": species_filters, + "filterProcessing": filter_processing, "filterRadius": { "miles": self.radius_miles, "postalcode": self.postal_code, - } + }, } } - logger.info( - f"Fetching {self.species} from RescueGroups within {self.radius_miles} miles of {self.postal_code}" + f"Fetching {', '.join(self.species)} from RescueGroups " + f"within {self.radius_miles} miles of {self.postal_code}" ) session = _session_with_retries() @@ -134,9 +170,14 @@ def fetch_pets(self) -> Iterator[AdoptablePet]: for item in body.get("included", []) if item.get("type") == "orgs" } + species_by_id = { + item["id"]: item.get("attributes", {}) + for item in body.get("included", []) + if item.get("type") == "species" + } for animal in data: - pet = self._parse_animal(animal, orgs_by_id) + pet = self._parse_animal(animal, orgs_by_id, species_by_id) if not pet: continue if self._is_placeholder_name(pet.name): @@ -144,7 +185,12 @@ def fetch_pets(self) -> Iterator[AdoptablePet]: continue yield pet - def _parse_animal(self, animal: dict, orgs_by_id: dict) -> AdoptablePet | None: + def _parse_animal( + self, + animal: dict, + orgs_by_id: dict, + species_by_id: dict, + ) -> AdoptablePet | None: """Parse a single animal record from the API response.""" try: attrs = animal.get("attributes", {}) @@ -153,8 +199,22 @@ def _parse_animal(self, animal: dict, orgs_by_id: dict) -> AdoptablePet | None: # Extract and clean the name name = self._clean_name(attrs.get("name", "Unknown")) - # Determine species from the endpoint we queried - species = "dog" if self.species == "dogs" else "cat" + # Determine species from the included species relationship + species_id = ( + animal.get("relationships", {}) + .get("species", {}) + .get("data", [{}])[0] + .get("id") + ) + if not species_id: + logger.warning(f"Skipping animal {animal_id} with no species relationship") + return None + plural = species_by_id.get(species_id, {}).get("plural") + normalized_plural = plural.lower() if isinstance(plural, str) else "" + if normalized_plural not in self.species: + logger.info(f"Skipping animal {animal_id} with unconfigured species: {plural!r}") + return None + species = SPECIES_SINGULAR[normalized_plural] # Get breed info breed = attrs.get("breedString", attrs.get("breedPrimary", "Mixed")) diff --git a/config.py b/config.py index 0178730..d7ee669 100644 --- a/config.py +++ b/config.py @@ -2,3 +2,9 @@ CITY_STATE = "MA" CITY_HASHTAGS = ["Boston"] POSTAL_CODE = "02108" + +# RescueGroups API plural species names for the species we post about. +PET_SPECIES = ("dogs", "cats") + +# Single-call limit; roughly matches two per-species calls at 25 each. +RESCUEGROUPS_LIMIT = 50 diff --git a/main.py b/main.py index 21a0ad8..21117cf 100644 --- a/main.py +++ b/main.py @@ -1,16 +1,22 @@ -import os -import random import argparse import json +import os +import random import sys import traceback import logging import pprint +from datetime import datetime, timedelta, timezone from pathlib import Path -from datetime import datetime, timezone, timedelta import requests +from adoption_sources import SourceManual, SourceRescueGroups +from social_posters.bluesky import PosterBluesky +from social_posters.debug import PosterDebug +from social_posters.instagram import PosterInstagram +from social_posters.mastodon import PosterMastodon + file_handler = logging.FileHandler("cutepets.log") file_handler.setLevel(logging.DEBUG) console_handler = logging.StreamHandler(sys.stdout) @@ -45,14 +51,8 @@ def main(): def create_posters(debug=False): - from social_posters.debug import PosterDebug - if debug: - return [PosterDebug()] - from social_posters.instagram import PosterInstagram - from social_posters.bluesky import PosterBluesky - from social_posters.mastodon import PosterMastodon posters = [] posters.append(PosterMastodon()) @@ -64,15 +64,17 @@ def create_posters(debug=False): def create_sources(debug=False): - from adoption_sources import SourceRescueGroups, SourceManual - if debug: - return [SourceManual()] + cat_fixture_path = Path(__file__).parent / "tests" / "fixtures" / "sample_cats.json" + with open(cat_fixture_path) as f: + cat_animals = json.load(f) + return [ + SourceManual(species="dog"), + SourceManual(species="cat", animals=cat_animals), + ] sources = [] - sources.append(SourceRescueGroups()) - return sources diff --git a/tests/fixtures/sample_cats.json b/tests/fixtures/sample_cats.json new file mode 100644 index 0000000..5b1be59 --- /dev/null +++ b/tests/fixtures/sample_cats.json @@ -0,0 +1,32 @@ +[ + { + "type": "animals", + "id": "99001001", + "attributes": { + "name": "Whiskers", + "breedString": "Domestic Shorthair", + "breedPrimary": "Domestic Shorthair", + "descriptionText": "Whiskers is a friendly tabby who loves sunny windowsills.", + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/example/pictures/whiskers.jpg?width=100", + "slug": "adopt-whiskers-domestic-shorthair-cat", + "sex": "Female", + "sizeGroup": "Medium" + }, + "relationships": {} + }, + { + "type": "animals", + "id": "99001002", + "attributes": { + "name": "Mittens", + "breedString": "Siamese / Mixed", + "breedPrimary": "Siamese", + "descriptionText": "Mittens is a vocal cuddle bug looking for a quiet home.", + "pictureThumbnailUrl": "https://cdn.rescuegroups.org/example/pictures/mittens.jpg?width=100", + "slug": "adopt-mittens-siamese-cat", + "sex": "Male", + "sizeGroup": "Small" + }, + "relationships": {} + } +] diff --git a/tests/test_main.py b/tests/test_main.py index af19a2f..5ddae7f 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,7 +1,10 @@ import unittest +import uuid from abstractions import AdoptablePet, Post, PostResult -from main import create_posters, run +from adoption_sources import SourceManual +from adoption_sources.rescue_groups import SourceRescueGroups +from main import create_posters, create_sources, run class FakeSource: @@ -41,6 +44,7 @@ def test_run_calls_source_and_posters(self): location="Boston, MA", image_url="https://example.com/poppy.jpg", adoption_url="https://example.com/adopt/poppy", + pet_id=f"test-poppy-{uuid.uuid4()}", ) source = FakeSource([pet]) poster_one = FakePoster() @@ -55,6 +59,52 @@ def test_run_calls_source_and_posters(self): self.assertTrue(poster_two.publish_called) self.assertEqual(len(results), 2) + def test_run_with_mixed_species_pool(self): + dog = AdoptablePet( + name="Rex", + species="dog", + breed="mutt", + location="Boston, MA", + image_url="https://example.com/rex.jpg", + adoption_url="https://example.com/adopt/rex", + pet_id=f"test-dog-{uuid.uuid4()}", + ) + cat = AdoptablePet( + name="Luna", + species="cat", + breed="tabby", + location="Boston, MA", + image_url="https://example.com/luna.jpg", + adoption_url="https://example.com/adopt/luna", + pet_id=f"test-cat-{uuid.uuid4()}", + ) + source = FakeSource([dog, cat]) + poster = FakePoster() + + results = run([source], [poster]) + + self.assertTrue(poster.format_called) + self.assertTrue(poster.publish_called) + self.assertEqual(len(results), 1) + + +class CreateSourcesTests(unittest.TestCase): + def test_prod_returns_single_multi_species_rescuegroups_source(self): + sources = create_sources(debug=False) + + self.assertEqual(len(sources), 1) + self.assertIsInstance(sources[0], SourceRescueGroups) + self.assertEqual(sources[0].species, ("dogs", "cats")) + + def test_debug_returns_manual_sources_for_dogs_and_cats(self): + sources = create_sources(debug=True) + + self.assertEqual(len(sources), 2) + self.assertIsInstance(sources[0], SourceManual) + self.assertIsInstance(sources[1], SourceManual) + self.assertEqual(sources[0].species, "dog") + self.assertEqual(sources[1].species, "cat") + class CreatePostersTests(unittest.TestCase): def test_debug_returns_debug_poster(self): diff --git a/tests/test_pet_links.py b/tests/test_pet_links.py index 0348f54..3a35649 100644 --- a/tests/test_pet_links.py +++ b/tests/test_pet_links.py @@ -71,18 +71,22 @@ class ParseAnimalIntegrationTests(unittest.TestCase): def setUp(self): self.source = SourceRescueGroups(api_key="dummy") + self.species_by_id = {"8": {"plural": "dogs"}} def _animal(self): return { "type": "animals", "id": "22506352", "attributes": {"name": "Ketchup", "breedString": "Lab Mix"}, - "relationships": {"orgs": {"data": [{"type": "orgs", "id": "org1"}]}}, + "relationships": { + "orgs": {"data": [{"type": "orgs", "id": "org1"}]}, + "species": {"data": [{"type": "species", "id": "8"}]}, + }, } def test_toolkit_org_gets_deep_link(self): orgs = {"org1": {"city": "Sterling", "state": "MA", "url": "https://sterlingshelter.org/"}} - pet = self.source._parse_animal(self._animal(), orgs) + pet = self.source._parse_animal(self._animal(), orgs, self.species_by_id) self.assertEqual( pet.adoption_url, "https://sterlingshelter.org/pet-finder/#action_0=pet&animalID_0=22506352&petIndex_0=-1", @@ -90,7 +94,7 @@ def test_toolkit_org_gets_deep_link(self): def test_non_toolkit_org_keeps_landing_url(self): orgs = {"org1": {"city": "Boston", "state": "MA", "url": "https://www.mspca.org/"}} - pet = self.source._parse_animal(self._animal(), orgs) + pet = self.source._parse_animal(self._animal(), orgs, self.species_by_id) self.assertEqual(pet.adoption_url, "https://www.mspca.org/") diff --git a/tests/test_rescue_groups.py b/tests/test_rescue_groups.py index 8f0995a..4642dbe 100644 --- a/tests/test_rescue_groups.py +++ b/tests/test_rescue_groups.py @@ -1,9 +1,15 @@ +import json import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch -from adoption_sources.rescue_groups import SourceRescueGroups +from adoption_sources.rescue_groups import ( + SourceRescueGroups, + _build_species_filters, +) -def _make_animal(adoption_url=None, **extra_attrs): +def _make_animal(adoption_url=None, species_id="8", **extra_attrs): attrs = { "name": "Buddy", "breedString": "Lab Mix", @@ -16,7 +22,10 @@ def _make_animal(adoption_url=None, **extra_attrs): "type": "animals", "id": "12345", "attributes": attrs, - "relationships": {"orgs": {"data": [{"type": "orgs", "id": "org1"}]}}, + "relationships": { + "orgs": {"data": [{"type": "orgs", "id": "org1"}]}, + "species": {"data": [{"type": "species", "id": species_id}]}, + }, } @@ -29,15 +38,44 @@ def _make_org(adoption_url=None, url=None): return attrs +def _make_species_by_id(plural="dogs", species_id="8"): + return {species_id: {"plural": plural}} + + +class BuildSpeciesFiltersTests(unittest.TestCase): + def test_two_species_uses_or_filter_processing(self): + filters, filter_processing = _build_species_filters(("dogs", "cats")) + + self.assertEqual( + filters, + [ + {"fieldName": "species.singular", "operation": "equals", "criteria": "Dog"}, + {"fieldName": "species.singular", "operation": "equals", "criteria": "Cat"}, + ], + ) + self.assertEqual(filter_processing, "1 OR 2") + + def test_single_species(self): + filters, filter_processing = _build_species_filters(("dogs",)) + + self.assertEqual(len(filters), 1) + self.assertEqual(filter_processing, "1") + + def test_no_species_raises(self): + with self.assertRaises(ValueError): + _build_species_filters(()) + + class AdoptionUrlTests(unittest.TestCase): def setUp(self): self.source = SourceRescueGroups(api_key="dummy") + self.species_by_id = _make_species_by_id() def test_uses_pet_adoption_url_when_present(self): animal = _make_animal(adoption_url="https://pet.example.com/buddy") orgs = {"org1": _make_org(adoption_url="https://org.example.com", url="https://org.example.com/fallback")} - pet = self.source._parse_animal(animal, orgs) + pet = self.source._parse_animal(animal, orgs, self.species_by_id) self.assertEqual(pet.adoption_url, "https://pet.example.com/buddy") @@ -45,7 +83,7 @@ def test_falls_back_to_org_adoption_url_when_pet_has_none(self): animal = _make_animal() orgs = {"org1": _make_org(adoption_url="https://org.example.com/adopt", url="https://org.example.com")} - pet = self.source._parse_animal(animal, orgs) + pet = self.source._parse_animal(animal, orgs, self.species_by_id) self.assertEqual(pet.adoption_url, "https://org.example.com/adopt") @@ -53,11 +91,49 @@ def test_falls_back_to_org_url_when_neither_pet_nor_org_has_adoption_url(self): animal = _make_animal() orgs = {"org1": _make_org(url="https://org.example.com")} - pet = self.source._parse_animal(animal, orgs) + pet = self.source._parse_animal(animal, orgs, self.species_by_id) self.assertEqual(pet.adoption_url, "https://org.example.com") +class SpeciesParsingTests(unittest.TestCase): + def setUp(self): + self.source = SourceRescueGroups(api_key="dummy") + self.orgs = {"org1": _make_org(url="https://org.example.com")} + + def test_dog_species_from_included(self): + animal = _make_animal(species_id="8") + species_by_id = _make_species_by_id(plural="Dogs", species_id="8") + + pet = self.source._parse_animal(animal, self.orgs, species_by_id) + + self.assertEqual(pet.species, "dog") + + def test_cat_species_from_included(self): + animal = _make_animal(species_id="3") + species_by_id = _make_species_by_id(plural="Cats", species_id="3") + + pet = self.source._parse_animal(animal, self.orgs, species_by_id) + + self.assertEqual(pet.species, "cat") + + def test_skips_unconfigured_species(self): + animal = _make_animal(species_id="99") + species_by_id = _make_species_by_id(plural="rabbits", species_id="99") + + pet = self.source._parse_animal(animal, self.orgs, species_by_id) + + self.assertIsNone(pet) + + def test_skips_animal_without_species_relationship(self): + animal = _make_animal() + del animal["relationships"]["species"] + + pet = self.source._parse_animal(animal, self.orgs, _make_species_by_id()) + + self.assertIsNone(pet) + + class PlaceholderNameTests(unittest.TestCase): def setUp(self): self.source = SourceRescueGroups(api_key="dummy") @@ -66,10 +142,78 @@ def test_more_dogs_soon_is_placeholder(self): self.assertTrue(self.source._is_placeholder_name("More Dogs Soon!")) self.assertTrue(self.source._is_placeholder_name("MORE DOGS SOON!")) + def test_more_cats_soon_is_placeholder(self): + self.assertTrue(self.source._is_placeholder_name("More Cats Soon!")) + self.assertTrue(self.source._is_placeholder_name("MORE CATS SOON!")) + def test_real_pet_name_is_not_placeholder(self): self.assertFalse(self.source._is_placeholder_name("Pippin")) self.assertFalse(self.source._is_placeholder_name("Buddy")) +class FetchPetsRequestTests(unittest.TestCase): + @patch("adoption_sources.rescue_groups._session_with_retries") + def test_posts_single_multi_species_request(self, mock_session_factory): + mock_session = MagicMock() + mock_session_factory.return_value = mock_session + mock_response = MagicMock() + mock_response.json.return_value = {"data": [], "included": []} + mock_session.post.return_value = mock_response + + source = SourceRescueGroups(api_key="dummy") + pets = list(source.fetch_pets()) + + self.assertEqual(pets, []) + mock_session.post.assert_called_once() + url = mock_session.post.call_args.args[0] + payload = mock_session.post.call_args.kwargs["json"] + + self.assertIn("/available/haspic", url) + self.assertNotIn("/dogs/", url) + self.assertIn("include=orgs,breeds,locations,species", url) + self.assertEqual( + payload["data"]["filters"], + [ + {"fieldName": "species.singular", "operation": "equals", "criteria": "Dog"}, + {"fieldName": "species.singular", "operation": "equals", "criteria": "Cat"}, + ], + ) + self.assertEqual(payload["data"]["filterProcessing"], "1 OR 2") + self.assertEqual( + payload["data"]["filterRadius"], + {"miles": 50, "postalcode": "02108"}, + ) + self.assertNotIn("geodistance", payload["data"]) + + def test_missing_api_key_raises(self): + source = SourceRescueGroups(api_key=None) + source._api_key = None # ignore any ambient env var + + with self.assertRaises(ValueError): + list(source.fetch_pets()) + + +class RealCaptureParsingTests(unittest.TestCase): + """Parse the real API capture end-to-end, as a guard against drift + between our parsing and what the live API actually returns.""" + + def test_real_capture_parses(self): + fixture = Path(__file__).parent / "fixtures" / "sample_data.json" + with open(fixture) as f: + raw_animals = json.load(f) + + source = SourceRescueGroups(api_key="dummy") + species_by_id = {"8": {"plural": "dogs"}} + + for raw in raw_animals: + pet = source._parse_animal(raw, {}, species_by_id) + self.assertIsNotNone(pet, f"failed to parse animal {raw['id']}") + self.assertEqual(pet.species, "dog") + self.assertTrue(pet.name) + self.assertTrue(pet.breed) + self.assertTrue(pet.image_url) + self.assertIn("width=800", pet.image_url) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_rescue_groups_live.py b/tests/test_rescue_groups_live.py new file mode 100644 index 0000000..e7e0f2a --- /dev/null +++ b/tests/test_rescue_groups_live.py @@ -0,0 +1,41 @@ +"""Live-API integration test for SourceRescueGroups. + +Skipped unless CUTEPETSBOSTON_RESCUEGROUPS_API_KEY is set. This is the guard +against request-shape regressions like #124, where the hand-rolled body was +silently rejected by the live API and the bot fetched zero pets: unit tests +can't catch a body the real API refuses, only a real call can. + +Run locally with: + CUTEPETSBOSTON_RESCUEGROUPS_API_KEY=... pytest tests/test_rescue_groups_live.py +""" + +import os + +import pytest + +from adoption_sources.rescue_groups import SourceRescueGroups + +requires_api_key = pytest.mark.skipif( + not os.environ.get("CUTEPETSBOSTON_RESCUEGROUPS_API_KEY"), + reason="CUTEPETSBOSTON_RESCUEGROUPS_API_KEY not set", +) + + +@requires_api_key +def test_live_multi_species_search_returns_usable_pets(): + source = SourceRescueGroups() + pets = list(source.fetch_pets()) + + # The whole point of the search: the live API accepted the request shape + # and returned records (a rejected body historically yielded zero). + assert pets, "live search returned no pets — request shape likely rejected" + + assert {pet.species for pet in pets} <= {"dog", "cat"} + for pet in pets: + assert pet.name + assert pet.pet_id + + # The bot can only post pets with an image and a link; if parsing lost + # these for every record the run would fail even with a 200 response. + postable = [pet for pet in pets if pet.image_url and pet.adoption_url] + assert postable, "no pet had both an image and an adoption URL"