diff --git a/services/api/api/resources/grids/fbfm40/examples.py b/services/api/api/resources/grids/fbfm40/examples.py index 3418259e..dd612b81 100644 --- a/services/api/api/resources/grids/fbfm40/examples.py +++ b/services/api/api/resources/grids/fbfm40/examples.py @@ -8,6 +8,8 @@ domain_id comes from the URL path parameter, not the request body. """ +from lib.landfire import LANDFIRE_VERSIONS + EXAMPLE_FBFM40_MINIMAL = {} EXAMPLE_FBFM40_WITH_METADATA = { @@ -39,8 +41,8 @@ EXAMPLE_FBFM40_SEASONAL = { "name": "FBFM40 spring seasonal fuels", - "version": "2025", - "season": "SP", + "version": LANDFIRE_VERSIONS["fbfm40"]["lfps_available"][0], + "season": "ES", } CREATE_LANDFIRE_FBFM40_OPENAPI_EXAMPLES = { @@ -112,7 +114,7 @@ }, } -ALL_FBFM40_EXAMPLE_VALUES = [ +STAGED_FBFM40_EXAMPLE_VALUES = [ ("minimal", EXAMPLE_FBFM40_MINIMAL), ("with_metadata", EXAMPLE_FBFM40_WITH_METADATA), ("remove_non_burnable", EXAMPLE_FBFM40_REMOVE_NON_BURNABLE), @@ -120,3 +122,7 @@ ("domain_aligned_2m", EXAMPLE_FBFM40_DOMAIN_2M), ("native_anchor", EXAMPLE_FBFM40_NATIVE), ] + +LFPS_FBFM40_EXAMPLE_VALUES = [ + ("seasonal", EXAMPLE_FBFM40_SEASONAL), +] diff --git a/services/api/tests/conftest.py b/services/api/tests/conftest.py index 2e9b44d5..470a92a1 100644 --- a/services/api/tests/conftest.py +++ b/services/api/tests/conftest.py @@ -28,8 +28,14 @@ DOMAINS_COLLECTION, KEYS_COLLECTION, ) +from lib.testing import SHARED_TEST_DOMAINS_DIR from tests import fixtures -from tests.fixtures import make_application_data, make_domain_data, make_key_data +from tests.fixtures import ( + load_domain_from_file, + make_application_data, + make_domain_data, + make_key_data, +) TEST_URL = os.getenv("TEST_API_URL", "http://127.0.0.1:8080") TEST_API_KEY = os.environ.get("TEST_API_KEY", "") @@ -146,6 +152,21 @@ def second_domain(firestore_client, test_owner_id): doc_ref.delete() +@pytest.fixture(scope="session") +def lfps_covered_domain(firestore_client, test_owner_id): + """A domain with known LANDFIRE Product Service SW coverage (Kingman, AZ).""" + domain_data = load_domain_from_file( + SHARED_TEST_DOMAINS_DIR / "threedep_ept_seam.json", + owner_id=test_owner_id, + ) + doc_ref = firestore_client.collection(DOMAINS_COLLECTION).document( + domain_data["id"] + ) + doc_ref.set(domain_data) + yield domain_data + doc_ref.delete() + + @pytest.fixture(scope="session") def application_for_testing(firestore_client, test_owner_id): """An application owned by test-owner, available for any test that needs one.""" diff --git a/services/api/tests/fixtures.py b/services/api/tests/fixtures.py index 7d0235d7..0645c600 100644 --- a/services/api/tests/fixtures.py +++ b/services/api/tests/fixtures.py @@ -60,6 +60,27 @@ def make_domain_data( } +def load_domain_from_file(path, owner_id: str | None = None) -> dict: + """Factory function to load a domain fixture file as Firestore-ready data. + + Domain fixture files under lib.testing.SHARED_TEST_DOMAINS_DIR store + coordinates as plain lists and carry their own id/timestamps; this + stringifies coordinates for Firestore and assigns a fresh id/owner/ + timestamps the same way make_domain_data does. + """ + with open(path) as f: + domain_data = json.load(f) + domain_data["id"] = f"test-{uuid.uuid4().hex}" + domain_data["owner_id"] = owner_id or DEFAULT_OWNER_ID + domain_data["created_on"] = datetime.now() + domain_data["modified_on"] = datetime.now() + for feature in domain_data.get("features", []): + coords = feature.get("geometry", {}).get("coordinates") + if isinstance(coords, list): + feature["geometry"]["coordinates"] = json.dumps(coords) + return domain_data + + def make_grid_data( domain_id: str, owner_id: str | None = None, diff --git a/services/api/tests/resources/grids/fbfm40/test_router.py b/services/api/tests/resources/grids/fbfm40/test_router.py index 6ff9f8c3..14b20295 100644 --- a/services/api/tests/resources/grids/fbfm40/test_router.py +++ b/services/api/tests/resources/grids/fbfm40/test_router.py @@ -7,7 +7,8 @@ import pytest from api.resources.grids.fbfm40.examples import ( - ALL_FBFM40_EXAMPLE_VALUES, + LFPS_FBFM40_EXAMPLE_VALUES, + STAGED_FBFM40_EXAMPLE_VALUES, ) @@ -91,7 +92,7 @@ def test_response_excludes_owner_id(self, client, domain_for_testing): data = response.json() assert "owner_id" not in data - @pytest.mark.parametrize("example_name,example_value", ALL_FBFM40_EXAMPLE_VALUES) + @pytest.mark.parametrize("example_name,example_value", STAGED_FBFM40_EXAMPLE_VALUES) def test_documented_example_creates_grid( self, client, domain_for_testing, example_name, example_value ): @@ -180,3 +181,18 @@ def test_season_omitted_persists_none_and_annual_year( source = response.json()["source"] assert source["season"] is None assert source["year"] == 2024 # default version + + @pytest.mark.parametrize("example_name,example_value", LFPS_FBFM40_EXAMPLE_VALUES) + def test_lfps_documented_example_creates_grid( + self, client, lfps_covered_domain, example_name, example_value + ): + """Each LFPS-sourced example should successfully create a grid + against a domain known to have LFPS coverage. A failure here after + LANDFIRE rotates its live season means the example needs updating.""" + response = client.post( + self.route(lfps_covered_domain["id"]), json=example_value + ) + assert response.status_code == 201, ( + f"Example '{example_name}' failed with status {response.status_code}: " + f"{response.json()}" + ) diff --git a/services/griddle/tests/integration/test_landfire_lfps.py b/services/griddle/tests/integration/test_landfire_lfps.py index af7edd7c..3d887dd3 100644 --- a/services/griddle/tests/integration/test_landfire_lfps.py +++ b/services/griddle/tests/integration/test_landfire_lfps.py @@ -74,6 +74,23 @@ def test_seasonal_fbfm40(roi): assert fbfm_valid.max() <= 204 # matches test_landfire.py's FBFM40 range check +def test_configured_seasonal_version_still_live(): + """Fails once LANDFIRE moves seasonal fuels past our configured + version -- signals LANDFIRE_VERSIONS["fbfm40"]["lfps_available"] + needs updating, rather than silently going stale.""" + version = LANDFIRE_VERSIONS["fbfm40"]["lfps_available"][0] + live = any( + p.acronym.upper() == "FBFM40" + and p.season is not None + and p.version == f"LF{version}" + for p in list_products() + ) + assert live, ( + f"No live seasonal FBFM40 product for version {version} -- LANDFIRE may " + 'have moved on; update LANDFIRE_VERSIONS["fbfm40"]["lfps_available"].' + ) + + @pytest.mark.parametrize( ("product", "fetch_fn", "band"), [