diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadWfsDataService.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadWfsDataService.java index 79ee9aa9..1f32c405 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadWfsDataService.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadWfsDataService.java @@ -49,8 +49,8 @@ private String buildCqlFilter(String startDate, String endDate, Object multiPoly .filter(field -> "dateTime".equals(field.getType()) || "date".equals(field.getType())) .findFirst(); - // Add temporal filter - if (temporalField.isPresent() && startDate != null && endDate != null) { + // Add temporal filter only if both dates are specified + if (temporalField.isPresent() && startDate != null && !startDate.isEmpty() && endDate != null && !endDate.isEmpty()) { String fieldName = temporalField.get().getName(); cqlFilter.append(fieldName) .append(" DURING ") diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtils.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtils.java index 820ee25a..8837bd35 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtils.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtils.java @@ -7,6 +7,7 @@ public class DatetimeUtils { private static final Pattern MM_YYYY_PATTERN = Pattern.compile("^(\\d{2})-(\\d{4})$"); private static final Pattern YYYY_MM_DD_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$"); private static final DateTimeFormatter ISO_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + public static final String NON_SPECIFIED_DATE = "non-specified"; private DatetimeUtils() { } @@ -18,10 +19,15 @@ private DatetimeUtils() { * * @param dateInput Input date string (supports MM-YYYY or YYYY-MM-DD formats) * @param isStartDate true for start date (first day of month), false for end date (last day of month) - * @return Formatted date string in YYYY-MM-DD format + * @return Formatted date string in YYYY-MM-DD format, or null if date is not specified * @throws IllegalArgumentException if date format is invalid */ public static String validateAndFormatDate(String dateInput, boolean isStartDate) { + // Handle null, empty, or "non-specified" dates + if (dateInput == null || dateInput.trim().isEmpty() || NON_SPECIFIED_DATE.equalsIgnoreCase(dateInput.trim())) { + return null; + } + if (MM_YYYY_PATTERN.matcher(dateInput).matches()) { String[] parts = dateInput.split("-"); int month = Integer.parseInt(parts[0]); diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/processes/RestServices.java b/server/src/main/java/au/org/aodn/ogcapi/server/processes/RestServices.java index 30b50071..0321ec48 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/processes/RestServices.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/processes/RestServices.java @@ -3,6 +3,7 @@ import au.org.aodn.ogcapi.server.core.exception.wfs.WfsErrorHandler; import au.org.aodn.ogcapi.server.core.model.enumeration.DatasetDownloadEnums; import au.org.aodn.ogcapi.server.core.service.wfs.DownloadWfsDataService; +import au.org.aodn.ogcapi.server.core.util.DatetimeUtils; import au.org.aodn.ogcapi.server.core.util.EmailUtils; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -121,8 +122,8 @@ private String generateStartedEmailContent(String uuid, String startDate, String String template = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); // Handle dates - only show if not "non-specified" - String displayStartDate = (startDate != null && !startDate.equals("non-specified")) ? startDate.replace("-", "/") : ""; - String displayEndDate = (endDate != null && !endDate.equals("non-specified")) ? endDate.replace("-", "/") : ""; + String displayStartDate = (startDate != null && !startDate.equals(DatetimeUtils.NON_SPECIFIED_DATE)) ? startDate.replace("-", "/") : ""; + String displayEndDate = (endDate != null && !endDate.equals(DatetimeUtils.NON_SPECIFIED_DATE)) ? endDate.replace("-", "/") : ""; // Generate dynamic bbox HTML String bboxHtml = EmailUtils.generateBboxHtml(multipolygon, objectMapper); diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadWfsDataServiceTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadWfsDataServiceTest.java new file mode 100644 index 00000000..b444c1bd --- /dev/null +++ b/server/src/test/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadWfsDataServiceTest.java @@ -0,0 +1,284 @@ +package au.org.aodn.ogcapi.server.core.service.wfs; + +import au.org.aodn.ogcapi.server.core.model.ogc.FeatureRequest; +import au.org.aodn.ogcapi.server.core.model.ogc.wfs.DownloadableFieldModel; +import au.org.aodn.ogcapi.server.core.model.ogc.wms.DescribeLayerResponse; +import au.org.aodn.ogcapi.server.core.service.wms.WmsServer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Unit tests for DownloadWfsDataService + */ +@ExtendWith(MockitoExtension.class) +public class DownloadWfsDataServiceTest { + + @Mock + private WmsServer wmsServer; + + @Mock + private WfsServer wfsServer; + + @Mock + private RestTemplate restTemplate; + + @Mock + private WfsDefaultParam wfsDefaultParam; + + private DownloadWfsDataService downloadWfsDataService; + + @BeforeEach + public void setUp() { + downloadWfsDataService = new DownloadWfsDataService( + wmsServer, wfsServer, restTemplate, wfsDefaultParam + ); + } + + /** + * Helper method to create a list of downloadable fields for testing + */ + private List createTestDownloadableFields() { + List fields = new ArrayList<>(); + + // Add geometry field + fields.add(DownloadableFieldModel.builder() + .name("geom") + .label("geom") + .type("geometrypropertytype") + .build()); + + // Add datetime field + fields.add(DownloadableFieldModel.builder() + .name("timestamp") + .label("timestamp") + .type("dateTime") + .build()); + + return fields; + } + + @Test + public void testPrepareWfsRequestUrl_WithNullDates() { + // Setup + String uuid = "test-uuid"; + String layerName = "test:layer"; + List fields = createTestDownloadableFields(); + + DescribeLayerResponse describeLayerResponse = mock(DescribeLayerResponse.class); + DescribeLayerResponse.LayerDescription layerDescription = mock(DescribeLayerResponse.LayerDescription.class); + DescribeLayerResponse.Query query = mock(DescribeLayerResponse.Query.class); + + when(describeLayerResponse.getLayerDescription()).thenReturn(layerDescription); + when(layerDescription.getWfs()).thenReturn("https://test.com/geoserver/wfs"); + when(layerDescription.getQuery()).thenReturn(query); + when(query.getTypeName()).thenReturn(layerName); + + when(wmsServer.describeLayer(eq(uuid), any(FeatureRequest.class))).thenReturn(describeLayerResponse); + when(wfsServer.getDownloadableFields(eq(uuid), any(FeatureRequest.class), anyString())).thenReturn(fields); + + Map defaultParams = new HashMap<>(); + defaultParams.put("service", "WFS"); + defaultParams.put("version", "2.0.0"); + defaultParams.put("request", "GetFeature"); + when(wfsDefaultParam.getDownload()).thenReturn(defaultParams); + + // Test with null dates (non-specified dates from frontend) + String result = downloadWfsDataService.prepareWfsRequestUrl( + uuid, null, null, null, null, layerName + ); + + // Verify URL doesn't contain temporal filter when dates are null + assertNotNull(result); + assertTrue(result.contains("typeName=" + layerName)); + assertFalse(result.contains("cql_filter"), "URL should not contain cql_filter when dates are null"); + } + + @Test + public void testPrepareWfsRequestUrl_WithEmptyDates() { + // Setup + String uuid = "test-uuid"; + String layerName = "test:layer"; + List fields = createTestDownloadableFields(); + + DescribeLayerResponse describeLayerResponse = mock(DescribeLayerResponse.class); + DescribeLayerResponse.LayerDescription layerDescription = mock(DescribeLayerResponse.LayerDescription.class); + DescribeLayerResponse.Query query = mock(DescribeLayerResponse.Query.class); + + when(describeLayerResponse.getLayerDescription()).thenReturn(layerDescription); + when(layerDescription.getWfs()).thenReturn("https://test.com/geoserver/wfs"); + when(layerDescription.getQuery()).thenReturn(query); + when(query.getTypeName()).thenReturn(layerName); + + when(wmsServer.describeLayer(eq(uuid), any(FeatureRequest.class))).thenReturn(describeLayerResponse); + when(wfsServer.getDownloadableFields(eq(uuid), any(FeatureRequest.class), anyString())).thenReturn(fields); + + Map defaultParams = new HashMap<>(); + defaultParams.put("service", "WFS"); + defaultParams.put("version", "2.0.0"); + defaultParams.put("request", "GetFeature"); + when(wfsDefaultParam.getDownload()).thenReturn(defaultParams); + + // Test with empty string dates + String result = downloadWfsDataService.prepareWfsRequestUrl( + uuid, "", "", null, null, layerName + ); + + // Verify URL doesn't contain temporal filter when dates are empty + assertNotNull(result); + assertTrue(result.contains("typeName=" + layerName)); + assertFalse(result.contains("cql_filter"), "URL should not contain cql_filter when dates are empty"); + } + + @Test + public void testPrepareWfsRequestUrl_WithValidDates() { + // Setup + String uuid = "test-uuid"; + String layerName = "test:layer"; + String startDate = "2023-01-01"; + String endDate = "2023-12-31"; + List fields = createTestDownloadableFields(); + + DescribeLayerResponse describeLayerResponse = mock(DescribeLayerResponse.class); + DescribeLayerResponse.LayerDescription layerDescription = mock(DescribeLayerResponse.LayerDescription.class); + DescribeLayerResponse.Query query = mock(DescribeLayerResponse.Query.class); + + when(describeLayerResponse.getLayerDescription()).thenReturn(layerDescription); + when(layerDescription.getWfs()).thenReturn("https://test.com/geoserver/wfs"); + when(layerDescription.getQuery()).thenReturn(query); + when(query.getTypeName()).thenReturn(layerName); + + when(wmsServer.describeLayer(eq(uuid), any(FeatureRequest.class))).thenReturn(describeLayerResponse); + when(wfsServer.getDownloadableFields(eq(uuid), any(FeatureRequest.class), anyString())).thenReturn(fields); + + Map defaultParams = new HashMap<>(); + defaultParams.put("service", "WFS"); + defaultParams.put("version", "2.0.0"); + defaultParams.put("request", "GetFeature"); + when(wfsDefaultParam.getDownload()).thenReturn(defaultParams); + + // Test with valid dates + String result = downloadWfsDataService.prepareWfsRequestUrl( + uuid, startDate, endDate, null, null, layerName + ); + + // Verify URL contains temporal filter when valid dates are provided + assertNotNull(result); + assertTrue(result.contains("typeName=" + layerName)); + assertTrue(result.contains("cql_filter"), "URL should contain cql_filter with valid dates"); + assertTrue(result.contains("DURING"), "CQL filter should contain DURING operator"); + assertTrue(result.contains("2023-01-01T00:00:00Z"), "CQL filter should contain start date"); + assertTrue(result.contains("2023-12-31T23:59:59Z"), "CQL filter should contain end date"); + } + + @Test + public void testPrepareWfsRequestUrl_WithOnlyStartDate() { + // Setup + String uuid = "test-uuid"; + String layerName = "test:layer"; + String startDate = "2023-01-01"; + List fields = createTestDownloadableFields(); + + DescribeLayerResponse describeLayerResponse = mock(DescribeLayerResponse.class); + DescribeLayerResponse.LayerDescription layerDescription = mock(DescribeLayerResponse.LayerDescription.class); + DescribeLayerResponse.Query query = mock(DescribeLayerResponse.Query.class); + + when(describeLayerResponse.getLayerDescription()).thenReturn(layerDescription); + when(layerDescription.getWfs()).thenReturn("https://test.com/geoserver/wfs"); + when(layerDescription.getQuery()).thenReturn(query); + when(query.getTypeName()).thenReturn(layerName); + + when(wmsServer.describeLayer(eq(uuid), any(FeatureRequest.class))).thenReturn(describeLayerResponse); + when(wfsServer.getDownloadableFields(eq(uuid), any(FeatureRequest.class), anyString())).thenReturn(fields); + + Map defaultParams = new HashMap<>(); + defaultParams.put("service", "WFS"); + defaultParams.put("version", "2.0.0"); + defaultParams.put("request", "GetFeature"); + when(wfsDefaultParam.getDownload()).thenReturn(defaultParams); + + // Test with only start date (end date is null) + String result = downloadWfsDataService.prepareWfsRequestUrl( + uuid, startDate, null, null, null, layerName + ); + + // Verify URL doesn't contain temporal filter when only one date is provided + assertNotNull(result); + assertTrue(result.contains("typeName=" + layerName)); + assertFalse(result.contains("cql_filter"), "URL should not contain cql_filter when only start date is provided"); + } + + @Test + public void testPrepareWfsRequestUrl_WithMMYYYYFormat() { + // Setup + String uuid = "test-uuid"; + String layerName = "test:layer"; + String startDate = "01-2023"; // MM-YYYY format + String endDate = "12-2023"; // MM-YYYY format + List fields = createTestDownloadableFields(); + + DescribeLayerResponse describeLayerResponse = mock(DescribeLayerResponse.class); + DescribeLayerResponse.LayerDescription layerDescription = mock(DescribeLayerResponse.LayerDescription.class); + DescribeLayerResponse.Query query = mock(DescribeLayerResponse.Query.class); + + when(describeLayerResponse.getLayerDescription()).thenReturn(layerDescription); + when(layerDescription.getWfs()).thenReturn("https://test.com/geoserver/wfs"); + when(layerDescription.getQuery()).thenReturn(query); + when(query.getTypeName()).thenReturn(layerName); + + when(wmsServer.describeLayer(eq(uuid), any(FeatureRequest.class))).thenReturn(describeLayerResponse); + when(wfsServer.getDownloadableFields(eq(uuid), any(FeatureRequest.class), anyString())).thenReturn(fields); + + Map defaultParams = new HashMap<>(); + defaultParams.put("service", "WFS"); + defaultParams.put("version", "2.0.0"); + defaultParams.put("request", "GetFeature"); + when(wfsDefaultParam.getDownload()).thenReturn(defaultParams); + + // Test with MM-YYYY format dates + String result = downloadWfsDataService.prepareWfsRequestUrl( + uuid, startDate, endDate, null, null, layerName + ); + + // Verify URL contains temporal filter with converted dates + assertNotNull(result); + assertTrue(result.contains("typeName=" + layerName)); + assertTrue(result.contains("cql_filter"), "URL should contain cql_filter"); + assertTrue(result.contains("DURING"), "CQL filter should contain DURING operator"); + // Start date should be first day of January 2023 + assertTrue(result.contains("2023-01-01T00:00:00Z"), "Start date should be converted to first day of month"); + // End date should be last day of December 2023 + assertTrue(result.contains("2023-12-31T23:59:59Z"), "End date should be converted to last day of month"); + } + + @Test + public void testPrepareWfsRequestUrl_NoWfsServerUrl() { + // Setup + String uuid = "test-uuid"; + String layerName = "test:layer"; + + when(wmsServer.describeLayer(eq(uuid), any(FeatureRequest.class))).thenReturn(null); + when(wfsServer.getFeatureServerUrl(eq(uuid), eq(layerName))).thenReturn(java.util.Optional.empty()); + + // Test with no WFS server URL available + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + downloadWfsDataService.prepareWfsRequestUrl( + uuid, null, null, null, null, layerName + ); + }); + + assertTrue(exception.getMessage().contains("No WFS server URL found")); + } +} diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadableFieldsServiceTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadableFieldsServiceTest.java index 1cb9b909..c7444b6a 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadableFieldsServiceTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/core/service/wfs/DownloadableFieldsServiceTest.java @@ -219,19 +219,6 @@ public void testGetDownloadableFieldsEmptyResponse() { ); } - // @Test -// public void testGetDownloadableFieldsUnauthorizedServer() { -// when(wfsServerConfig.validateAndGetApprovedServerUrl(UNAUTHORIZED_SERVER)) -// .thenThrow(new UnauthorizedServerException("Access to WFS server '" + UNAUTHORIZED_SERVER + "' is not authorized")); -// UnauthorizedServerException exception = assertThrows( -// UnauthorizedServerException.class, -// () -> downloadableFieldsService.getDownloadableFields(UNAUTHORIZED_SERVER, "test:layer") -// ); -// -// assertTrue(exception.getMessage().contains("not authorized")); -// assertTrue(exception.getMessage().contains(UNAUTHORIZED_SERVER)); -// } -// @Test public void testGetDownloadableFieldsWfsError() { FeatureRequest request = FeatureRequest.builder().layerName("invalid:layer").build(); diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtilsTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtilsTest.java new file mode 100644 index 00000000..c72c0abb --- /dev/null +++ b/server/src/test/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtilsTest.java @@ -0,0 +1,132 @@ +package au.org.aodn.ogcapi.server.core.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for DatetimeUtils + */ +public class DatetimeUtilsTest { + + @Test + public void testValidateAndFormatDate_ValidYYYY_MM_DD() { + // Test valid YYYY-MM-DD format + String result = DatetimeUtils.validateAndFormatDate("2023-01-15", true); + assertEquals("2023-01-15", result); + } + + @Test + public void testValidateAndFormatDate_ValidMM_YYYY_StartDate() { + // Test MM-YYYY format for start date (should return first day of month) + String result = DatetimeUtils.validateAndFormatDate("01-2023", true); + assertEquals("2023-01-01", result); + } + + @Test + public void testValidateAndFormatDate_ValidMM_YYYY_EndDate() { + // Test MM-YYYY format for end date (should return last day of month) + String result = DatetimeUtils.validateAndFormatDate("02-2023", false); + assertEquals("2023-02-28", result); + } + + @Test + public void testValidateAndFormatDate_LeapYear() { + // Test February in leap year + String result = DatetimeUtils.validateAndFormatDate("02-2024", false); + assertEquals("2024-02-29", result); + } + + @Test + public void testValidateAndFormatDate_Month31Days() { + // Test month with 31 days + String result = DatetimeUtils.validateAndFormatDate("01-2023", false); + assertEquals("2023-01-31", result); + } + + @Test + public void testValidateAndFormatDate_Month30Days() { + // Test month with 30 days + String result = DatetimeUtils.validateAndFormatDate("04-2023", false); + assertEquals("2023-04-30", result); + } + + @Test + public void testValidateAndFormatDate_NullDate() { + // Test null date returns null + String result = DatetimeUtils.validateAndFormatDate(null, true); + assertNull(result, "Null date should return null"); + } + + @Test + public void testValidateAndFormatDate_EmptyString() { + // Test empty string returns null + String result = DatetimeUtils.validateAndFormatDate("", true); + assertNull(result, "Empty string should return null"); + } + + @Test + public void testValidateAndFormatDate_WhitespaceString() { + // Test whitespace string returns null + String result = DatetimeUtils.validateAndFormatDate(" ", false); + assertNull(result, "Whitespace string should return null"); + } + + @Test + public void testValidateAndFormatDate_NonSpecified() { + // Test "non-specified" returns null (case-insensitive) + String result1 = DatetimeUtils.validateAndFormatDate(DatetimeUtils.NON_SPECIFIED_DATE, true); + assertNull(result1, "non-specified should return null"); + + String result2 = DatetimeUtils.validateAndFormatDate(DatetimeUtils.NON_SPECIFIED_DATE.toUpperCase(), false); + assertNull(result2, "NON-SPECIFIED should return null"); + + String result3 = DatetimeUtils.validateAndFormatDate("Non-Specified", true); + assertNull(result3, "Non-Specified (mixed case) should return null"); + } + + @Test + public void testValidateAndFormatDate_InvalidFormat() { + // Test invalid format throws exception + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + DatetimeUtils.validateAndFormatDate("2023/01/15", true); + }); + assertTrue(exception.getMessage().contains("Date must be in MM-YYYY or YYYY-MM-DD format")); + } + + @Test + public void testValidateAndFormatDate_InvalidMonth() { + // Test invalid month throws exception + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + DatetimeUtils.validateAndFormatDate("13-2023", true); + }); + assertTrue(exception.getMessage().contains("Invalid month in date")); + } + + @Test + public void testValidateAndFormatDate_InvalidFormat_Slashes() { + // Test date with slashes instead of dashes + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + DatetimeUtils.validateAndFormatDate("2023/02/15", true); + }); + assertTrue(exception.getMessage().contains("Date must be in MM-YYYY or YYYY-MM-DD format")); + } + + @Test + public void testValidateAndFormatDate_IncompleteDate() { + // Test incomplete date format + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + DatetimeUtils.validateAndFormatDate("2023-01", true); + }); + assertTrue(exception.getMessage().contains("Date must be in MM-YYYY or YYYY-MM-DD format")); + } + + @Test + public void testValidateAndFormatDate_RandomString() { + // Test random string throws exception + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + DatetimeUtils.validateAndFormatDate("random-text", true); + }); + assertTrue(exception.getMessage().contains("Date must be in MM-YYYY or YYYY-MM-DD format")); + } +}