diff --git a/.phpunit.cache/test-results b/.phpunit.cache/test-results index b4f05bdc..4660b154 100644 --- a/.phpunit.cache/test-results +++ b/.phpunit.cache/test-results @@ -1 +1 @@ -{"version":2,"defects":{"OCA\\Procest\\Tests\\Unit\\Service\\SettingsServiceTest::testUpdateSettingsOnlyUpdatesRecognizedKeys":8},"times":{"OCA\\Procest\\Tests\\Unit\\Service\\SettingsServiceTest::testIsOpenRegisterAvailableReturnsTrue":0.001,"OCA\\Procest\\Tests\\Unit\\Service\\SettingsServiceTest::testIsOpenRegisterAvailableReturnsFalse":0,"OCA\\Procest\\Tests\\Unit\\Service\\SettingsServiceTest::testGetSettingsReturnsAllConfigKeys":0.001,"OCA\\Procest\\Tests\\Unit\\Service\\SettingsServiceTest::testUpdateSettingsOnlyUpdatesRecognizedKeys":0.005,"OCA\\Procest\\Tests\\Unit\\Service\\SettingsServiceTest::testGetConfigValueDelegatesToAppConfig":0,"OCA\\Procest\\Tests\\Unit\\Service\\SettingsServiceTest::testSetConfigValueDelegatesToAppConfig":0,"OCA\\Procest\\Tests\\Unit\\Service\\SettingsServiceTest::testLoadConfigurationFailsWithoutOpenRegister":0,"OCA\\Procest\\Tests\\Unit\\Controller\\GisProxyControllerTest::testProxyReturnsBadRequestWhenUrlMissing":0,"OCA\\Procest\\Tests\\Unit\\Controller\\GisProxyControllerTest::testProxyReturnsSuccessWithData":0.001,"OCA\\Procest\\Tests\\Unit\\Controller\\GisProxyControllerTest::testProxyReturnsForbiddenWhenUrlBlocked":0,"OCA\\Procest\\Tests\\Unit\\Controller\\GisProxyControllerTest::testProxyReturnsTooManyRequestsWhenRateLimited":0,"OCA\\Procest\\Tests\\Unit\\Controller\\GisProxyControllerTest::testCapabilitiesReturnsBadRequestWhenUrlMissing":0,"OCA\\Procest\\Tests\\Unit\\Controller\\GisProxyControllerTest::testCapabilitiesReturnsSuccessWithLayers":0,"OCA\\Procest\\Tests\\Unit\\Controller\\GisProxyControllerTest::testCapabilitiesReturnsBadGatewayOnException":0,"OCA\\Procest\\Tests\\Unit\\Controller\\HealthControllerTest::testHealthySystemReturnsOk":0,"OCA\\Procest\\Tests\\Unit\\Controller\\HealthControllerTest::testOpenRegisterUnavailableReturnsError":0.006,"OCA\\Procest\\Tests\\Unit\\Controller\\HealthControllerTest::testDatabaseUnreachableReturnsError":0.001,"OCA\\Procest\\Tests\\Unit\\Controller\\HealthControllerTest::testResponseIncludesVersion":0,"OCA\\Procest\\Tests\\Unit\\Controller\\KpiControllerTest::testIndexReturns401WhenNotAuthenticated":0,"OCA\\Procest\\Tests\\Unit\\Controller\\KpiControllerTest::testIndexReturnsFreshDataOnCacheMiss":0,"OCA\\Procest\\Tests\\Unit\\Controller\\KpiControllerTest::testIndexReturnsCacheHitOnSecondRequest":0.003,"OCA\\Procest\\Tests\\Unit\\Controller\\KpiControllerTest::testIndexResponseContainsAllRequiredFields":0.001,"OCA\\Procest\\Tests\\Unit\\Controller\\KpiControllerTest::testComputeKpisCalledWithCorrectUserId":0,"OCA\\Procest\\Tests\\Unit\\Controller\\KpiControllerTest::testDataIsStoredInCacheAfterMiss":0,"OCA\\Procest\\Tests\\Unit\\Controller\\KpiControllerTest::testCacheStoreFailureDoesNotBreakResponse":0,"OCA\\Procest\\Tests\\Unit\\Controller\\MetricsControllerTest::testIndexReturnsTextPlainResponse":0.001,"OCA\\Procest\\Tests\\Unit\\Controller\\MetricsControllerTest::testMetricsContainsExpectedFamilies":0.001,"OCA\\Procest\\Tests\\Unit\\Controller\\MetricsControllerTest::testInfoGaugeIncludesNextcloudVersion":0.001,"OCA\\Procest\\Tests\\Unit\\Controller\\MetricsControllerTest::testUpGaugeReflectsDatabaseHealth":0,"OCA\\Procest\\Tests\\Unit\\Controller\\MetricsControllerTest::testCasesCreatedTodayMetricFormat":0,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testCasesOverviewWidgetId":0.001,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testCasesOverviewWidgetTitle":0,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testCasesOverviewWidgetUrl":0.001,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testDeadlineAlertsWidgetId":0.001,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testDeadlineAlertsWidgetOrder":0,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testDeadlineAlertsWidgetIconClass":0.001,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testOverdueCasesWidgetId":0,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testStalledCasesWidgetId":0.001,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testTaskRemindersWidgetId":0,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testAllWidgetsLinkToDashboard":0,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testAllWidgetsHaveUniqueIds":0,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testAllWidgetsHaveNonEmptyTitles":0.001,"OCA\\Procest\\Tests\\Unit\\Listener\\KpiCacheInvalidationListenerTest::testHandleIgnoresUnrelatedEvents":0,"OCA\\Procest\\Tests\\Unit\\Listener\\KpiCacheInvalidationListenerTest::testHandleDoesNothingWhenNoUserWithUnrelatedEvent":0,"OCA\\Procest\\Tests\\Unit\\Listener\\KpiCacheInvalidationListenerTest::testIncrementFromNullYieldsTwo":0,"OCA\\Procest\\Tests\\Unit\\Listener\\KpiCacheInvalidationListenerTest::testIncrementFromThreeYieldsFour":0,"OCA\\Procest\\Tests\\Unit\\Listener\\KpiCacheInvalidationListenerTest::testIncrementFromStringVersionCastsCorrectly":0.001,"OCA\\Procest\\Tests\\Unit\\Listener\\KpiCacheInvalidationListenerTest::testListenerConstructedSuccessfully":0,"OCA\\Procest\\Tests\\Unit\\Listener\\KpiCacheInvalidationListenerTest::testHandleWithGenericEventNeverCallsCache":0,"OCA\\Procest\\Tests\\Unit\\Listener\\KpiCacheInvalidationListenerTest::testCacheKeyPatternIsConsistentWithController":0,"OCA\\Procest\\Tests\\Unit\\Listener\\KpiCacheInvalidationListenerTest::testConstructorCallsCreateLocal":0,"OCA\\Procest\\Tests\\Unit\\Middleware\\ZgwAuthMiddlewareTest::testConfidentialityEqualLevelAllowed":0,"OCA\\Procest\\Tests\\Unit\\Middleware\\ZgwAuthMiddlewareTest::testConfidentialityBelowMaxAllowed":0,"OCA\\Procest\\Tests\\Unit\\Middleware\\ZgwAuthMiddlewareTest::testConfidentialityAboveMaxDenied":0,"OCA\\Procest\\Tests\\Unit\\Middleware\\ZgwAuthMiddlewareTest::testConfidentialityUnknownLevelDenied":0,"OCA\\Procest\\Tests\\Unit\\Middleware\\ZgwAuthMiddlewareTest::testBeforeControllerSkipsNonZgwController":0.001,"OCA\\Procest\\Tests\\Unit\\Middleware\\ZgwAuthMiddlewareTest::testAfterExceptionReturnsNullForGenericException":0,"OCA\\Procest\\Tests\\Unit\\Middleware\\ZgwAuthMiddlewareTest::testAfterExceptionReturnsJsonForZgwAuthException":0,"OCA\\Procest\\Tests\\Unit\\Middleware\\ZgwAuthMiddlewareTest::testConfidentialityOrderingComplete":0,"OCA\\Procest\\Tests\\Unit\\Repair\\SeedBezwaarBeroepDataTest::testGetNameReturnsNonEmptyString":0.001,"OCA\\Procest\\Tests\\Unit\\Repair\\SeedBezwaarBeroepDataTest::testGetNameDescribesBezwaarBeroep":0,"OCA\\Procest\\Tests\\Unit\\Repair\\SeedBezwaarBeroepDataTest::testRunSkipsWhenOpenRegisterUnavailable":0,"OCA\\Procest\\Tests\\Unit\\Repair\\SeedBezwaarBeroepDataTest::testRunCallsSeedServiceWhenOpenRegisterAvailable":0,"OCA\\Procest\\Tests\\Unit\\Repair\\SeedBezwaarBeroepDataTest::testRunOutputsInfoOnSuccess":0,"OCA\\Procest\\Tests\\Unit\\Repair\\SeedBezwaarBeroepDataTest::testRunHandlesExceptionsGracefully":0,"OCA\\Procest\\Tests\\Unit\\Service\\GisProxyServiceTest::testProxyRequestThrowsForDisallowedUrl":0.002,"OCA\\Procest\\Tests\\Unit\\Service\\GisProxyServiceTest::testProxyRequestAllowsPdokUrl":0.244,"OCA\\Procest\\Tests\\Unit\\Service\\GisProxyServiceTest::testProxyRequestReturnsCachedResult":0.001,"OCA\\Procest\\Tests\\Unit\\Service\\GisProxyServiceTest::testProxyRequestThrowsWhenRateLimitExceeded":0,"OCA\\Procest\\Tests\\Unit\\Service\\GisProxyServiceTest::testKadasterUrlIsAllowed":0,"OCA\\Procest\\Tests\\Unit\\Service\\KpiAggregationServiceTest::testComputeKpisReturnsAllExpectedKeys":0.004,"OCA\\Procest\\Tests\\Unit\\Service\\KpiAggregationServiceTest::testComputeKpisReturnsZeroDefaultsOnDbError":0,"OCA\\Procest\\Tests\\Unit\\Service\\KpiAggregationServiceTest::testComputeKpisReturnsTypedIntegers":0.002,"OCA\\Procest\\Tests\\Unit\\Service\\KpiAggregationServiceTest::testStatusBreakdownIsArray":0.007,"OCA\\Procest\\Tests\\Unit\\Service\\KpiAggregationServiceTest::testAvgProcessingDaysReturnsNullWhenNoData":0.002,"OCA\\Procest\\Tests\\Unit\\Service\\KpiAggregationServiceTest::testAvgProcessingDaysReturnsCastFloat":0.003,"OCA\\Procest\\Tests\\Unit\\Service\\KpiAggregationServiceTest::testComputeKpisCallsDbForEachKpi":0.002,"OCA\\Procest\\Tests\\Unit\\Service\\ParaferingNotificationServiceTest::testNotifyStepActivatedSendsNotificationToActor":0,"OCA\\Procest\\Tests\\Unit\\Service\\ParaferingNotificationServiceTest::testNotifyStepActivatedSetsCorrectSubject":0.001,"OCA\\Procest\\Tests\\Unit\\Service\\ParaferingNotificationServiceTest::testNotifyStepActivatedSetsAppId":0,"OCA\\Procest\\Tests\\Unit\\Service\\ParaferingNotificationServiceTest::testNotifyVoorstelReturnedSendsNotificationToSteller":0,"OCA\\Procest\\Tests\\Unit\\Service\\ParaferingNotificationServiceTest::testNotifyVoorstelReturnedIncludesComment":0,"OCA\\Procest\\Tests\\Unit\\Service\\ParaferingNotificationServiceTest::testNotifyParaferingReminderSendsToActor":0,"OCA\\Procest\\Tests\\Unit\\Service\\ParaferingNotificationServiceTest::testNotifyParaferingReminderIncludesDaysWaiting":0,"OCA\\Procest\\Tests\\Unit\\Service\\ParaferingNotificationServiceTest::testNotificationExceptionIsCaughtAndLogged":0,"OCA\\Procest\\Tests\\Unit\\Service\\SeedDataServiceTest::testSeedBezwaarBeroepDataFailsWithoutObjectService":0,"OCA\\Procest\\Tests\\Unit\\Service\\SeedDataServiceTest::testSeedBezwaarBeroepDataFailsWithoutRegisterConfig":0,"OCA\\Procest\\Tests\\Unit\\Service\\SeedDataServiceTest::testSeedBezwaarBeroepDataReturnsSummaryStructure":0.001,"OCA\\Procest\\Tests\\Unit\\Service\\SeedDataServiceTest::testSeedBezwaarBeroepDataSkipsExistingCaseTypes":0.001,"OCA\\Procest\\Tests\\Unit\\Service\\SeedDataServiceTest::testBezwaarSeedDataFileExistsAndIsValidJson":0.004,"OCA\\Procest\\Tests\\Unit\\Service\\VthSettingsServiceTest::testGetSettingsIncludesVthSchemaKeys":0,"OCA\\Procest\\Tests\\Unit\\Service\\VthSettingsServiceTest::testUpdateSettingsPersistsVthKeys":0,"OCA\\Procest\\Tests\\Unit\\Service\\VthSettingsServiceTest::testLhsMatrixKeyIsReadableViaGetConfigValue":0,"OCA\\Procest\\Tests\\Unit\\Service\\VthSettingsServiceTest::testVthKeysDoNotOverrideCoreKeys":0.001,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testGetMappingReturnsNullWhenEmpty":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testGetMappingReturnsDecodedConfig":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testGetMappingReturnsNullForInvalidJson":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testSaveMappingPersistsJson":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testDeleteMappingRemovesKey":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testHasMappingReturnsTrueWhenExists":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testHasMappingReturnsFalseWhenMissing":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testGetResourceKeysReturnsKnownKeys":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testListMappingsReturnsAllKeys":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testResetToDefaultSavesDefault":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testResetToDefaultIgnoresUnknownKey":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwPaginationHelperTest::testSinglePageHasNoNextOrPrevious":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwPaginationHelperTest::testFirstPageHasNextButNoPrevious":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwPaginationHelperTest::testMiddlePageHasBothNextAndPrevious":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwPaginationHelperTest::testLastPageHasPreviousButNoNext":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwPaginationHelperTest::testFrameworkParamsFiltered":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwPaginationHelperTest::testEmptyResults":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwPaginationHelperTest::testZeroPageSizeNoDivisionByZero":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testDetectEindstatusReturnsFalseWithoutObjectService":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testDetectEindstatusExplicitTrue":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testDetectEindstatusExplicitFalse":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testDetectEindstatusVolgnummerFallbackHighestIsEindstatus":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testDetectEindstatusVolgnummerFallbackLowerIsNotEindstatus":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testFilterZakenForConsumerUnfilteredWithoutAuthorizations":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testFilterZakenForConsumerExcludesUnauthorizedZaaktype":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testFilterZakenForConsumerExcludesExceedingVertrouwelijkheid":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testCommunicatiekanaalCollectionUrlReturnsInvalidResource":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testCommunicatiekanaalInvalidUrlReturnsBadUrl":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testHoofdzaakNotFoundReturnsDoesNotExist":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testVertrouwelijkheidaanduidingAlwaysOverridesFromZaaktype":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testVertrouwelijkheidaanduidingFallsBackToIncomingWhenZaaktypeHasNone":0,"OCA\\Procest\\Tests\\Unit\\Settings\\VthSchemaTest::testAllVthSchemasAreRegistered":0.017,"OCA\\Procest\\Tests\\Unit\\Settings\\VthSchemaTest::testVthTemplatesDirectoryExists":0,"OCA\\Procest\\Tests\\Unit\\Settings\\VthSchemaTest::testVthTemplateFilesAreValidJson":0.003,"OCA\\Procest\\Tests\\Unit\\Settings\\VthSchemaTest::testExpectedVthTemplateFilesArePresent":0,"OCA\\Procest\\Tests\\Unit\\Settings\\VthSchemaTest::testVthSeedDataFileExistsAndIsValid":0.001,"OCA\\Procest\\Tests\\Unit\\Settings\\WorkflowEngineSchemaTest::testRegisterFileExistsAndIsValidJson":0.004,"OCA\\Procest\\Tests\\Unit\\Settings\\WorkflowEngineSchemaTest::testRegisterFileFollowsOpenApiStructure":0.028,"OCA\\Procest\\Tests\\Unit\\Settings\\WorkflowEngineSchemaTest::testWorkflowTemplateSchemaIsRegistered":0.006,"OCA\\Procest\\Tests\\Unit\\Settings\\WorkflowEngineSchemaTest::testWorkflowTemplateSchemaHasRequiredProperties":0,"OCA\\Procest\\Tests\\Unit\\Settings\\WorkflowEngineSchemaTest::testCoreSchemasPresentAfterWorkflowEngineMigration":0.02}} \ No newline at end of file +{"version":2,"defects":{"OCA\\Procest\\Tests\\Unit\\Service\\SettingsServiceTest::testUpdateSettingsOnlyUpdatesRecognizedKeys":8,"OCA\\Procest\\Tests\\Unit\\Service\\LocationServiceTest::testListForCaseReturnsLocationsFromObjectService":8,"OCA\\Procest\\Tests\\Unit\\Service\\WfsExportServiceTest::testBuildFeatureCollectionConvertsLocationsToFeatures":8,"OCA\\Procest\\Tests\\Unit\\Service\\WfsExportServiceTest::testBuildFeatureCollectionSkipsLocationsWithoutCoordinates":8,"OCA\\Procest\\Tests\\Unit\\Service\\WfsExportServiceTest::testBuildFeatureCollectionFiltersByBbox":8,"OCA\\Procest\\Tests\\Unit\\Service\\WfsExportServiceTest::testBuildFeatureCollectionCapsMaxFeaturesAtHardCap":8},"times":{"OCA\\Procest\\Tests\\Unit\\Service\\SettingsServiceTest::testIsOpenRegisterAvailableReturnsTrue":0.001,"OCA\\Procest\\Tests\\Unit\\Service\\SettingsServiceTest::testIsOpenRegisterAvailableReturnsFalse":0,"OCA\\Procest\\Tests\\Unit\\Service\\SettingsServiceTest::testGetSettingsReturnsAllConfigKeys":0.002,"OCA\\Procest\\Tests\\Unit\\Service\\SettingsServiceTest::testUpdateSettingsOnlyUpdatesRecognizedKeys":0.001,"OCA\\Procest\\Tests\\Unit\\Service\\SettingsServiceTest::testGetConfigValueDelegatesToAppConfig":0,"OCA\\Procest\\Tests\\Unit\\Service\\SettingsServiceTest::testSetConfigValueDelegatesToAppConfig":0,"OCA\\Procest\\Tests\\Unit\\Service\\SettingsServiceTest::testLoadConfigurationFailsWithoutOpenRegister":0.001,"OCA\\Procest\\Tests\\Unit\\Controller\\GisProxyControllerTest::testProxyReturnsBadRequestWhenUrlMissing":0,"OCA\\Procest\\Tests\\Unit\\Controller\\GisProxyControllerTest::testProxyReturnsSuccessWithData":0.001,"OCA\\Procest\\Tests\\Unit\\Controller\\GisProxyControllerTest::testProxyReturnsForbiddenWhenUrlBlocked":0,"OCA\\Procest\\Tests\\Unit\\Controller\\GisProxyControllerTest::testProxyReturnsTooManyRequestsWhenRateLimited":0,"OCA\\Procest\\Tests\\Unit\\Controller\\GisProxyControllerTest::testCapabilitiesReturnsBadRequestWhenUrlMissing":0,"OCA\\Procest\\Tests\\Unit\\Controller\\GisProxyControllerTest::testCapabilitiesReturnsSuccessWithLayers":0,"OCA\\Procest\\Tests\\Unit\\Controller\\GisProxyControllerTest::testCapabilitiesReturnsBadGatewayOnException":0,"OCA\\Procest\\Tests\\Unit\\Controller\\HealthControllerTest::testHealthySystemReturnsOk":0.001,"OCA\\Procest\\Tests\\Unit\\Controller\\HealthControllerTest::testOpenRegisterUnavailableReturnsError":0.005,"OCA\\Procest\\Tests\\Unit\\Controller\\HealthControllerTest::testDatabaseUnreachableReturnsError":0,"OCA\\Procest\\Tests\\Unit\\Controller\\HealthControllerTest::testResponseIncludesVersion":0,"OCA\\Procest\\Tests\\Unit\\Controller\\KpiControllerTest::testIndexReturns401WhenNotAuthenticated":0,"OCA\\Procest\\Tests\\Unit\\Controller\\KpiControllerTest::testIndexReturnsFreshDataOnCacheMiss":0,"OCA\\Procest\\Tests\\Unit\\Controller\\KpiControllerTest::testIndexReturnsCacheHitOnSecondRequest":0.002,"OCA\\Procest\\Tests\\Unit\\Controller\\KpiControllerTest::testIndexResponseContainsAllRequiredFields":0.001,"OCA\\Procest\\Tests\\Unit\\Controller\\KpiControllerTest::testComputeKpisCalledWithCorrectUserId":0,"OCA\\Procest\\Tests\\Unit\\Controller\\KpiControllerTest::testDataIsStoredInCacheAfterMiss":0,"OCA\\Procest\\Tests\\Unit\\Controller\\KpiControllerTest::testCacheStoreFailureDoesNotBreakResponse":0,"OCA\\Procest\\Tests\\Unit\\Controller\\MetricsControllerTest::testIndexReturnsTextPlainResponse":0.001,"OCA\\Procest\\Tests\\Unit\\Controller\\MetricsControllerTest::testMetricsContainsExpectedFamilies":0,"OCA\\Procest\\Tests\\Unit\\Controller\\MetricsControllerTest::testInfoGaugeIncludesNextcloudVersion":0,"OCA\\Procest\\Tests\\Unit\\Controller\\MetricsControllerTest::testUpGaugeReflectsDatabaseHealth":0,"OCA\\Procest\\Tests\\Unit\\Controller\\MetricsControllerTest::testCasesCreatedTodayMetricFormat":0,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testCasesOverviewWidgetId":0,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testCasesOverviewWidgetTitle":0,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testCasesOverviewWidgetUrl":0,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testDeadlineAlertsWidgetId":0,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testDeadlineAlertsWidgetOrder":0,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testDeadlineAlertsWidgetIconClass":0,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testOverdueCasesWidgetId":0,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testStalledCasesWidgetId":0,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testTaskRemindersWidgetId":0,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testAllWidgetsLinkToDashboard":0,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testAllWidgetsHaveUniqueIds":0,"OCA\\Procest\\Tests\\Unit\\Dashboard\\SignaleringWidgetsTest::testAllWidgetsHaveNonEmptyTitles":0,"OCA\\Procest\\Tests\\Unit\\Listener\\KpiCacheInvalidationListenerTest::testHandleIgnoresUnrelatedEvents":0,"OCA\\Procest\\Tests\\Unit\\Listener\\KpiCacheInvalidationListenerTest::testHandleDoesNothingWhenNoUserWithUnrelatedEvent":0,"OCA\\Procest\\Tests\\Unit\\Listener\\KpiCacheInvalidationListenerTest::testIncrementFromNullYieldsTwo":0,"OCA\\Procest\\Tests\\Unit\\Listener\\KpiCacheInvalidationListenerTest::testIncrementFromThreeYieldsFour":0,"OCA\\Procest\\Tests\\Unit\\Listener\\KpiCacheInvalidationListenerTest::testIncrementFromStringVersionCastsCorrectly":0,"OCA\\Procest\\Tests\\Unit\\Listener\\KpiCacheInvalidationListenerTest::testListenerConstructedSuccessfully":0,"OCA\\Procest\\Tests\\Unit\\Listener\\KpiCacheInvalidationListenerTest::testHandleWithGenericEventNeverCallsCache":0,"OCA\\Procest\\Tests\\Unit\\Listener\\KpiCacheInvalidationListenerTest::testCacheKeyPatternIsConsistentWithController":0,"OCA\\Procest\\Tests\\Unit\\Listener\\KpiCacheInvalidationListenerTest::testConstructorCallsCreateLocal":0,"OCA\\Procest\\Tests\\Unit\\Middleware\\ZgwAuthMiddlewareTest::testConfidentialityEqualLevelAllowed":0,"OCA\\Procest\\Tests\\Unit\\Middleware\\ZgwAuthMiddlewareTest::testConfidentialityBelowMaxAllowed":0,"OCA\\Procest\\Tests\\Unit\\Middleware\\ZgwAuthMiddlewareTest::testConfidentialityAboveMaxDenied":0,"OCA\\Procest\\Tests\\Unit\\Middleware\\ZgwAuthMiddlewareTest::testConfidentialityUnknownLevelDenied":0,"OCA\\Procest\\Tests\\Unit\\Middleware\\ZgwAuthMiddlewareTest::testBeforeControllerSkipsNonZgwController":0,"OCA\\Procest\\Tests\\Unit\\Middleware\\ZgwAuthMiddlewareTest::testAfterExceptionReturnsNullForGenericException":0,"OCA\\Procest\\Tests\\Unit\\Middleware\\ZgwAuthMiddlewareTest::testAfterExceptionReturnsJsonForZgwAuthException":0,"OCA\\Procest\\Tests\\Unit\\Middleware\\ZgwAuthMiddlewareTest::testConfidentialityOrderingComplete":0,"OCA\\Procest\\Tests\\Unit\\Repair\\SeedBezwaarBeroepDataTest::testGetNameReturnsNonEmptyString":0,"OCA\\Procest\\Tests\\Unit\\Repair\\SeedBezwaarBeroepDataTest::testGetNameDescribesBezwaarBeroep":0,"OCA\\Procest\\Tests\\Unit\\Repair\\SeedBezwaarBeroepDataTest::testRunSkipsWhenOpenRegisterUnavailable":0,"OCA\\Procest\\Tests\\Unit\\Repair\\SeedBezwaarBeroepDataTest::testRunCallsSeedServiceWhenOpenRegisterAvailable":0,"OCA\\Procest\\Tests\\Unit\\Repair\\SeedBezwaarBeroepDataTest::testRunOutputsInfoOnSuccess":0,"OCA\\Procest\\Tests\\Unit\\Repair\\SeedBezwaarBeroepDataTest::testRunHandlesExceptionsGracefully":0,"OCA\\Procest\\Tests\\Unit\\Service\\GisProxyServiceTest::testProxyRequestThrowsForDisallowedUrl":0,"OCA\\Procest\\Tests\\Unit\\Service\\GisProxyServiceTest::testProxyRequestAllowsPdokUrl":0.088,"OCA\\Procest\\Tests\\Unit\\Service\\GisProxyServiceTest::testProxyRequestReturnsCachedResult":0,"OCA\\Procest\\Tests\\Unit\\Service\\GisProxyServiceTest::testProxyRequestThrowsWhenRateLimitExceeded":0,"OCA\\Procest\\Tests\\Unit\\Service\\GisProxyServiceTest::testKadasterUrlIsAllowed":0,"OCA\\Procest\\Tests\\Unit\\Service\\KpiAggregationServiceTest::testComputeKpisReturnsAllExpectedKeys":0.005,"OCA\\Procest\\Tests\\Unit\\Service\\KpiAggregationServiceTest::testComputeKpisReturnsZeroDefaultsOnDbError":0,"OCA\\Procest\\Tests\\Unit\\Service\\KpiAggregationServiceTest::testComputeKpisReturnsTypedIntegers":0.002,"OCA\\Procest\\Tests\\Unit\\Service\\KpiAggregationServiceTest::testStatusBreakdownIsArray":0.002,"OCA\\Procest\\Tests\\Unit\\Service\\KpiAggregationServiceTest::testAvgProcessingDaysReturnsNullWhenNoData":0.002,"OCA\\Procest\\Tests\\Unit\\Service\\KpiAggregationServiceTest::testAvgProcessingDaysReturnsCastFloat":0.002,"OCA\\Procest\\Tests\\Unit\\Service\\KpiAggregationServiceTest::testComputeKpisCallsDbForEachKpi":0.002,"OCA\\Procest\\Tests\\Unit\\Service\\ParaferingNotificationServiceTest::testNotifyStepActivatedSendsNotificationToActor":0,"OCA\\Procest\\Tests\\Unit\\Service\\ParaferingNotificationServiceTest::testNotifyStepActivatedSetsCorrectSubject":0,"OCA\\Procest\\Tests\\Unit\\Service\\ParaferingNotificationServiceTest::testNotifyStepActivatedSetsAppId":0,"OCA\\Procest\\Tests\\Unit\\Service\\ParaferingNotificationServiceTest::testNotifyVoorstelReturnedSendsNotificationToSteller":0.001,"OCA\\Procest\\Tests\\Unit\\Service\\ParaferingNotificationServiceTest::testNotifyVoorstelReturnedIncludesComment":0,"OCA\\Procest\\Tests\\Unit\\Service\\ParaferingNotificationServiceTest::testNotifyParaferingReminderSendsToActor":0,"OCA\\Procest\\Tests\\Unit\\Service\\ParaferingNotificationServiceTest::testNotifyParaferingReminderIncludesDaysWaiting":0,"OCA\\Procest\\Tests\\Unit\\Service\\ParaferingNotificationServiceTest::testNotificationExceptionIsCaughtAndLogged":0,"OCA\\Procest\\Tests\\Unit\\Service\\SeedDataServiceTest::testSeedBezwaarBeroepDataFailsWithoutObjectService":0,"OCA\\Procest\\Tests\\Unit\\Service\\SeedDataServiceTest::testSeedBezwaarBeroepDataFailsWithoutRegisterConfig":0,"OCA\\Procest\\Tests\\Unit\\Service\\SeedDataServiceTest::testSeedBezwaarBeroepDataReturnsSummaryStructure":0.001,"OCA\\Procest\\Tests\\Unit\\Service\\SeedDataServiceTest::testSeedBezwaarBeroepDataSkipsExistingCaseTypes":0,"OCA\\Procest\\Tests\\Unit\\Service\\SeedDataServiceTest::testBezwaarSeedDataFileExistsAndIsValidJson":0.002,"OCA\\Procest\\Tests\\Unit\\Service\\VthSettingsServiceTest::testGetSettingsIncludesVthSchemaKeys":0.001,"OCA\\Procest\\Tests\\Unit\\Service\\VthSettingsServiceTest::testUpdateSettingsPersistsVthKeys":0,"OCA\\Procest\\Tests\\Unit\\Service\\VthSettingsServiceTest::testLhsMatrixKeyIsReadableViaGetConfigValue":0,"OCA\\Procest\\Tests\\Unit\\Service\\VthSettingsServiceTest::testVthKeysDoNotOverrideCoreKeys":0.001,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testGetMappingReturnsNullWhenEmpty":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testGetMappingReturnsDecodedConfig":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testGetMappingReturnsNullForInvalidJson":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testSaveMappingPersistsJson":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testDeleteMappingRemovesKey":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testHasMappingReturnsTrueWhenExists":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testHasMappingReturnsFalseWhenMissing":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testGetResourceKeysReturnsKnownKeys":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testListMappingsReturnsAllKeys":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testResetToDefaultSavesDefault":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwMappingServiceTest::testResetToDefaultIgnoresUnknownKey":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwPaginationHelperTest::testSinglePageHasNoNextOrPrevious":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwPaginationHelperTest::testFirstPageHasNextButNoPrevious":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwPaginationHelperTest::testMiddlePageHasBothNextAndPrevious":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwPaginationHelperTest::testLastPageHasPreviousButNoNext":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwPaginationHelperTest::testFrameworkParamsFiltered":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwPaginationHelperTest::testEmptyResults":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwPaginationHelperTest::testZeroPageSizeNoDivisionByZero":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testDetectEindstatusReturnsFalseWithoutObjectService":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testDetectEindstatusExplicitTrue":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testDetectEindstatusExplicitFalse":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testDetectEindstatusVolgnummerFallbackHighestIsEindstatus":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testDetectEindstatusVolgnummerFallbackLowerIsNotEindstatus":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testFilterZakenForConsumerUnfilteredWithoutAuthorizations":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testFilterZakenForConsumerExcludesUnauthorizedZaaktype":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testFilterZakenForConsumerExcludesExceedingVertrouwelijkheid":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testCommunicatiekanaalCollectionUrlReturnsInvalidResource":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testCommunicatiekanaalInvalidUrlReturnsBadUrl":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testHoofdzaakNotFoundReturnsDoesNotExist":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testVertrouwelijkheidaanduidingAlwaysOverridesFromZaaktype":0,"OCA\\Procest\\Tests\\Unit\\Service\\ZgwZrcRulesServiceTest::testVertrouwelijkheidaanduidingFallsBackToIncomingWhenZaaktypeHasNone":0,"OCA\\Procest\\Tests\\Unit\\Settings\\VthSchemaTest::testAllVthSchemasAreRegistered":0.037,"OCA\\Procest\\Tests\\Unit\\Settings\\VthSchemaTest::testVthTemplatesDirectoryExists":0,"OCA\\Procest\\Tests\\Unit\\Settings\\VthSchemaTest::testVthTemplateFilesAreValidJson":0.004,"OCA\\Procest\\Tests\\Unit\\Settings\\VthSchemaTest::testExpectedVthTemplateFilesArePresent":0,"OCA\\Procest\\Tests\\Unit\\Settings\\VthSchemaTest::testVthSeedDataFileExistsAndIsValid":0.002,"OCA\\Procest\\Tests\\Unit\\Settings\\WorkflowEngineSchemaTest::testRegisterFileExistsAndIsValidJson":0.008,"OCA\\Procest\\Tests\\Unit\\Settings\\WorkflowEngineSchemaTest::testRegisterFileFollowsOpenApiStructure":0.029,"OCA\\Procest\\Tests\\Unit\\Settings\\WorkflowEngineSchemaTest::testWorkflowTemplateSchemaIsRegistered":0.009,"OCA\\Procest\\Tests\\Unit\\Settings\\WorkflowEngineSchemaTest::testWorkflowTemplateSchemaHasRequiredProperties":0.001,"OCA\\Procest\\Tests\\Unit\\Settings\\WorkflowEngineSchemaTest::testCoreSchemasPresentAfterWorkflowEngineMigration":0.037,"OCA\\Procest\\Tests\\Unit\\Controller\\WfsExportControllerTest::testGetFeaturesReturnsFeatureCollection":0.001,"OCA\\Procest\\Tests\\Unit\\Controller\\WfsExportControllerTest::testGetFeaturesThrowsWhenUserIsNull":0,"OCA\\Procest\\Tests\\Unit\\Controller\\WfsExportControllerTest::testGetFeaturesReturnsBadRequestForUnknownTypeName":0,"OCA\\Procest\\Tests\\Unit\\Controller\\WfsExportControllerTest::testGetFeaturesReturnsBadRequestForUnsupportedFormat":0,"OCA\\Procest\\Tests\\Unit\\Controller\\WfsExportControllerTest::testGetFeaturesParsesBboxParameter":0,"OCA\\Procest\\Tests\\Unit\\Controller\\WfsExportControllerTest::testGetCapabilitiesReturnsDescriptor":0,"OCA\\Procest\\Tests\\Unit\\Controller\\WfsExportControllerTest::testGetCapabilitiesThrowsWhenUserIsNull":0,"OCA\\Procest\\Tests\\Unit\\Controller\\WmsWfsControllerTest::testProxyReturnsBadRequestWhenLayerIdMissing":0,"OCA\\Procest\\Tests\\Unit\\Controller\\WmsWfsControllerTest::testProxyReturnsNotFoundWhenLayerDoesNotExist":0,"OCA\\Procest\\Tests\\Unit\\Controller\\WmsWfsControllerTest::testProxyReturnsSuccessWithProxiedData":0,"OCA\\Procest\\Tests\\Unit\\Controller\\WmsWfsControllerTest::testProxyReturnsBadGatewayOnServiceException":0,"OCA\\Procest\\Tests\\Unit\\Controller\\WmsWfsControllerTest::testProxyExcludesLayerIdFromForwardedParams":0,"OCA\\Procest\\Tests\\Unit\\Mcp\\ProcestToolProviderTest::testGetAppIdReturnsProcest":0,"OCA\\Procest\\Tests\\Unit\\Mcp\\ProcestToolProviderTest::testGetToolsReturnsTwoWellFormedDescriptors":0.001,"OCA\\Procest\\Tests\\Unit\\Mcp\\ProcestToolProviderTest::testInvokeUnknownToolReturnsErrorEnvelope":0,"OCA\\Procest\\Tests\\Unit\\Mcp\\ProcestToolProviderTest::testInvokeToolWithoutStorageReturnsErrorEnvelope":0,"OCA\\Procest\\Tests\\Unit\\Mcp\\ProcestToolProviderTest::testGetProcessDetailsWithoutIdReturnsInvalidArguments":0,"OCA\\Procest\\Tests\\Unit\\Service\\LocationServiceTest::testValidateReturnsMissingSourceError":0,"OCA\\Procest\\Tests\\Unit\\Service\\LocationServiceTest::testValidateReturnsInvalidSourceError":0,"OCA\\Procest\\Tests\\Unit\\Service\\LocationServiceTest::testValidateReturnsMissingCaseError":0,"OCA\\Procest\\Tests\\Unit\\Service\\LocationServiceTest::testValidateRequiresNummeraanduidingIdForBagSource":0,"OCA\\Procest\\Tests\\Unit\\Service\\LocationServiceTest::testValidatePassesForValidBagPayload":0,"OCA\\Procest\\Tests\\Unit\\Service\\LocationServiceTest::testValidateRequiresAccuracyRadiusForGpsSource":0,"OCA\\Procest\\Tests\\Unit\\Service\\LocationServiceTest::testValidatePassesForValidGpsPayload":0,"OCA\\Procest\\Tests\\Unit\\Service\\LocationServiceTest::testValidateRequiresAddressOrCoordsForFreeSource":0,"OCA\\Procest\\Tests\\Unit\\Service\\LocationServiceTest::testValidatePassesForFreeSourceWithCoordinatesOnly":0,"OCA\\Procest\\Tests\\Unit\\Service\\LocationServiceTest::testReverseGeocodeReturnsNullForInvalidLatitude":0,"OCA\\Procest\\Tests\\Unit\\Service\\LocationServiceTest::testReverseGeocodeReturnsNullWhenPdokUnavailable":0.001,"OCA\\Procest\\Tests\\Unit\\Service\\LocationServiceTest::testAttachToCaseThrowsForEmptyCaseId":0,"OCA\\Procest\\Tests\\Unit\\Service\\LocationServiceTest::testAttachToCaseThrowsWhenValidationFails":0.001,"OCA\\Procest\\Tests\\Unit\\Service\\LocationServiceTest::testAttachToCaseThrowsWhenObjectServiceUnavailable":0,"OCA\\Procest\\Tests\\Unit\\Service\\LocationServiceTest::testListForCaseReturnsEmptyArrayWhenObjectServiceUnavailable":0,"OCA\\Procest\\Tests\\Unit\\Service\\LocationServiceTest::testListForCaseReturnsLocationsFromObjectService":0.004,"OCA\\Procest\\Tests\\Unit\\Service\\WfsExportServiceTest::testBuildFeatureCollectionReturnsEmptyWhenObjectServiceUnavailable":0.001,"OCA\\Procest\\Tests\\Unit\\Service\\WfsExportServiceTest::testBuildFeatureCollectionConvertsLocationsToFeatures":0,"OCA\\Procest\\Tests\\Unit\\Service\\WfsExportServiceTest::testBuildFeatureCollectionSkipsLocationsWithoutCoordinates":0.004,"OCA\\Procest\\Tests\\Unit\\Service\\WfsExportServiceTest::testBuildFeatureCollectionFiltersByBbox":0,"OCA\\Procest\\Tests\\Unit\\Service\\WfsExportServiceTest::testBuildFeatureCollectionCapsMaxFeaturesAtHardCap":0.001,"OCA\\Procest\\Tests\\Unit\\Service\\WfsExportServiceTest::testBuildCapabilitiesReturnsValidDescriptor":0.002}} \ No newline at end of file diff --git a/appinfo/routes.php b/appinfo/routes.php index cf4d0c87..526ff926 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -158,6 +158,11 @@ // CRUD on wmsLayer objects is served by OpenRegister manifest pages. ['name' => 'wms_wfs#proxy', 'url' => '/api/wms-wfs/proxy', 'verb' => 'GET'], + // WFS export — exposes case locations as a GeoJSON WFS layer for external GIS applications. + // gis-integration spec AC 6. + ['name' => 'wfsExport#getFeatures', 'url' => '/api/gis/wfs', 'verb' => 'GET'], + ['name' => 'wfsExport#getCapabilities', 'url' => '/api/gis/wfs/capabilities', 'verb' => 'GET'], + // ── Parafeerroute (B&W parafering engine) ─────────────────────── // CRUD on parafeerroute objects is served by OpenRegister's auto-exposed // /api/objects// endpoints — only engine routes remain. diff --git a/lib/Controller/WfsExportController.php b/lib/Controller/WfsExportController.php new file mode 100644 index 00000000..e78e7938 --- /dev/null +++ b/lib/Controller/WfsExportController.php @@ -0,0 +1,168 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/gis-integration/tasks.md#task-19 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Procest\Controller; + +use OCA\Procest\Service\WfsExportService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * Controller exposing case locations as a WFS GeoJSON endpoint. + * + * @spec openspec/changes/gis-integration/tasks.md#task-19 + */ +class WfsExportController extends Controller +{ + /** + * Constructor. + * + * @param string $appName The application name + * @param IRequest $request The request object + * @param WfsExportService $wfsExportService The WFS export service + * @param IUserSession $userSession The user session + * + * @return void + */ + public function __construct( + string $appName, + IRequest $request, + private WfsExportService $wfsExportService, + private IUserSession $userSession, + ) { + parent::__construct(appName: $appName, request: $request); + }//end __construct() + + /** + * Return case locations as a GeoJSON FeatureCollection (WFS GetFeature). + * + * Query parameters: + * - typeName: Feature type to return (default: procest:cases) + * - outputFormat: Output format, only application/json supported (default: application/json) + * - maxFeatures: Maximum features to return (default: 500, hard cap: 2000) + * - bbox: Bounding box filter as "minLon,minLat,maxLon,maxLat" in WGS84 (optional) + * - status: Filter by case status (optional) + * - caseType: Filter by case type name (optional) + * + * @NoAdminRequired + * + * @return JSONResponse GeoJSON FeatureCollection + * + * @throws OCSForbiddenException When user session is not authenticated + * + * @spec openspec/changes/gis-integration/tasks.md#task-19 + */ + public function getFeatures(): JSONResponse + { + if ($this->userSession->getUser() === null) { + throw new OCSForbiddenException('Authentication required'); + } + + $typeName = (string) $this->request->getParam('typeName', WfsExportService::TYPE_NAME_CASES); + $outputFormat = (string) $this->request->getParam('outputFormat', 'application/json'); + $maxFeatures = (int) $this->request->getParam('maxFeatures', WfsExportService::DEFAULT_MAX_FEATURES); + $bboxParam = $this->request->getParam('bbox', null); + $statusRaw = $this->request->getParam('status', null); + $caseTypeRaw = $this->request->getParam('caseType', null); + + $status = null; + if ($statusRaw !== null) { + $status = (string) $statusRaw; + } + + $caseType = null; + if ($caseTypeRaw !== null) { + $caseType = (string) $caseTypeRaw; + } + + if ($typeName !== WfsExportService::TYPE_NAME_CASES) { + return new JSONResponse( + ['error' => 'Unsupported typeName: '.$typeName.'. Supported: '.WfsExportService::TYPE_NAME_CASES], + 400 + ); + } + + if ($outputFormat !== 'application/json') { + return new JSONResponse( + ['error' => 'Unsupported outputFormat: '.$outputFormat.'. Supported: application/json'], + 400 + ); + } + + if ($maxFeatures <= 0) { + $maxFeatures = WfsExportService::DEFAULT_MAX_FEATURES; + } + + $bbox = null; + if ($bboxParam !== null && $bboxParam !== '') { + $parts = array_map('floatval', explode(',', (string) $bboxParam)); + if (count($parts) === 4) { + $bbox = $parts; + } + } + + $collection = $this->wfsExportService->buildFeatureCollection( + maxFeatures: $maxFeatures, + bbox: $bbox, + status: $status, + caseType: $caseType, + ); + + return new JSONResponse($collection); + }//end getFeatures() + + /** + * Return WFS GetCapabilities descriptor for this endpoint. + * + * @NoAdminRequired + * + * @return JSONResponse WFS capabilities descriptor + * + * @throws OCSForbiddenException When user session is not authenticated + * + * @spec openspec/changes/gis-integration/tasks.md#task-19 + */ + public function getCapabilities(): JSONResponse + { + if ($this->userSession->getUser() === null) { + throw new OCSForbiddenException('Authentication required'); + } + + $baseUrl = $this->request->getServerProtocol().'://'.$this->request->getServerHost(); + $capabilities = $this->wfsExportService->buildCapabilities( + baseUrl: $baseUrl.'/index.php/apps/procest/api/gis/wfs' + ); + + return new JSONResponse($capabilities); + }//end getCapabilities() +}//end class diff --git a/lib/Service/WfsExportService.php b/lib/Service/WfsExportService.php new file mode 100644 index 00000000..621e4870 --- /dev/null +++ b/lib/Service/WfsExportService.php @@ -0,0 +1,322 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/gis-integration/tasks.md#task-18 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Procest\Service; + +use OCA\Procest\AppInfo\Application; +use Psr\Log\LoggerInterface; + +/** + * Builds GeoJSON FeatureCollections from case location objects for WFS export. + * + * @spec openspec/changes/gis-integration/tasks.md#task-18 + */ +class WfsExportService +{ + + /** + * Default maximum number of features to return. + */ + public const DEFAULT_MAX_FEATURES = 500; + + /** + * Hard cap on features per request. + */ + public const MAX_FEATURES_HARD_CAP = 2000; + + /** + * WFS type name this service handles. + */ + public const TYPE_NAME_CASES = 'procest:cases'; + + /** + * Constructor. + * + * @param SettingsService $settingsService The settings service (resolves register/schema ids and ObjectService) + * @param LoggerInterface $logger The logger + * + * @return void + */ + public function __construct( + private readonly SettingsService $settingsService, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Build a GeoJSON FeatureCollection of case locations. + * + * @param int $maxFeatures Max features to return (capped at MAX_FEATURES_HARD_CAP) + * @param array|null $bbox Optional bounding box [minLon, minLat, maxLon, maxLat] + * @param string|null $status Optional case status filter + * @param string|null $caseType Optional case type filter + * + * @return array GeoJSON FeatureCollection + * + * @spec openspec/changes/gis-integration/tasks.md#task-18 + * + * @psalm-suppress MixedAssignment + * @psalm-suppress MixedArrayAccess + */ + public function buildFeatureCollection( + int $maxFeatures=self::DEFAULT_MAX_FEATURES, + ?array $bbox=null, + ?string $status=null, + ?string $caseType=null, + ): array { + $limit = min($maxFeatures, self::MAX_FEATURES_HARD_CAP); + + $locations = $this->fetchLocations(limit: $limit, status: $status, caseType: $caseType); + + $features = []; + foreach ($locations as $location) { + $feature = $this->locationToFeature(location: $location); + if ($feature === null) { + continue; + } + + if ($bbox !== null && $this->isOutsideBbox(feature: $feature, bbox: $bbox) === true) { + continue; + } + + $features[] = $feature; + } + + return [ + 'type' => 'FeatureCollection', + 'name' => self::TYPE_NAME_CASES, + 'crs' => [ + 'type' => 'name', + 'properties' => ['name' => 'urn:ogc:def:crs:OGC:1.3:CRS84'], + ], + 'features' => $features, + ]; + }//end buildFeatureCollection() + + /** + * Build a WFS GetCapabilities-style descriptor for this service. + * + * @param string $baseUrl The base URL of the WFS endpoint + * + * @return array Capabilities descriptor + * + * @spec openspec/changes/gis-integration/tasks.md#task-18 + */ + public function buildCapabilities(string $baseUrl): array + { + return [ + 'version' => '2.0.0', + 'title' => 'Procest Case Locations WFS', + 'abstract' => 'WFS endpoint exposing Procest case locations as GeoJSON features.', + 'keywords' => ['procest', 'cases', 'locations', 'GIS', 'WFS'], + 'featureTypes' => [ + [ + 'name' => self::TYPE_NAME_CASES, + 'title' => 'Case Locations', + 'abstract' => 'Case locations with metadata (status, type, assignee, address).', + 'defaultCRS' => 'urn:ogc:def:crs:OGC:1.3:CRS84', + 'outputFormats' => ['application/json'], + 'operations' => ['GetCapabilities', 'GetFeature'], + 'getFeatureUrl' => $baseUrl, + ], + ], + ]; + }//end buildCapabilities() + + /** + * Fetch location objects from OpenRegister, optionally filtered by case status/type. + * + * @param int $limit Max records to fetch + * @param string|null $status Optional case status filter + * @param string|null $caseType Optional case type filter + * + * @return array> Location records + */ + private function fetchLocations(int $limit, ?string $status, ?string $caseType): array + { + $objectService = $this->settingsService->getObjectService(); + if ($objectService === null) { + $this->logger->warning( + 'Procest WfsExportService: ObjectService not available', + ['app' => Application::APP_ID] + ); + return []; + } + + $register = $this->settingsService->getConfigValue('register'); + $locationSchema = $this->settingsService->getConfigValue('location_schema'); + + if ($register === '' || $locationSchema === '') { + return []; + } + + $params = ['_limit' => $limit]; + + try { + $raw = $objectService->findObjects($register, $locationSchema, $params); + } catch (\Throwable $e) { + $this->logger->error( + 'Procest WfsExportService: failed to fetch locations: '.$e->getMessage(), + ['app' => Application::APP_ID] + ); + return []; + } + + if (is_array($raw) === false) { + return []; + } + + // Apply optional case-level filters when status or caseType is set. + if ($status === null && $caseType === null) { + return $raw; + } + + return $this->applyFilters(locations: $raw, status: $status, caseType: $caseType); + }//end fetchLocations() + + /** + * Filter locations by their associated case status or type. + * + * @param array> $locations The location records + * @param string|null $status Status filter + * @param string|null $caseType Case type filter + * + * @return array> Filtered locations + */ + private function applyFilters(array $locations, ?string $status, ?string $caseType): array + { + return array_values( + array_filter( + $locations, + function (mixed $location) use ($status, $caseType): bool { + if (is_array($location) === false) { + return false; + } + + if ($status !== null) { + $locStatus = (string) ($location['caseStatus'] ?? ''); + if ($locStatus !== $status) { + return false; + } + } + + if ($caseType !== null) { + $locType = (string) ($location['caseType'] ?? ''); + if ($locType !== $caseType) { + return false; + } + } + + return true; + } + ) + ); + }//end applyFilters() + + /** + * Convert a single location record to a GeoJSON Feature. + * + * Returns null when the location lacks valid coordinates. + * + * @param array $location The location record + * + * @return array|null GeoJSON Feature or null + */ + private function locationToFeature(array $location): ?array + { + $lat = null; + if (isset($location['latitude']) === true) { + $lat = (float) $location['latitude']; + } + + $lng = null; + if (isset($location['longitude']) === true) { + $lng = (float) $location['longitude']; + } + + if ($lat === null || $lng === null) { + return null; + } + + // Basic WGS84 sanity check. + if ($lat < -90.0 || $lat > 90.0 || $lng < -180.0 || $lng > 180.0) { + return null; + } + + $properties = [ + 'id' => (string) ($location['@id'] ?? ($location['id'] ?? '')), + 'caseId' => (string) ($location['case'] ?? ''), + 'caseIdentifier' => (string) ($location['caseIdentifier'] ?? ''), + 'caseTitle' => (string) ($location['caseTitle'] ?? ''), + 'caseStatus' => (string) ($location['caseStatus'] ?? ''), + 'caseType' => (string) ($location['caseType'] ?? ''), + 'assignee' => (string) ($location['assignee'] ?? ''), + 'source' => (string) ($location['source'] ?? ''), + 'label' => (string) ($location['label'] ?? ''), + 'formattedAddress' => (string) ($location['formattedAddress'] ?? ''), + 'nummeraanduidingId' => (string) ($location['nummeraanduidingId'] ?? ''), + ]; + + return [ + 'type' => 'Feature', + 'id' => $properties['id'], + 'geometry' => [ + 'type' => 'Point', + 'coordinates' => [$lng, $lat], + ], + 'properties' => $properties, + ]; + }//end locationToFeature() + + /** + * Check whether a GeoJSON Feature falls outside the requested bounding box. + * + * @param array $feature The GeoJSON Feature + * @param array $bbox [minLon, minLat, maxLon, maxLat] + * + * @return bool True when the feature is outside the BBOX + */ + private function isOutsideBbox(array $feature, array $bbox): bool + { + if (count($bbox) < 4) { + return false; + } + + $coords = $feature['geometry']['coordinates'] ?? null; + if (is_array($coords) === false || count($coords) < 2) { + return true; + } + + $lng = (float) $coords[0]; + $lat = (float) $coords[1]; + + return ($lng < $bbox[0] || $lat < $bbox[1] || $lng > $bbox[2] || $lat > $bbox[3]); + }//end isOutsideBbox() +}//end class diff --git a/openspec/changes/gis-integration/design.md b/openspec/changes/gis-integration/design.md new file mode 100644 index 00000000..23db34d7 --- /dev/null +++ b/openspec/changes/gis-integration/design.md @@ -0,0 +1,73 @@ +# GIS Integration — Design Document + +**Change name:** gis-integration +**Issue:** #462 +**Kind:** code +**Status:** in-progress + +## Summary + +Add geographic information system (GIS) capabilities to Procest: map-based views of cases, location tagging on cases via address lookup or map interaction, integration with Dutch geo-data services (PDOK, WMS/WFS), and WFS exposure of case locations for external GIS applications. + +## Architecture + +The GIS feature is built on four pillars: + +1. **OpenRegister schemas** — `location`, `wmsLayer`, `mapLayer` objects stored in the Procest register. No direct DB writes; all persistence flows through `ObjectService`. +2. **Backend services** — `LocationService` (location domain), `WmsWfsService` (layer resolution + proxy), `GisProxyService` (outbound HTTP with allowlist + rate limiting), `WfsExportService` (GeoJSON/WFS output for external consumers), `PdokLocatieserverService` + `PdokBagService` (PDOK integration). +3. **Backend controllers** — `GisProxyController`, `WmsWfsController`, `WfsExportController`. +4. **Frontend components** — `MapComponent`, `CaseMap`, `LocationPicker`, `AddressSearch`, `MapLayerSwitcher`, plus views `LocationTab`, `MapLayerSettings`, `CaseMapWidget`, and the manifest-driven `CaseMap` overview page. + +## Acceptance criteria mapping + +| AC | Implementation | +|----|---------------| +| 1 | `LocationPicker.vue` + `AddressSearch.vue` with PDOK autocomplete | +| 2 | `LocationTab.vue` embedding `CaseMap.vue` in read-only mode | +| 3 | Auto-fit on `geometry` / `latitude+longitude` in `LocationTab` | +| 4 | `MapLayerSettings.vue` + `WmsWfsService` + manifest-driven WmsLayers admin page | +| 5 | Manifest `CaseMap` page (type: map) with status/caseType/assignee filters + clustering | +| 6 | `WfsExportController` + `WfsExportService` → GET `/api/gis/wfs` GeoJSON FeatureCollection | +| 7 | `LocationPicker` free-location mode (GPS coords, free-text, map pin) | +| 8 | `AddressSearch` PDOK Locatieserver suggest/lookup with real-time autocomplete | + +## WFS export endpoint (AC 6) + +**URL:** `GET /index.php/apps/procest/api/gis/wfs` + +**Parameters:** +- `outputFormat` — `application/json` (GeoJSON, default) or `text/xml` (not yet supported) +- `typeName` — `procest:cases` (default, only value currently supported) +- `maxFeatures` — max features to return (default 500, max 2000) +- `bbox` — `minLon,minLat,maxLon,maxLat` in WGS84 (optional) +- `status` — filter by case status (optional) +- `caseType` — filter by case type name (optional) + +**Output:** GeoJSON FeatureCollection where each Feature represents one case location. Feature properties include case metadata (id, identifier, title, status, caseType, assignee). + +**Auth:** `#[NoAdminRequired]` — any authenticated Nextcloud user (external GIS apps authenticate via HTTP Basic Auth or OIDC). + +## Validation rules (location payload) + +- `source` MUST be one of: `bag`, `pdok-reverse`, `gps`, `free`, `geocoded`, `import` +- `case` UUID MUST be present +- `source=bag` → `nummeraanduidingId` required +- `source=pdok-reverse` → `latitude` + `longitude` required +- `source=gps` → `latitude`, `longitude`, `accuracyRadius` required +- `source=free` → at least `formattedAddress` OR `latitude`+`longitude` +- Every location MUST carry either `nummeraanduidingId` OR `latitude`+`longitude` + +## Declarative-vs-imperative decision + +The location lifecycle (attach, validate, list) does not fit any `x-openregister-*` extension because: +- Validation cross-checks multiple fields with source-dependent rules (no schema extension for this) +- PDOK reverse geocoding requires lazy DI to an optional external service +- WFS export requires joining location objects with case objects into a GeoJSON FeatureCollection + +All three are therefore implemented as imperative service methods with appropriate unit tests. + +## Security + +- All outbound WMS/WFS proxy traffic flows through `GisProxyService` (allowlist + rate limit). +- `WfsExportController.getFeatures()` uses `#[NoAdminRequired]` — no admin escalation. +- No per-object IDOR concern: the WFS export lists all locations visible to the authenticated user; no mutation endpoints exist on this controller. diff --git a/openspec/changes/gis-integration/hydra.json b/openspec/changes/gis-integration/hydra.json new file mode 100644 index 00000000..26286b3b --- /dev/null +++ b/openspec/changes/gis-integration/hydra.json @@ -0,0 +1,56 @@ +{ + "schema_version": 2, + "spec_slug": "gis-integration", + "repo": "https://github.com/ConductionNL/procest", + "issue": 462, + "cycles": [ + { + "cycle": 1, + "trigger": "orchestrator", + "started_at": "2026-05-19T02:41:13Z", + "ended_at": null, + "outcome": "in-flight", + "outcome_reason": null, + "pattern_tags": [ + "browser-test-nc-setup-failed" + ], + "stages": [ + { + "stage": "build", + "persona": "Al Gorithm", + "model": "sonnet", + "container": "hydra-builder", + "started_at": "2026-05-19T01:58:24Z", + "ended_at": "2026-05-19T02:41:09Z", + "exit_code": 0, + "turns_used": 369, + "turns_budget": 200, + "checks_run": [ + "composer check:strict (phpcs)", + "composer test:unit (phpunit)" + ], + "checks_skipped": [], + "findings": [], + "decisions": [], + "verdict": "pass", + "duration_seconds": 2565 + } + ] + } + ], + "totals": { + "runs": 1, + "turns_used": 369, + "cost_usd": 0.0, + "duration_seconds": 2565, + "cycles": 1, + "by_stage": { + "build": { + "runs": 1, + "turns_used": 369, + "cost_usd": 0.0, + "duration_seconds": 2565 + } + } + } +} diff --git a/openspec/changes/gis-integration/tasks.md b/openspec/changes/gis-integration/tasks.md new file mode 100644 index 00000000..c92916aa --- /dev/null +++ b/openspec/changes/gis-integration/tasks.md @@ -0,0 +1,33 @@ +# GIS Integration — Task List + +**Change:** gis-integration | **Issue:** #462 + +## Already implemented (pre-existing) + +- [x] task-1: `location` schema in `lib/Settings/procest_register.json` +- [x] task-2: `wmsLayer` + `mapLayer` schemas in `lib/Settings/procest_register.json` +- [x] task-3: `PdokLocatieserverService` — PDOK suggest/lookup/reverse geocoding +- [x] task-4: `PdokBagService` — BAG nummeraanduiding/verblijfsobject/pand lookup +- [x] task-5: `LocationService` — validate, reverseGeocode, attachToCase, listForCase +- [x] task-6: `GisProxyService` + `GisProxyController` — proxy with allowlist + rate limit +- [x] task-7: `WmsWfsService` + `WmsWfsController` — per-layer WMS/WFS proxy +- [x] task-8: Routes for GIS proxy + WMS/WFS proxy in `appinfo/routes.php` +- [x] task-9: Pinia gis store (`src/store/modules/gis.js`) +- [x] task-10: `MapComponent.vue`, `CaseMap.vue`, `CasePopup.vue`, `MapLegend.vue` +- [x] task-11: `LocationPicker.vue`, `AddressSearch.vue` (PDOK autocomplete) +- [x] task-12: `MapLayerSwitcher.vue`, `SpatialFilter.vue` +- [x] task-13: `LocationTab.vue` — case detail location management tab +- [x] task-14: `MapLayerSettings.vue` — admin layer configuration +- [x] task-15: `CaseMapWidget.vue` — dashboard widget +- [x] task-16: `CaseMap` manifest page (type: map, filters: status/caseType/assignee/deadlineRange, clustering) +- [x] task-17: `GisProxyControllerTest` + `GisProxyServiceTest` — existing unit tests + +## New implementation required + +- [x] task-18: `WfsExportService` — fetch case locations and format as GeoJSON FeatureCollection (`lib/Service/WfsExportService.php`) +- [x] task-19: `WfsExportController` — GET `/api/gis/wfs` WFS endpoint for external GIS apps (`lib/Controller/WfsExportController.php`) +- [x] task-20: WFS export routes in `appinfo/routes.php` +- [x] task-21: `WfsExportControllerTest` — unit test for WFS export controller +- [x] task-22: `WfsExportServiceTest` — unit test for WFS export service +- [x] task-23: `LocationServiceTest` — unit tests for LocationService.validate(), reverseGeocode(), attachToCase(), listForCase() +- [x] task-24: `WmsWfsControllerTest` — unit tests for WmsWfsController.proxy() diff --git a/task-audit.json b/task-audit.json new file mode 100644 index 00000000..2bef4e15 --- /dev/null +++ b/task-audit.json @@ -0,0 +1,31 @@ +{ + "spec": "openspec/changes/gis-integration", + "verified": [ + {"task": "1", "file": "lib/Settings/procest_register.json#location-schema", "ok": true}, + {"task": "2", "file": "lib/Settings/procest_register.json#wmsLayer-schema", "ok": true}, + {"task": "3", "file": "lib/Service/Pdok/PdokLocatieserverService.php", "ok": true}, + {"task": "4", "file": "lib/Service/Pdok/PdokBagService.php", "ok": true}, + {"task": "5", "file": "lib/Service/LocationService.php", "ok": true}, + {"task": "6", "file": "lib/Controller/GisProxyController.php", "ok": true}, + {"task": "7", "file": "lib/Controller/WmsWfsController.php", "ok": true}, + {"task": "8", "file": "appinfo/routes.php", "ok": true}, + {"task": "9", "file": "src/store/modules/gis.js", "ok": true}, + {"task": "10", "file": "src/components/map/MapComponent.vue", "ok": true}, + {"task": "11", "file": "src/components/map/LocationPicker.vue", "ok": true}, + {"task": "12", "file": "src/components/map/MapLayerSwitcher.vue", "ok": true}, + {"task": "13", "file": "src/views/cases/components/LocationTab.vue", "ok": true}, + {"task": "14", "file": "src/views/settings/MapLayerSettings.vue", "ok": true}, + {"task": "15", "file": "src/views/dashboard/CaseMapWidget.vue", "ok": true}, + {"task": "16", "file": "src/manifest.json#CaseMap-page", "ok": true}, + {"task": "17", "file": "tests/Unit/Controller/GisProxyControllerTest.php", "ok": true}, + {"task": "18", "file": "lib/Service/WfsExportService.php", "ok": true}, + {"task": "19", "file": "lib/Controller/WfsExportController.php", "ok": true}, + {"task": "20", "file": "appinfo/routes.php#wfsExport-routes", "ok": true}, + {"task": "21", "file": "tests/Unit/Controller/WfsExportControllerTest.php", "ok": true}, + {"task": "22", "file": "tests/Unit/Service/WfsExportServiceTest.php", "ok": true}, + {"task": "23", "file": "tests/Unit/Service/LocationServiceTest.php", "ok": true}, + {"task": "24", "file": "tests/Unit/Controller/WmsWfsControllerTest.php", "ok": true} + ], + "stubs": [], + "missing_routes": [] +} diff --git a/tests/Unit/Controller/WfsExportControllerTest.php b/tests/Unit/Controller/WfsExportControllerTest.php new file mode 100644 index 00000000..c22c2a4a --- /dev/null +++ b/tests/Unit/Controller/WfsExportControllerTest.php @@ -0,0 +1,310 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/gis-integration/tasks.md#task-21 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Procest\Tests\Unit\Controller; + +use OCA\Procest\Controller\WfsExportController; +use OCA\Procest\Service\WfsExportService; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; + +/** + * Unit tests for WfsExportController. + * + * @covers \OCA\Procest\Controller\WfsExportController + */ +class WfsExportControllerTest extends TestCase +{ + + /** + * The mocked request. + * + * @var IRequest|\PHPUnit\Framework\MockObject\MockObject + */ + private IRequest $request; + + /** + * The mocked WFS export service. + * + * @var WfsExportService|\PHPUnit\Framework\MockObject\MockObject + */ + private WfsExportService $wfsExportService; + + /** + * The mocked user session. + * + * @var IUserSession|\PHPUnit\Framework\MockObject\MockObject + */ + private IUserSession $userSession; + + /** + * The controller under test. + * + * @var WfsExportController + */ + private WfsExportController $controller; + + /** + * Set up test fixtures. + * + * @return void + */ + protected function setUp(): void + { + $this->request = $this->createMock(originalClassName: IRequest::class); + $this->wfsExportService = $this->createMock(originalClassName: WfsExportService::class); + $this->userSession = $this->createMock(originalClassName: IUserSession::class); + + $mockUser = $this->createMock(originalClassName: IUser::class); + $this->userSession->method('getUser')->willReturn($mockUser); + + $this->controller = new WfsExportController( + appName: 'procest', + request: $this->request, + wfsExportService: $this->wfsExportService, + userSession: $this->userSession, + ); + + }//end setUp() + + /** + * Test that getFeatures() returns 200 with a GeoJSON FeatureCollection. + * + * @return void + */ + public function testGetFeaturesReturnsFeatureCollection(): void + { + $this->request + ->method('getParam') + ->willReturnMap( + [ + ['typeName', WfsExportService::TYPE_NAME_CASES, WfsExportService::TYPE_NAME_CASES], + ['outputFormat', 'application/json', 'application/json'], + ['maxFeatures', WfsExportService::DEFAULT_MAX_FEATURES, 500], + ['bbox', null, null], + ['status', null, null], + ['caseType', null, null], + ] + ); + + $featureCollection = [ + 'type' => 'FeatureCollection', + 'name' => WfsExportService::TYPE_NAME_CASES, + 'features' => [], + ]; + + $this->wfsExportService + ->expects($this->once()) + ->method('buildFeatureCollection') + ->with(500, null, null, null) + ->willReturn($featureCollection); + + $response = $this->controller->getFeatures(); + + $this->assertInstanceOf(expected: JSONResponse::class, actual: $response); + $this->assertSame(expected: 200, actual: $response->getStatus()); + $data = $response->getData(); + $this->assertSame(expected: 'FeatureCollection', actual: $data['type']); + + }//end testGetFeaturesReturnsFeatureCollection() + + /** + * Test that getFeatures() throws OCSForbiddenException when user is null. + * + * @return void + */ + public function testGetFeaturesThrowsWhenUserIsNull(): void + { + $this->userSession = $this->createMock(originalClassName: IUserSession::class); + $this->userSession->method('getUser')->willReturn(null); + + $controller = new WfsExportController( + appName: 'procest', + request: $this->request, + wfsExportService: $this->wfsExportService, + userSession: $this->userSession, + ); + + $this->expectException(exception: OCSForbiddenException::class); + + $controller->getFeatures(); + + }//end testGetFeaturesThrowsWhenUserIsNull() + + /** + * Test that getFeatures() returns 400 for unsupported typeName. + * + * @return void + */ + public function testGetFeaturesReturnsBadRequestForUnknownTypeName(): void + { + $this->request + ->method('getParam') + ->willReturnMap( + [ + ['typeName', WfsExportService::TYPE_NAME_CASES, 'unknown:type'], + ['outputFormat', 'application/json', 'application/json'], + ['maxFeatures', WfsExportService::DEFAULT_MAX_FEATURES, 500], + ['bbox', null, null], + ['status', null, null], + ['caseType', null, null], + ] + ); + + $this->wfsExportService + ->expects($this->never()) + ->method('buildFeatureCollection'); + + $response = $this->controller->getFeatures(); + + $this->assertInstanceOf(expected: JSONResponse::class, actual: $response); + $this->assertSame(expected: 400, actual: $response->getStatus()); + + }//end testGetFeaturesReturnsBadRequestForUnknownTypeName() + + /** + * Test that getFeatures() returns 400 for unsupported outputFormat. + * + * @return void + */ + public function testGetFeaturesReturnsBadRequestForUnsupportedFormat(): void + { + $this->request + ->method('getParam') + ->willReturnMap( + [ + ['typeName', WfsExportService::TYPE_NAME_CASES, WfsExportService::TYPE_NAME_CASES], + ['outputFormat', 'application/json', 'text/xml'], + ['maxFeatures', WfsExportService::DEFAULT_MAX_FEATURES, 500], + ['bbox', null, null], + ['status', null, null], + ['caseType', null, null], + ] + ); + + $this->wfsExportService + ->expects($this->never()) + ->method('buildFeatureCollection'); + + $response = $this->controller->getFeatures(); + + $this->assertInstanceOf(expected: JSONResponse::class, actual: $response); + $this->assertSame(expected: 400, actual: $response->getStatus()); + + }//end testGetFeaturesReturnsBadRequestForUnsupportedFormat() + + /** + * Test that getFeatures() parses bbox parameter and passes it as array. + * + * @return void + */ + public function testGetFeaturesParsesBboxParameter(): void + { + $this->request + ->method('getParam') + ->willReturnMap( + [ + ['typeName', WfsExportService::TYPE_NAME_CASES, WfsExportService::TYPE_NAME_CASES], + ['outputFormat', 'application/json', 'application/json'], + ['maxFeatures', WfsExportService::DEFAULT_MAX_FEATURES, 100], + ['bbox', null, '4.5,52.0,5.5,53.0'], + ['status', null, 'open'], + ['caseType', null, null], + ] + ); + + $this->wfsExportService + ->expects($this->once()) + ->method('buildFeatureCollection') + ->with(100, [4.5, 52.0, 5.5, 53.0], 'open', null) + ->willReturn(['type' => 'FeatureCollection', 'features' => []]); + + $response = $this->controller->getFeatures(); + + $this->assertSame(expected: 200, actual: $response->getStatus()); + + }//end testGetFeaturesParsesBboxParameter() + + /** + * Test that getCapabilities() returns 200 with WFS capabilities descriptor. + * + * @return void + */ + public function testGetCapabilitiesReturnsDescriptor(): void + { + $this->request + ->method('getServerProtocol') + ->willReturn('https'); + $this->request + ->method('getServerHost') + ->willReturn('example.nl'); + + $capabilities = [ + 'version' => '2.0.0', + 'featureTypes' => [['name' => WfsExportService::TYPE_NAME_CASES]], + ]; + + $this->wfsExportService + ->expects($this->once()) + ->method('buildCapabilities') + ->with('https://example.nl/index.php/apps/procest/api/gis/wfs') + ->willReturn($capabilities); + + $response = $this->controller->getCapabilities(); + + $this->assertInstanceOf(expected: JSONResponse::class, actual: $response); + $this->assertSame(expected: 200, actual: $response->getStatus()); + $data = $response->getData(); + $this->assertSame(expected: '2.0.0', actual: $data['version']); + + }//end testGetCapabilitiesReturnsDescriptor() + + /** + * Test that getCapabilities() throws OCSForbiddenException when user is null. + * + * @return void + */ + public function testGetCapabilitiesThrowsWhenUserIsNull(): void + { + $this->userSession = $this->createMock(originalClassName: IUserSession::class); + $this->userSession->method('getUser')->willReturn(null); + + $controller = new WfsExportController( + appName: 'procest', + request: $this->request, + wfsExportService: $this->wfsExportService, + userSession: $this->userSession, + ); + + $this->expectException(exception: OCSForbiddenException::class); + + $controller->getCapabilities(); + + }//end testGetCapabilitiesThrowsWhenUserIsNull() +}//end class diff --git a/tests/Unit/Controller/WmsWfsControllerTest.php b/tests/Unit/Controller/WmsWfsControllerTest.php new file mode 100644 index 00000000..3de37d55 --- /dev/null +++ b/tests/Unit/Controller/WmsWfsControllerTest.php @@ -0,0 +1,235 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/gis-integration/tasks.md#task-24 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Procest\Tests\Unit\Controller; + +use OCA\Procest\Controller\WmsWfsController; +use OCA\Procest\Service\WmsWfsService; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use PHPUnit\Framework\TestCase; + +/** + * Unit tests for WmsWfsController. + * + * @covers \OCA\Procest\Controller\WmsWfsController + */ +class WmsWfsControllerTest extends TestCase +{ + + /** + * The mocked request. + * + * @var IRequest|\PHPUnit\Framework\MockObject\MockObject + */ + private IRequest $request; + + /** + * The mocked WMS/WFS service. + * + * @var WmsWfsService|\PHPUnit\Framework\MockObject\MockObject + */ + private WmsWfsService $wmsWfsService; + + /** + * The controller under test. + * + * @var WmsWfsController + */ + private WmsWfsController $controller; + + /** + * Set up test fixtures. + * + * @return void + */ + protected function setUp(): void + { + $this->request = $this->createMock(originalClassName: IRequest::class); + $this->wmsWfsService = $this->createMock(originalClassName: WmsWfsService::class); + + $this->controller = new WmsWfsController( + appName: 'procest', + request: $this->request, + wmsWfsService: $this->wmsWfsService, + ); + + }//end setUp() + + /** + * Test that proxy() returns 400 when layerId is missing. + * + * @return void + */ + public function testProxyReturnsBadRequestWhenLayerIdMissing(): void + { + $this->request + ->method('getParam') + ->willReturnMap([['layerId', '', '']]); + + $response = $this->controller->proxy(); + + $this->assertInstanceOf(expected: JSONResponse::class, actual: $response); + $this->assertSame(expected: 400, actual: $response->getStatus()); + + }//end testProxyReturnsBadRequestWhenLayerIdMissing() + + /** + * Test that proxy() returns 404 when the layer is not found. + * + * @return void + */ + public function testProxyReturnsNotFoundWhenLayerDoesNotExist(): void + { + $this->request + ->method('getParam') + ->willReturnMap([['layerId', '', 'non-existent-uuid']]); + + $this->wmsWfsService + ->expects($this->once()) + ->method('getLayerById') + ->with('non-existent-uuid') + ->willReturn(null); + + $response = $this->controller->proxy(); + + $this->assertInstanceOf(expected: JSONResponse::class, actual: $response); + $this->assertSame(expected: 404, actual: $response->getStatus()); + + }//end testProxyReturnsNotFoundWhenLayerDoesNotExist() + + /** + * Test that proxy() returns 200 with proxied data on success. + * + * @return void + */ + public function testProxyReturnsSuccessWithProxiedData(): void + { + $layerId = 'layer-uuid-123'; + $layer = ['id' => $layerId, 'type' => 'WMS', 'url' => 'https://service.pdok.nl/wms']; + + $this->request + ->method('getParam') + ->willReturnMap([['layerId', '', $layerId]]); + + $this->request + ->method('getParams') + ->willReturn(['layerId' => $layerId, 'request' => 'GetMap', 'width' => '256', 'height' => '256']); + + $this->wmsWfsService + ->expects($this->once()) + ->method('getLayerById') + ->with($layerId) + ->willReturn($layer); + + $this->wmsWfsService + ->expects($this->once()) + ->method('proxyRequest') + ->with($layer, ['request' => 'GetMap', 'width' => '256', 'height' => '256']) + ->willReturn(['data' => 'image-data', 'contentType' => 'image/png']); + + $response = $this->controller->proxy(); + + $this->assertInstanceOf(expected: JSONResponse::class, actual: $response); + $this->assertSame(expected: 200, actual: $response->getStatus()); + + }//end testProxyReturnsSuccessWithProxiedData() + + /** + * Test that proxy() returns 502 when the WmsWfsService throws a RuntimeException. + * + * @return void + */ + public function testProxyReturnsBadGatewayOnServiceException(): void + { + $layerId = 'layer-uuid-456'; + $layer = ['id' => $layerId, 'type' => 'WMS', 'url' => 'https://service.pdok.nl/wms']; + + $this->request + ->method('getParam') + ->willReturnMap([['layerId', '', $layerId]]); + + $this->request + ->method('getParams') + ->willReturn(['layerId' => $layerId]); + + $this->wmsWfsService + ->method('getLayerById') + ->willReturn($layer); + + $this->wmsWfsService + ->method('proxyRequest') + ->willThrowException(new \RuntimeException('Upstream service failed', 502)); + + $response = $this->controller->proxy(); + + $this->assertInstanceOf(expected: JSONResponse::class, actual: $response); + $this->assertSame(expected: 502, actual: $response->getStatus()); + + }//end testProxyReturnsBadGatewayOnServiceException() + + /** + * Test that proxy() excludes the layerId param when forwarding to WmsWfsService. + * + * @return void + */ + public function testProxyExcludesLayerIdFromForwardedParams(): void + { + $layerId = 'layer-uuid-789'; + $layer = ['id' => $layerId, 'type' => 'WFS', 'url' => 'https://service.pdok.nl/wfs']; + + $this->request + ->method('getParam') + ->willReturnMap([['layerId', '', $layerId]]); + + $allParams = ['layerId' => $layerId, 'request' => 'GetFeature', 'typeName' => 'perceel']; + $this->request + ->method('getParams') + ->willReturn($allParams); + + $this->wmsWfsService->method('getLayerById')->willReturn($layer); + + $this->wmsWfsService + ->expects($this->once()) + ->method('proxyRequest') + ->with( + $layer, + $this->callback( + callback: function (array $params): bool { + // LayerId must NOT be in the forwarded params. + return isset($params['request']) === true + && isset($params['typeName']) === true + && isset($params['layerId']) === false; + } + ) + ) + ->willReturn(['features' => []]); + + $response = $this->controller->proxy(); + + $this->assertSame(expected: 200, actual: $response->getStatus()); + + }//end testProxyExcludesLayerIdFromForwardedParams() +}//end class diff --git a/tests/Unit/Service/LocationServiceTest.php b/tests/Unit/Service/LocationServiceTest.php new file mode 100644 index 00000000..7548818d --- /dev/null +++ b/tests/Unit/Service/LocationServiceTest.php @@ -0,0 +1,425 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/gis-integration/tasks.md#task-23 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Procest\Tests\Unit\Service; + +use OCA\Procest\Service\LocationService; +use OCA\Procest\Service\SettingsService; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Minimal ObjectService shape used by LocationService. + * + * Declares the positional signature used in production so that + * `createMock(LocationObjectServiceStub::class)` returns a configurable stub. + * A `getMockBuilder(\stdClass::class)->addMethods([...])` stub throws + * "Unknown named parameter" on named-arg calls in PHPUnit 10. + */ +interface LocationObjectServiceStub +{ + /** + * Find objects in the given register/schema. + * + * @param string $register The register name + * @param string $schema The schema name + * @param array $filters Filter criteria + * @param array $options Additional options + * @param int $limit Maximum results + * + * @return array + */ + public function findObjects(string $register, string $schema, array $filters, array $options=[], int $limit=500): array; +}//end interface + +/** + * Unit tests for LocationService. + * + * @covers \OCA\Procest\Service\LocationService + */ +class LocationServiceTest extends TestCase +{ + + /** + * The mocked settings service. + * + * @var SettingsService|\PHPUnit\Framework\MockObject\MockObject + */ + private SettingsService $settingsService; + + /** + * The mocked DI container. + * + * @var ContainerInterface|\PHPUnit\Framework\MockObject\MockObject + */ + private ContainerInterface $container; + + /** + * The mocked logger. + * + * @var LoggerInterface|\PHPUnit\Framework\MockObject\MockObject + */ + private LoggerInterface $logger; + + /** + * The service under test. + * + * @var LocationService + */ + private LocationService $service; + + /** + * Set up test fixtures. + * + * @return void + */ + protected function setUp(): void + { + $this->settingsService = $this->createMock(originalClassName: SettingsService::class); + $this->container = $this->createMock(originalClassName: ContainerInterface::class); + $this->logger = $this->createMock(originalClassName: LoggerInterface::class); + + $this->service = new LocationService( + settingsService: $this->settingsService, + container: $this->container, + logger: $this->logger, + ); + + }//end setUp() + + /** + * Test that validate() returns an error when source is missing. + * + * @return void + */ + public function testValidateReturnsMissingSourceError(): void + { + $errors = $this->service->validate( + [ + 'case' => 'case-uuid', + 'latitude' => 52.3, + 'longitude' => 4.9, + ] + ); + + $this->assertContains(needle: 'source.required', haystack: $errors); + + }//end testValidateReturnsMissingSourceError() + + /** + * Test that validate() returns an error for an invalid source value. + * + * @return void + */ + public function testValidateReturnsInvalidSourceError(): void + { + $errors = $this->service->validate( + [ + 'case' => 'case-uuid', + 'source' => 'invalid-source', + 'latitude' => 52.3, + 'longitude' => 4.9, + ] + ); + + $this->assertContains(needle: 'source.invalid', haystack: $errors); + + }//end testValidateReturnsInvalidSourceError() + + /** + * Test that validate() returns error when case UUID is missing. + * + * @return void + */ + public function testValidateReturnsMissingCaseError(): void + { + $errors = $this->service->validate( + [ + 'source' => 'gps', + 'latitude' => 52.3, + 'longitude' => 4.9, + 'accuracyRadius' => 5, + ] + ); + + $this->assertContains(needle: 'case.required', haystack: $errors); + + }//end testValidateReturnsMissingCaseError() + + /** + * Test that validate() requires nummeraanduidingId for source=bag. + * + * @return void + */ + public function testValidateRequiresNummeraanduidingIdForBagSource(): void + { + $errors = $this->service->validate( + [ + 'case' => 'case-uuid', + 'source' => 'bag', + 'latitude' => 52.3, + 'longitude' => 4.9, + ] + ); + + $this->assertContains(needle: 'nummeraanduidingId.required', haystack: $errors); + + }//end testValidateRequiresNummeraanduidingIdForBagSource() + + /** + * Test that validate() returns no errors for a valid BAG payload. + * + * @return void + */ + public function testValidatePassesForValidBagPayload(): void + { + $errors = $this->service->validate( + [ + 'case' => 'case-uuid', + 'source' => 'bag', + 'nummeraanduidingId' => '0363200003761521', + 'latitude' => 52.3, + 'longitude' => 4.9, + ] + ); + + $this->assertEmpty(actual: $errors); + + }//end testValidatePassesForValidBagPayload() + + /** + * Test that validate() returns error for GPS source without accuracyRadius. + * + * @return void + */ + public function testValidateRequiresAccuracyRadiusForGpsSource(): void + { + $errors = $this->service->validate( + [ + 'case' => 'case-uuid', + 'source' => 'gps', + 'latitude' => 52.3, + 'longitude' => 4.9, + ] + ); + + $this->assertContains(needle: 'accuracyRadius.required', haystack: $errors); + + }//end testValidateRequiresAccuracyRadiusForGpsSource() + + /** + * Test that validate() returns no errors for a valid GPS payload. + * + * @return void + */ + public function testValidatePassesForValidGpsPayload(): void + { + $errors = $this->service->validate( + [ + 'case' => 'case-uuid', + 'source' => 'gps', + 'latitude' => 52.3, + 'longitude' => 4.9, + 'accuracyRadius' => 5, + ] + ); + + $this->assertEmpty(actual: $errors); + + }//end testValidatePassesForValidGpsPayload() + + /** + * Test that validate() requires address OR coordinates for source=free. + * + * @return void + */ + public function testValidateRequiresAddressOrCoordsForFreeSource(): void + { + $errors = $this->service->validate( + [ + 'case' => 'case-uuid', + 'source' => 'free', + 'label' => 'Some field', + ] + ); + + $this->assertContains(needle: 'formattedAddress-or-coordinates.required', haystack: $errors); + + }//end testValidateRequiresAddressOrCoordsForFreeSource() + + /** + * Test that validate() passes for a free source with coordinates only. + * + * @return void + */ + public function testValidatePassesForFreeSourceWithCoordinatesOnly(): void + { + $errors = $this->service->validate( + [ + 'case' => 'case-uuid', + 'source' => 'free', + 'latitude' => 52.0, + 'longitude' => 5.0, + ] + ); + + $this->assertEmpty(actual: $errors); + + }//end testValidatePassesForFreeSourceWithCoordinatesOnly() + + /** + * Test that reverseGeocode() returns null for out-of-range latitude. + * + * @return void + */ + public function testReverseGeocodeReturnsNullForInvalidLatitude(): void + { + $result = $this->service->reverseGeocode(latitude: 200.0, longitude: 4.9); + + $this->assertNull(actual: $result); + + }//end testReverseGeocodeReturnsNullForInvalidLatitude() + + /** + * Test that reverseGeocode() returns null when PDOK service is unavailable. + * + * @return void + */ + public function testReverseGeocodeReturnsNullWhenPdokUnavailable(): void + { + $this->container + ->method('get') + ->willThrowException(new \RuntimeException('Service not found')); + + $result = $this->service->reverseGeocode(latitude: 52.3, longitude: 4.9); + + $this->assertNull(actual: $result); + + }//end testReverseGeocodeReturnsNullWhenPdokUnavailable() + + /** + * Test that attachToCase() throws RuntimeException when caseId is empty. + * + * @return void + */ + public function testAttachToCaseThrowsForEmptyCaseId(): void + { + $this->expectException(exception: \RuntimeException::class); + + $this->service->attachToCase(caseId: '', location: []); + + }//end testAttachToCaseThrowsForEmptyCaseId() + + /** + * Test that attachToCase() throws RuntimeException when validation fails. + * + * @return void + */ + public function testAttachToCaseThrowsWhenValidationFails(): void + { + $this->expectException(exception: \RuntimeException::class); + $this->expectExceptionMessageMatches(regularExpression: '/validation/'); + + // Missing source and coordinates — validation should fail. + $this->service->attachToCase(caseId: 'case-uuid', location: ['label' => 'no data']); + + }//end testAttachToCaseThrowsWhenValidationFails() + + /** + * Test that attachToCase() throws RuntimeException when ObjectService is unavailable. + * + * @return void + */ + public function testAttachToCaseThrowsWhenObjectServiceUnavailable(): void + { + $this->settingsService + ->method('getObjectService') + ->willReturn(null); + + $this->expectException(exception: \RuntimeException::class); + $this->expectExceptionMessageMatches(regularExpression: '/OpenRegister/'); + + $this->service->attachToCase( + caseId: 'case-uuid', + location: [ + 'source' => 'gps', + 'latitude' => 52.3, + 'longitude' => 4.9, + 'accuracyRadius' => 5, + ] + ); + + }//end testAttachToCaseThrowsWhenObjectServiceUnavailable() + + /** + * Test that listForCase() returns an empty array when ObjectService is unavailable. + * + * @return void + */ + public function testListForCaseReturnsEmptyArrayWhenObjectServiceUnavailable(): void + { + $this->settingsService + ->method('getObjectService') + ->willReturn(null); + + $result = $this->service->listForCase(caseId: 'case-uuid'); + + $this->assertSame(expected: [], actual: $result); + + }//end testListForCaseReturnsEmptyArrayWhenObjectServiceUnavailable() + + /** + * Test that listForCase() returns location records from ObjectService. + * + * @return void + */ + public function testListForCaseReturnsLocationsFromObjectService(): void + { + $mockObjectService = $this->createMock(originalClassName: LocationObjectServiceStub::class); + + $expectedLocations = [ + ['id' => 'loc-1', 'case' => 'case-uuid', 'latitude' => 52.3, 'longitude' => 4.9], + ]; + + $mockObjectService + ->expects($this->once()) + ->method('findObjects') + ->willReturn($expectedLocations); + + $this->settingsService->method('getObjectService')->willReturn($mockObjectService); + $this->settingsService->method('getConfigValue')->willReturnMap( + [ + ['register', 'procest'], + ['location_schema', 'location'], + ] + ); + + $result = $this->service->listForCase(caseId: 'case-uuid'); + + $this->assertSame(expected: $expectedLocations, actual: $result); + + }//end testListForCaseReturnsLocationsFromObjectService() +}//end class diff --git a/tests/Unit/Service/WfsExportServiceTest.php b/tests/Unit/Service/WfsExportServiceTest.php new file mode 100644 index 00000000..2562b3b1 --- /dev/null +++ b/tests/Unit/Service/WfsExportServiceTest.php @@ -0,0 +1,320 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/gis-integration/tasks.md#task-22 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Procest\Tests\Unit\Service; + +use OCA\Procest\Service\SettingsService; +use OCA\Procest\Service\WfsExportService; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Minimal ObjectService shape used by WfsExportService. + * + * Declares the positional signature used in production so that + * `createMock(WfsObjectServiceStub::class)` returns a configurable stub. + * A `getMockBuilder(\stdClass::class)->addMethods([...])` stub throws + * "Unknown named parameter" on named-arg calls in PHPUnit 10. + */ +interface WfsObjectServiceStub +{ + /** + * Find objects in the given register/schema. + * + * @param string $register The register name + * @param string $schema The schema name + * @param array $params Query parameters + * + * @return array + */ + public function findObjects(string $register, string $schema, array $params): array; +}//end interface + +/** + * Unit tests for WfsExportService. + * + * @covers \OCA\Procest\Service\WfsExportService + */ +class WfsExportServiceTest extends TestCase +{ + + /** + * The mocked settings service. + * + * @var SettingsService|\PHPUnit\Framework\MockObject\MockObject + */ + private SettingsService $settingsService; + + /** + * The mocked logger. + * + * @var LoggerInterface|\PHPUnit\Framework\MockObject\MockObject + */ + private LoggerInterface $logger; + + /** + * The service under test. + * + * @var WfsExportService + */ + private WfsExportService $service; + + /** + * Set up test fixtures. + * + * @return void + */ + protected function setUp(): void + { + $this->settingsService = $this->createMock(originalClassName: SettingsService::class); + $this->logger = $this->createMock(originalClassName: LoggerInterface::class); + + $this->service = new WfsExportService( + settingsService: $this->settingsService, + logger: $this->logger, + ); + + }//end setUp() + + /** + * Test that buildFeatureCollection() returns empty features when ObjectService unavailable. + * + * @return void + */ + public function testBuildFeatureCollectionReturnsEmptyWhenObjectServiceUnavailable(): void + { + $this->settingsService + ->method('getObjectService') + ->willReturn(null); + + $result = $this->service->buildFeatureCollection(); + + $this->assertSame(expected: 'FeatureCollection', actual: $result['type']); + $this->assertSame(expected: [], actual: $result['features']); + + }//end testBuildFeatureCollectionReturnsEmptyWhenObjectServiceUnavailable() + + /** + * Test that buildFeatureCollection() converts location records to GeoJSON features. + * + * @return void + */ + public function testBuildFeatureCollectionConvertsLocationsToFeatures(): void + { + $mockObjectService = $this->createMock(originalClassName: WfsObjectServiceStub::class); + + $locations = [ + [ + '@id' => 'loc-uuid-1', + 'case' => 'case-uuid-1', + 'caseTitle' => 'Test Case', + 'caseStatus' => 'open', + 'caseType' => 'Omgevingsvergunning', + 'source' => 'bag', + 'formattedAddress' => 'Teststraat 1, Amsterdam', + 'latitude' => 52.3, + 'longitude' => 4.9, + ], + ]; + + $mockObjectService + ->method('findObjects') + ->willReturn($locations); + + $this->settingsService + ->method('getObjectService') + ->willReturn($mockObjectService); + $this->settingsService + ->method('getConfigValue') + ->willReturnMap( + [ + ['register', 'procest'], + ['location_schema', 'location'], + ] + ); + + $result = $this->service->buildFeatureCollection(); + + $this->assertSame(expected: 'FeatureCollection', actual: $result['type']); + $this->assertCount(expectedCount: 1, haystack: $result['features']); + + $feature = $result['features'][0]; + $this->assertSame(expected: 'Feature', actual: $feature['type']); + $this->assertSame(expected: 'Point', actual: $feature['geometry']['type']); + $this->assertSame(expected: [4.9, 52.3], actual: $feature['geometry']['coordinates']); + $this->assertSame(expected: 'Test Case', actual: $feature['properties']['caseTitle']); + $this->assertSame(expected: 'open', actual: $feature['properties']['caseStatus']); + + }//end testBuildFeatureCollectionConvertsLocationsToFeatures() + + /** + * Test that buildFeatureCollection() skips locations without coordinates. + * + * @return void + */ + public function testBuildFeatureCollectionSkipsLocationsWithoutCoordinates(): void + { + $mockObjectService = $this->createMock(originalClassName: WfsObjectServiceStub::class); + + $locations = [ + [ + '@id' => 'loc-no-coords', + 'case' => 'case-uuid-2', + 'source' => 'free', + 'label' => 'No coordinates', + // No latitude / longitude. + ], + [ + '@id' => 'loc-with-coords', + 'case' => 'case-uuid-3', + 'source' => 'gps', + 'latitude' => 51.9, + 'longitude' => 4.47, + ], + ]; + + $mockObjectService + ->method('findObjects') + ->willReturn($locations); + + $this->settingsService + ->method('getObjectService') + ->willReturn($mockObjectService); + $this->settingsService + ->method('getConfigValue') + ->willReturnMap( + [ + ['register', 'procest'], + ['location_schema', 'location'], + ] + ); + + $result = $this->service->buildFeatureCollection(); + + $this->assertCount(expectedCount: 1, haystack: $result['features']); + $this->assertSame(expected: [4.47, 51.9], actual: $result['features'][0]['geometry']['coordinates']); + + }//end testBuildFeatureCollectionSkipsLocationsWithoutCoordinates() + + /** + * Test that buildFeatureCollection() filters features by bounding box. + * + * @return void + */ + public function testBuildFeatureCollectionFiltersByBbox(): void + { + $mockObjectService = $this->createMock(originalClassName: WfsObjectServiceStub::class); + + $locations = [ + [ + '@id' => 'inside', + 'case' => 'case-1', + 'source' => 'gps', + 'latitude' => 52.3, + 'longitude' => 4.9, + ], + [ + '@id' => 'outside', + 'case' => 'case-2', + 'source' => 'gps', + 'latitude' => 51.0, + 'longitude' => 3.0, + ], + ]; + + $mockObjectService + ->method('findObjects') + ->willReturn($locations); + + $this->settingsService->method('getObjectService')->willReturn($mockObjectService); + $this->settingsService->method('getConfigValue')->willReturnMap( + [ + ['register', 'procest'], + ['location_schema', 'location'], + ] + ); + + // Bbox covering Amsterdam area only. + $result = $this->service->buildFeatureCollection(bbox: [4.5, 52.0, 5.5, 53.0]); + + $this->assertCount(expectedCount: 1, haystack: $result['features']); + $this->assertSame(expected: 'inside', actual: $result['features'][0]['id']); + + }//end testBuildFeatureCollectionFiltersByBbox() + + /** + * Test that maxFeatures is capped at the hard cap. + * + * @return void + */ + public function testBuildFeatureCollectionCapsMaxFeaturesAtHardCap(): void + { + $mockObjectService = $this->createMock(originalClassName: WfsObjectServiceStub::class); + + $mockObjectService + ->expects($this->once()) + ->method('findObjects') + ->with( + 'procest', + 'location', + $this->callback( + callback: function (array $params): bool { + return $params['_limit'] === WfsExportService::MAX_FEATURES_HARD_CAP; + } + ) + ) + ->willReturn([]); + + $this->settingsService->method('getObjectService')->willReturn($mockObjectService); + $this->settingsService->method('getConfigValue')->willReturnMap( + [ + ['register', 'procest'], + ['location_schema', 'location'], + ] + ); + + // Request 99999 features — must be capped at hard cap. + $result = $this->service->buildFeatureCollection(maxFeatures: 99999); + + $this->assertSame(expected: 'FeatureCollection', actual: $result['type']); + + }//end testBuildFeatureCollectionCapsMaxFeaturesAtHardCap() + + /** + * Test that buildCapabilities() returns a valid WFS capabilities descriptor. + * + * @return void + */ + public function testBuildCapabilitiesReturnsValidDescriptor(): void + { + $result = $this->service->buildCapabilities('https://example.nl/api/gis/wfs'); + + $this->assertSame(expected: '2.0.0', actual: $result['version']); + $this->assertNotEmpty(actual: $result['featureTypes']); + $this->assertSame(expected: WfsExportService::TYPE_NAME_CASES, actual: $result['featureTypes'][0]['name']); + $this->assertStringContainsString(needle: 'https://example.nl/api/gis/wfs', haystack: $result['featureTypes'][0]['getFeatureUrl']); + + }//end testBuildCapabilitiesReturnsValidDescriptor() +}//end class