diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/das/DasTilerService.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/das/DasTilerService.java index d357d79c..13fabb63 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/das/DasTilerService.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/das/DasTilerService.java @@ -19,6 +19,7 @@ import java.io.IOException; import java.net.SocketTimeoutException; import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -121,6 +122,24 @@ public DasTileResult getDataTile(String productId, String date, int lod, int x, return exchangeForImage(builder, params, "image/png"); } + /** + * Fetches the decoded value(s) at a single lat/lon point for a product on a date. Unlike the + * tile route this returns the already-decoded value(s) as JSON, not value-encoded pixels. + */ + public DasJsonResult getPoint(String productId, String date, double lat, double lon) { + UriComponentsBuilder builder = UriComponentsBuilder + .fromUriString(dasProperties.host() + DATA_TILES_BASE + "/{product}/{date}/point") + .queryParam("lat", "{lat}") + .queryParam("lon", "{lon}"); + Map params = new HashMap<>(); + params.put("product", productId); + params.put("date", date); + params.put("lat", lat); + params.put("lon", lon); + + return exchangeForJson(builder, params); + } + /** * Fetches the per-date data-tile manifest (decode ranges, bounds, LOD geometry) needed to * decode the data tiles. Unlike the plain JSON getters this expands the product id (which @@ -153,15 +172,14 @@ public List getProducts() { } } - public JsonNode getManifest() { - String url = dasProperties.host() + VISUAL_TILES_BASE + "/manifest"; - try { - return httpClient.getForObject(url, JsonNode.class); - } catch (HttpStatusCodeException e) { - throw mapUpstreamError(e); - } catch (ResourceAccessException e) { - throw mapNetworkError(e); - } + /** + * Fetches the product/date manifest, forwarding its {@code Cache-Control} rather than this + * service inventing a freshness of its own. + */ + public DasJsonResult getManifest() { + UriComponentsBuilder builder = UriComponentsBuilder + .fromUriString(dasProperties.host() + VISUAL_TILES_BASE + "/manifest"); + return exchangeForJson(builder, Map.of()); } public JsonNode getColormaps() { @@ -203,9 +221,19 @@ public DasTileResult getLegend(String name, String rescale, Integer width, Integ } public List productsForCollection(String collectionId) { + return productsForCollections(List.of(collectionId)); + } + + /** + * Filters products down to the given collection ids ({@code metadata_uuid}). A null or empty + * id collection is unfiltered — every product across every collection is returned. + */ + public List productsForCollections(Collection collectionIds) { + boolean unfiltered = collectionIds == null || collectionIds.isEmpty(); List result = new ArrayList<>(); for (JsonNode product : getProducts()) { - if (collectionId.equals(product.path("metadata_uuid").asText(null))) { + String uuid = product.path("metadata_uuid").asText(null); + if (unfiltered || (uuid != null && collectionIds.contains(uuid))) { result.add(product); } } diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/tile/RestExtApi.java b/server/src/main/java/au/org/aodn/ogcapi/server/tile/RestExtApi.java index 807abe53..fcfe9bca 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/tile/RestExtApi.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/tile/RestExtApi.java @@ -111,8 +111,8 @@ public ResponseEntity getCollectionProducts( example = "0c9eb39c-9cbe-4c6a-8a10-5867087e703a") @PathVariable String collectionId) { List products = dasTilerService.productsForCollection(collectionId); - JsonNode manifest = dasTilerService.getManifest(); - JsonNode manifestProducts = manifest != null ? manifest.path("products") : null; + DasTilerService.DasJsonResult manifest = dasTilerService.getManifest(); + JsonNode manifestProducts = manifest.body() != null ? manifest.body().path("products") : null; ArrayNode result = mapper.createArrayNode(); for (JsonNode product : products) { @@ -185,9 +185,11 @@ public ResponseEntity getCollectionProducts( ObjectNode body = mapper.createObjectNode(); body.set("products", result); - return ResponseEntity.ok() - .header(HttpHeaders.CACHE_CONTROL, "public, max-age=300, must-revalidate") - .body(body); + ResponseEntity.BodyBuilder response = ResponseEntity.ok(); + if (manifest.cacheControl() != null) { + response.header(HttpHeaders.CACHE_CONTROL, manifest.cacheControl()); + } + return response.body(body); } @Operation( @@ -309,6 +311,104 @@ public ResponseEntity getCollectionDataTile( return response.body(tile.body()); } + @Operation( + summary = "Retrieve the decoded value(s) at a point for a product", + description = "Returns the already-decoded value(s) at a single lat/lon point — unlike the data-tile " + + "route, this is **not** value-encoded pixels, it's the plain decoded value(s) as JSON. " + + "Useful for point queries (e.g. a click on the map) without fetching and decoding a whole " + + "tile.\n\n" + + "Valid `dataset`, `variable` and `datetime` values come from " + + "`GET /api/v1/ogc/ext/tiles/collections/{collectionId}/products`.", + tags = {"Data Tiles"}) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "The decoded value(s) at the point.", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "lat": -44.27813720703125, + "lon": 132.0092315673828, + "variables": { + "MCS_category": { + "value": 0.0, + "units": null + } + } + }"""))), + @ApiResponse(responseCode = "400", description = "`dataset` or `variable` missing, `variable` " + + "containing a space (an unencoded `+`), `datetime` not `YYYY-MM-DD`, or `lat`/`lon` " + + "missing or out of range.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "DAS reported an unknown product " + + "(`{dataset}:{variable}`), an unavailable date, or a point outside the product's coverage. " + + "Forwarded from DAS, which owns the product catalogue.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "422", description = "DAS could not process the request (e.g. a " + + "malformed date).", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "429", description = "Upstream rate limit reached.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "502", description = "The tile service is unavailable. The cause is " + + "deliberately not described and is logged server-side instead.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "503", description = "DAS is still warming up.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "504", description = "DAS did not respond in time.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class)))}) + @GetMapping("/collections/{collectionId}/data_tiles/point") + public ResponseEntity getCollectionDataPoint( + @Parameter(in = ParameterIn.PATH, required = true, + description = "The metadata record UUID.", + example = "0c9eb39c-9cbe-4c6a-8a10-5867087e703a") + @PathVariable String collectionId, + + @Parameter(in = ParameterIn.QUERY, required = true, + description = "Dataset name — the `{dataset}` half of a DAS product id. Must be one of the " + + "collection's data assets.", + example = "model_sea_level_anomaly_gridded_realtime") + @RequestParam(required = false) String dataset, + + @Parameter(in = ParameterIn.QUERY, required = true, + description = "Variable name — the `{variable}` half of a DAS product id. A two-variable " + + "value such as `ucur+vcur` must have its `+` percent-encoded as `%2B`.", + example = "gsla") + @RequestParam(required = false) String variable, + + @Parameter(in = ParameterIn.QUERY, required = true, + description = "Date to decode, strict `YYYY-MM-DD`. Must be one of the product's " + + "`available_dates`.", + example = "2024-01-01") + @RequestParam(required = false) String datetime, + + @Parameter(in = ParameterIn.QUERY, required = true, + description = "Latitude of the point, in degrees.", + schema = @Schema(type = "number", minimum = "-90", maximum = "90"), example = "-44.27") + @RequestParam(required = false) Double lat, + + @Parameter(in = ParameterIn.QUERY, required = true, + description = "Longitude of the point, in degrees.", + schema = @Schema(type = "number", minimum = "-180", maximum = "180"), example = "132.00") + @RequestParam(required = false) Double lon) { + + validateProductParams(dataset, variable, datetime); + validateLatLon(lat, lon); + + String product = dataset + ":" + variable; + DasTilerService.DasJsonResult point = dasTilerService.getPoint(product, datetime, lat, lon); + + ResponseEntity.BodyBuilder response = ResponseEntity.ok(); + if (point.cacheControl() != null) { + response.header(HttpHeaders.CACHE_CONTROL, point.cacheControl()); + } + return response.body(point.body()); + } + @Operation( summary = "Retrieve the data-tile decode manifest for a product", description = "Returns the per-date manifest a client must fetch **before** requesting data " + @@ -406,6 +506,15 @@ private void validateProductParams(String dataset, String variable, String datet } } + private void validateLatLon(Double lat, Double lon) { + if (lat == null || lat < -90 || lat > 90) { + throw new InvalidParameterException("lat is required and must be between -90 and 90"); + } + if (lon == null || lon < -180 || lon > 180) { + throw new InvalidParameterException("lon is required and must be between -180 and 180"); + } + } + @Operation( summary = "List the available colormaps", description = "Names accepted by the tile route's `colormap` parameter and the legend endpoint. " + diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/core/service/das/DasTilerServiceTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/core/service/das/DasTilerServiceTest.java index f9ef856c..6eef32ad 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/core/service/das/DasTilerServiceTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/core/service/das/DasTilerServiceTest.java @@ -188,6 +188,62 @@ public void testGetDataTileNotFoundMirrored() { assertEquals("LOD 9 not in grid", ex.getMessage()); } + // --- Point: decoded value(s) at a lat/lon, JSON body with query params --- + + @Test + public void testGetPointSendsProductAndDateAsPathVariablesWithLatLonQuery() { + ObjectNode pointBody = new ObjectMapper().createObjectNode(); + pointBody.put("lat", -44.27813720703125); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set(HttpHeaders.CACHE_CONTROL, "public, max-age=31536000, immutable"); + when(httpClient.getForEntity(anyString(), eq(JsonNode.class), anyMap())) + .thenReturn(new ResponseEntity<>(pointBody, headers, HttpStatus.OK)); + + service.getPoint(PRODUCT_ID, "2024-01-01", -44.27, 132.00); + + CapturedRequest captured = captureJsonRequest(); + assertTrue(captured.url.contains("/data_tiles/{product}/{date}/point"), + "point must expand product/date as path variables, got: " + captured.url); + assertTrue(captured.url.contains("lat={lat}") && captured.url.contains("lon={lon}"), + "lat/lon must be query params, got: " + captured.url); + assertEquals(PRODUCT_ID, captured.params.get("product"), "product id with ':' must be a raw path variable"); + assertEquals("2024-01-01", captured.params.get("date")); + assertEquals(-44.27, captured.params.get("lat")); + assertEquals(132.00, captured.params.get("lon")); + } + + @Test + public void testGetPointForwardsBodyAndCacheControl() { + ObjectNode pointBody = new ObjectMapper().createObjectNode(); + pointBody.put("lat", -44.27813720703125); + pointBody.put("lon", 132.0092315673828); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set(HttpHeaders.CACHE_CONTROL, "public, max-age=31536000, immutable"); + when(httpClient.getForEntity(anyString(), eq(JsonNode.class), anyMap())) + .thenReturn(new ResponseEntity<>(pointBody, headers, HttpStatus.OK)); + + DasTilerService.DasJsonResult result = service.getPoint(PRODUCT_ID, "2024-01-01", -44.27, 132.00); + + assertEquals(pointBody, result.body()); + assertEquals("public, max-age=31536000, immutable", result.cacheControl()); + } + + @Test + public void testGetPointNotFoundMirrored() { + when(httpClient.getForEntity(anyString(), eq(JsonNode.class), anyMap())) + .thenThrow(HttpClientErrorException.create( + HttpStatus.NOT_FOUND, "Not Found", HttpHeaders.EMPTY, + "{\"detail\":\"point outside coverage\"}".getBytes(), null)); + + DasUpstreamException ex = assertThrows(DasUpstreamException.class, + () -> service.getPoint(PRODUCT_ID, "2024-01-01", -89.0, 0.0)); + + assertEquals(HttpStatus.NOT_FOUND, ex.getStatus()); + assertEquals("point outside coverage", ex.getMessage()); + } + // --- Data manifest: JSON body, but (unlike the plain getters) forwards Cache-Control --- @Test @@ -353,6 +409,39 @@ public void testOtherNetworkFailureMappedTo502() { assertEquals(HttpStatus.BAD_GATEWAY, ex.getStatus()); } + // --- Product/date manifest (visual_tiles): JSON body, forwards whatever Cache-Control DAS sent --- + + @Test + public void testGetManifestBuildsUrlAndForwardsCacheControl() { + ObjectNode manifestBody = new ObjectMapper().createObjectNode(); + manifestBody.putObject("products"); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set(HttpHeaders.CACHE_CONTROL, "public, max-age=60"); + when(httpClient.getForEntity(anyString(), eq(JsonNode.class), anyMap())) + .thenReturn(new ResponseEntity<>(manifestBody, headers, HttpStatus.OK)); + + DasTilerService.DasJsonResult result = service.getManifest(); + + CapturedRequest captured = captureJsonRequest(); + assertTrue(captured.url.contains("/visual_tiles/manifest"), "got: " + captured.url); + assertEquals(manifestBody, result.body()); + // Whatever freshness DAS decided must ride through as-is — this service must not invent + // its own Cache-Control for a response it doesn't own the freshness of. + assertEquals("public, max-age=60", result.cacheControl()); + } + + @Test + public void testGetManifestServerErrorMappedTo502() { + when(httpClient.getForEntity(anyString(), eq(JsonNode.class), anyMap())) + .thenThrow(HttpServerErrorException.create( + HttpStatus.INTERNAL_SERVER_ERROR, "Internal Server Error", HttpHeaders.EMPTY, new byte[0], null)); + + DasUpstreamException ex = assertThrows(DasUpstreamException.class, () -> service.getManifest()); + + assertEquals(HttpStatus.BAD_GATEWAY, ex.getStatus()); + } + @Test public void testProductsForCollectionFiltersByMetadataUuid() { ObjectMapper mapper = new ObjectMapper(); @@ -368,6 +457,36 @@ public void testProductsForCollectionFiltersByMetadataUuid() { assertTrue(service.productsForCollection("unknown-uuid").isEmpty()); } + @Test + public void testProductsForCollectionsFiltersByGivenIds() { + ObjectMapper mapper = new ObjectMapper(); + JsonNode products = mapper.createArrayNode() + .add(mapper.createObjectNode().put("id", "p1").put("metadata_uuid", "uuid-a")) + .add(mapper.createObjectNode().put("id", "p2").put("metadata_uuid", "uuid-b")) + .add(mapper.createObjectNode().put("id", "p3").put("metadata_uuid", "uuid-c")); + when(httpClient.getForObject(anyString(), eq(JsonNode.class))) + .thenReturn(products); + + List result = service.productsForCollections(List.of("uuid-a", "uuid-c")); + + assertEquals(2, result.size()); + assertEquals("p1", result.get(0).get("id").asText()); + assertEquals("p3", result.get(1).get("id").asText()); + } + + @Test + public void testProductsForCollectionsWithNullOrEmptyIdsReturnsEverything() { + ObjectMapper mapper = new ObjectMapper(); + JsonNode products = mapper.createArrayNode() + .add(mapper.createObjectNode().put("id", "p1").put("metadata_uuid", "uuid-a")) + .add(mapper.createObjectNode().put("id", "p2").put("metadata_uuid", "uuid-b")); + when(httpClient.getForObject(anyString(), eq(JsonNode.class))) + .thenReturn(products); + + assertEquals(2, service.productsForCollections(null).size()); + assertEquals(2, service.productsForCollections(List.of()).size()); + } + private record CapturedRequest(String url, Map params) { } } diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/tile/RestExtApiTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/tile/RestExtApiTest.java index de1b1aa1..558db85c 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/tile/RestExtApiTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/tile/RestExtApiTest.java @@ -24,6 +24,7 @@ import java.util.ArrayList; import java.util.List; +import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.never; @@ -73,7 +74,7 @@ private JsonNode scalarProductWithVisual(String id, String metadataUuid, String return node; } - private ObjectNode manifestWith(String productId) { + private DasTilerService.DasJsonResult manifestWith(String productId) { ObjectNode manifest = mapper.createObjectNode(); ObjectNode products = mapper.createObjectNode(); ObjectNode availability = mapper.createObjectNode(); @@ -84,7 +85,11 @@ private ObjectNode manifestWith(String productId) { availability.set("full_date_range", range); products.set(productId, availability); manifest.set("products", products); - return manifest; + return new DasTilerService.DasJsonResult(manifest, "public, max-age=31536000, immutable"); + } + + private DasTilerService.DasJsonResult emptyManifest() { + return new DasTilerService.DasJsonResult(mapper.createObjectNode(), "public, max-age=31536000, immutable"); } @Test @@ -149,7 +154,7 @@ public void verifyScalarProductAdvertisesVisualAndDataTileTypes() { @Test public void verifyCollectionProductsEmptyWhenNoneMatch() { when(dasTilerService.productsForCollection("uuid-none")).thenReturn(List.of()); - when(dasTilerService.getManifest()).thenReturn(mapper.createObjectNode()); + when(dasTilerService.getManifest()).thenReturn(emptyManifest()); ResponseEntity response = testRestTemplate.getForEntity( getExternalBasePath() + "/tiles/collections/uuid-none/products", JsonNode.class @@ -164,7 +169,7 @@ public void verifyMultiVariableProductAdvertisesDataTileTypeOnly() { when(dasTilerService.productsForCollection("uuid-a")).thenReturn( List.of(multiVariableProduct("model_currents:ucur+vcur", "uuid-a", List.of("UCUR", "VCUR"))) ); - when(dasTilerService.getManifest()).thenReturn(mapper.createObjectNode()); + when(dasTilerService.getManifest()).thenReturn(emptyManifest()); ResponseEntity response = testRestTemplate.getForEntity( getExternalBasePath() + "/tiles/collections/uuid-a/products", JsonNode.class @@ -235,7 +240,7 @@ public void verifyPairIsDataOnlyEvenWhenDasReportsVisualExplicitly() { JsonNode product = multiVariableProduct("model_currents:ucur+vcur", "uuid-a", List.of("UCUR", "VCUR")); ((ObjectNode) product).put("visual", false); when(dasTilerService.productsForCollection("uuid-a")).thenReturn(List.of(product)); - when(dasTilerService.getManifest()).thenReturn(mapper.createObjectNode()); + when(dasTilerService.getManifest()).thenReturn(emptyManifest()); JsonNode entry = getProducts("uuid-a").get(0); @@ -274,7 +279,7 @@ public void verifyDataCapabilityStillFollowsArityNotTheVisualField() { when(dasTilerService.productsForCollection("uuid-a")).thenReturn( List.of(scalarProductWithVisual("model_sla:wdir", "uuid-a", "WDIR", false)) ); - when(dasTilerService.getManifest()).thenReturn(mapper.createObjectNode()); + when(dasTilerService.getManifest()).thenReturn(emptyManifest()); JsonNode entry = getProducts("uuid-a").get(0); @@ -422,6 +427,91 @@ public void verifyDataTileMirrorsUpstreamServiceUnavailable() { } } + // --- Point route: decoded value(s) at a lat/lon, JSON body with query params --- + + @Test + public void verifyDataPointReturnsJsonWithCacheControl() { + ObjectNode pointBody = mapper.createObjectNode(); + pointBody.put("lat", -44.27813720703125); + pointBody.put("lon", 132.0092315673828); + when(dasTilerService.getPoint("model_sla:gsla", "2024-01-01", -44.27, 132.00)) + .thenReturn(new DasTilerService.DasJsonResult(pointBody, "public, max-age=31536000, immutable")); + + ResponseEntity response = testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?dataset=model_sla&variable=gsla&datetime=2024-01-01&lat=-44.27&lon=132.00", JsonNode.class); + + Assertions.assertEquals(HttpStatus.OK, response.getStatusCode()); + Assertions.assertTrue(response.getBody().has("lat")); + Assertions.assertEquals("public, max-age=31536000, immutable", response.getHeaders().getCacheControl()); + } + + @Test + public void verifyDataPointRejectsMissingOrMalformedParams() { + // missing dataset + Assertions.assertEquals(HttpStatus.BAD_REQUEST, testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?variable=gsla&datetime=2024-01-01&lat=-44.27&lon=132.00", ErrorResponse.class).getStatusCode()); + // missing variable + Assertions.assertEquals(HttpStatus.BAD_REQUEST, testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?dataset=model_sla&datetime=2024-01-01&lat=-44.27&lon=132.00", ErrorResponse.class).getStatusCode()); + // datetime not YYYY-MM-DD + Assertions.assertEquals(HttpStatus.BAD_REQUEST, testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?dataset=model_sla&variable=gsla&datetime=2024-1-1&lat=-44.27&lon=132.00", ErrorResponse.class).getStatusCode()); + } + + @Test + public void verifyDataPointRejectsMissingOrOutOfRangeLatLon() { + // missing lat + Assertions.assertEquals(HttpStatus.BAD_REQUEST, testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?dataset=model_sla&variable=gsla&datetime=2024-01-01&lon=132.00", ErrorResponse.class).getStatusCode()); + // missing lon + Assertions.assertEquals(HttpStatus.BAD_REQUEST, testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?dataset=model_sla&variable=gsla&datetime=2024-01-01&lat=-44.27", ErrorResponse.class).getStatusCode()); + // lat out of range + Assertions.assertEquals(HttpStatus.BAD_REQUEST, testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?dataset=model_sla&variable=gsla&datetime=2024-01-01&lat=91&lon=132.00", ErrorResponse.class).getStatusCode()); + // lon out of range + Assertions.assertEquals(HttpStatus.BAD_REQUEST, testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?dataset=model_sla&variable=gsla&datetime=2024-01-01&lat=-44.27&lon=181", ErrorResponse.class).getStatusCode()); + verify(dasTilerService, never()).getPoint(anyString(), anyString(), anyDouble(), anyDouble()); + } + + @Test + public void verifyDataPointRejectsUnencodedPlusInVariable() { + // A raw '+' decodes to a space, so the product id would be 'model_sla:ucur vcur' — caught + // here rather than forwarded to DAS as an unresolvable id. + ResponseEntity response = testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?dataset=model_sla&variable=ucur+vcur&datetime=2024-01-01&lat=-44.27&lon=132.00", ErrorResponse.class); + + Assertions.assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + Assertions.assertTrue(response.getBody().getMessage().contains("%2B"), + "the message must name the fix, got: " + response.getBody().getMessage()); + verify(dasTilerService, never()).getPoint(anyString(), anyString(), anyDouble(), anyDouble()); + } + + @Test + public void verifyDataPointUnknownProductIsForwardedToDas() { + // DAS owns the product catalogue, so an unknown dataset is its answer to give. + when(dasTilerService.getPoint("wrong:gsla", "2024-01-01", -44.27, 132.00)) + .thenThrow(new DasUpstreamException(HttpStatus.NOT_FOUND, "Unknown product: wrong:gsla")); + + ResponseEntity response = testRestTemplate.getForEntity( + getExternalBasePath() + "/tiles/collections/uuid-a/data_tiles/point" + + "?dataset=wrong&variable=gsla&datetime=2024-01-01&lat=-44.27&lon=132.00", ErrorResponse.class); + + Assertions.assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + Assertions.assertEquals("Unknown product: wrong:gsla", response.getBody().getMessage()); + verify(dasTilerService).getPoint("wrong:gsla", "2024-01-01", -44.27, 132.00); + } + @Test public void verifyDataManifestReturnsJsonWithCacheControl() { ObjectNode manifestBody = mapper.createObjectNode();