From 2ecd19a2ae3721ace4496a03cb00cf5ffce4ba42 Mon Sep 17 00:00:00 2001 From: John Thompson Date: Mon, 13 Apr 2026 16:02:35 -0400 Subject: [PATCH 1/2] Bump Spring Boot to latest --- pom.xml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 89212edf..1059a2c6 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,7 @@ org.springframework.boot spring-boot-starter-parent - 4.0.1 + 4.0.3 @@ -50,7 +50,8 @@ openespi-common openespi-datacustodian openespi-thirdparty - openespi-authserver + + From 86c69c721c4080e1ee3ef5cdf04d4a71f78b90bb Mon Sep 17 00:00:00 2001 From: John Thompson Date: Mon, 13 Apr 2026 16:02:58 -0400 Subject: [PATCH 2/2] Expand `DtoExportService` with additional feed and entry creation methods; add corresponding tests. Update controllers to leverage `ApiRequestValidator`. Introduce new test classes for `BatchController` and `IntervalBlockController`. --- .../common/dto/usage/ServiceStatusDto.java | 54 ++++ .../mapper/customer/CustomerMapper.java | 31 +++ .../common/service/BaseExportService.java | 40 +++ .../espi/common/service/DtoExportService.java | 42 +++ .../common/service/ReadingTypeService.java | 3 + .../service/impl/DtoExportServiceFacade.java | 178 +++++++++++- .../service/impl/DtoExportServiceImpl.java | 218 ++++++++++++--- .../service/impl/ReadingTypeServiceImpl.java | 6 + .../service/impl/UsageExportService.java | 1 + .../dto/customer/CustomerAccountDtoTest.java | 2 +- .../customer/CustomerAgreementDtoTest.java | 2 +- .../common/dto/customer/CustomerDtoTest.java | 5 +- .../common/dto/customer/EndDeviceDtoTest.java | 5 +- .../common/dto/customer/MeterDtoTest.java | 5 +- .../dto/customer/ServiceLocationDtoTest.java | 5 +- .../dto/customer/ServiceSupplierDtoTest.java | 5 +- .../api/ApplicationInformationController.java | 213 ++++++++++++++ .../web/api/AuthorizationController.java | 19 +- .../web/api/BatchController.java | 262 ++++++++++++++++++ .../web/api/CustomerAccountController.java | 136 +++++++++ .../web/api/CustomerController.java | 128 +++++++++ ...ElectricPowerQualitySummaryController.java | 134 +++++++++ .../web/api/IntervalBlockController.java | 132 +++++++++ .../web/api/ManageController.java | 148 ++++++++++ .../web/api/MeterReadingController.java | 19 +- .../web/api/ReadingTypeController.java | 140 ++++++++++ .../web/api/RetailCustomerController.java | 153 ++++++++++ .../web/api/ServiceStatusController.java | 101 +++++++ .../web/api/TimeConfigurationController.java | 157 +++++++++++ .../web/api/UsagePointController.java | 67 ++++- .../web/api/UsageSummaryController.java | 204 ++++++++++++++ .../web/api/support/ApiAccessValidator.java | 78 ++++++ .../web/api/support/ApiRequestValidator.java | 102 +++++++ .../ApplicationInformationControllerTest.java | 232 ++++++++++++++++ .../web/api/AuthorizationControllerTest.java | 149 ++++++++++ .../web/api/BatchControllerTest.java | 215 ++++++++++++++ .../api/CustomerAccountControllerTest.java | 139 ++++++++++ .../web/api/CustomerControllerTest.java | 139 ++++++++++ ...tricPowerQualitySummaryControllerTest.java | 144 ++++++++++ .../web/api/IntervalBlockControllerTest.java | 139 ++++++++++ .../web/api/ManageControllerTest.java | 99 +++++++ .../web/api/MeterReadingControllerTest.java | 148 ++++++++++ .../web/api/ReadingTypeControllerTest.java | 138 +++++++++ .../web/api/RetailCustomerControllerTest.java | 144 ++++++++++ .../web/api/ServiceStatusControllerTest.java | 104 +++++++ .../api/TimeConfigurationControllerTest.java | 171 ++++++++++++ .../web/api/UsagePointControllerTest.java | 171 ++++++++++++ .../web/api/UsageSummaryControllerTest.java | 217 +++++++++++++++ openespi-datacustodian/temp.txt | 145 ++++++++++ 49 files changed, 5203 insertions(+), 86 deletions(-) create mode 100644 openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/ServiceStatusDto.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationController.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/BatchController.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountController.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerController.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryController.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockController.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ManageController.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeController.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/RetailCustomerController.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ServiceStatusController.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/TimeConfigurationController.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryController.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/support/ApiAccessValidator.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/support/ApiRequestValidator.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationControllerTest.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationControllerTest.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/BatchControllerTest.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountControllerTest.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerControllerTest.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryControllerTest.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockControllerTest.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ManageControllerTest.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingControllerTest.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeControllerTest.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/RetailCustomerControllerTest.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ServiceStatusControllerTest.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/TimeConfigurationControllerTest.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointControllerTest.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryControllerTest.java create mode 100644 openespi-datacustodian/temp.txt diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/ServiceStatusDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/ServiceStatusDto.java new file mode 100644 index 00000000..c15c2c77 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/ServiceStatusDto.java @@ -0,0 +1,54 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.greenbuttonalliance.espi.common.dto.usage; + +import jakarta.xml.bind.annotation.XmlAccessType; +import jakarta.xml.bind.annotation.XmlAccessorType; +import jakarta.xml.bind.annotation.XmlElement; +import jakarta.xml.bind.annotation.XmlRootElement; +import jakarta.xml.bind.annotation.XmlType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * DTO for ESPI ServiceStatus. + * + *

Java class for ServiceStatus complex type. + */ +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@XmlRootElement(name = "ServiceStatus", namespace = "http://naesb.org/espi") +@XmlAccessorType(XmlAccessType.FIELD) +@XmlType(name = "ServiceStatus", namespace = "http://naesb.org/espi", propOrder = { + "currentStatus" +}) +public class ServiceStatusDto { + + @XmlElement(required = true) + private String currentStatus; + +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/CustomerMapper.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/CustomerMapper.java index c856811f..9a8f3b7e 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/CustomerMapper.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/CustomerMapper.java @@ -20,6 +20,7 @@ package org.greenbuttonalliance.espi.common.mapper.customer; import org.greenbuttonalliance.espi.common.domain.customer.entity.CustomerEntity; +import org.greenbuttonalliance.espi.common.domain.usage.RetailCustomerEntity; import org.greenbuttonalliance.espi.common.dto.customer.CustomerDto; import org.greenbuttonalliance.espi.common.mapper.BaseMapperUtils; import org.greenbuttonalliance.espi.common.mapper.DateTimeMapper; @@ -59,6 +60,36 @@ public interface CustomerMapper { @Mapping(target = "customerName", source = "customerName") CustomerDto toDto(CustomerEntity entity); + /** + * Converts a RetailCustomerEntity to a CustomerDto. + * Maps essential identity fields to Customer representation. + * + * @param retailCustomer the retail customer entity + * @return the customer DTO + */ + default CustomerDto toDto(RetailCustomerEntity retailCustomer) { + if (retailCustomer == null) { + return null; + } + CustomerDto dto = new CustomerDto(); + dto.setCustomerName(retailCustomer.getFirstName() + " " + retailCustomer.getLastName()); + + org.greenbuttonalliance.espi.common.dto.customer.OrganisationDto orgDto = new org.greenbuttonalliance.espi.common.dto.customer.OrganisationDto(); + orgDto.setOrganisationName(retailCustomer.getFirstName() + " " + retailCustomer.getLastName()); + + org.greenbuttonalliance.espi.common.dto.customer.ElectronicAddressDto emailDto = new org.greenbuttonalliance.espi.common.dto.customer.ElectronicAddressDto(); + emailDto.setEmail1(retailCustomer.getEmail()); + orgDto.setElectronicAddress(emailDto); + + org.greenbuttonalliance.espi.common.dto.customer.TelephoneNumberDto phoneDto = new org.greenbuttonalliance.espi.common.dto.customer.TelephoneNumberDto(); + phoneDto.setLocalNumber(retailCustomer.getPhone()); + orgDto.setPhone1(phoneDto); + + dto.setOrganisation(orgDto); + + return dto; + } + /** * Converts a CustomerDto to a CustomerEntity. * Maps customer information including embedded objects. diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/BaseExportService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/BaseExportService.java index 0c2b9f4a..66f757d4 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/BaseExportService.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/BaseExportService.java @@ -19,6 +19,10 @@ package org.greenbuttonalliance.espi.common.service; +import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.atom.LinkDto; +import org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto; + import jakarta.annotation.PostConstruct; import jakarta.xml.bind.JAXBContext; import jakarta.xml.bind.JAXBException; @@ -196,4 +200,40 @@ public void exportDtoWithHeader(Object dto, OutputStream stream) { throw new RuntimeException("Failed to export DTO with header", e); } } + + /** + * Creates an Atom entry for ServiceStatus. + * + * @param currentStatus the current service status + * @return AtomEntryDto wrapped around ServiceStatusDto + */ + public AtomEntryDto createServiceStatusEntry(String currentStatus) { + org.greenbuttonalliance.espi.common.dto.usage.ServiceStatusDto serviceStatus = + org.greenbuttonalliance.espi.common.dto.usage.ServiceStatusDto.builder() + .currentStatus(currentStatus) + .build(); + + AtomEntryDto entry = new UsageAtomEntryDto(); + entry.setTitle("ServiceStatus"); + entry.setId("urn:uuid:" + java.util.UUID.randomUUID()); + entry.setPublished(java.time.OffsetDateTime.now()); + entry.setUpdated(java.time.OffsetDateTime.now()); + + java.util.List links = new java.util.ArrayList<>(); + + LinkDto selfLink = new LinkDto(); + selfLink.setRel("self"); + selfLink.setHref("ServiceStatus"); + links.add(selfLink); + + LinkDto upLink = new LinkDto(); + upLink.setRel("up"); + upLink.setHref(""); + links.add(upLink); + + entry.setLinks(links); + entry.setContent(serviceStatus); + + return entry; + } } diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/DtoExportService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/DtoExportService.java index 9cd0289f..ee62bbca 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/DtoExportService.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/DtoExportService.java @@ -19,7 +19,11 @@ package org.greenbuttonalliance.espi.common.service; +import org.greenbuttonalliance.espi.common.domain.usage.ReadingTypeEntity; +import org.greenbuttonalliance.espi.common.domain.usage.RetailCustomerEntity; +import org.greenbuttonalliance.espi.common.domain.usage.TimeConfigurationEntity; import org.greenbuttonalliance.espi.common.domain.usage.UsagePointEntity; +import org.greenbuttonalliance.espi.common.domain.usage.UsageSummaryEntity; import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto; import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto; @@ -93,5 +97,43 @@ public interface DtoExportService { */ AtomEntryDto createAtomEntry(String title, Object resource); + AtomFeedDto createUsagePointsFeed(List usagePoints); + + AtomFeedDto createUsagePointsFeedByIds(List usagePointIds); + + AtomEntryDto createUsagePointEntry(UsagePointEntity usagePoint); + + AtomEntryDto createUsagePointEntry(UUID usagePointId); + + AtomFeedDto createTimeConfigurationsFeed(); + + AtomFeedDto createTimeConfigurationsFeedByUsagePointId(UUID usagePointId); + + AtomEntryDto createTimeConfigurationEntry(UUID timeConfigurationId); + + AtomEntryDto createTimeConfigurationEntry(TimeConfigurationEntity timeConfiguration); + + AtomFeedDto createRetailCustomersFeed(); + + AtomEntryDto createRetailCustomerEntry(Long retailCustomerId); + + AtomEntryDto createRetailCustomerEntry(RetailCustomerEntity retailCustomer); + + AtomFeedDto createUsageSummariesFeed(); + + AtomFeedDto createUsageSummariesFeedByUsagePointId(UUID usagePointId); + + AtomEntryDto createUsageSummaryEntry(UUID usageSummaryId); + + AtomEntryDto createUsageSummaryEntry(UsageSummaryEntity usageSummary); + + AtomFeedDto createReadingTypesFeed(); + + AtomEntryDto createReadingTypeEntry(UUID readingTypeId); + + AtomEntryDto createReadingTypeEntry(ReadingTypeEntity readingType); + + AtomEntryDto createServiceStatusEntry(String currentStatus); + void exportAtomFeed(AtomFeedDto atomFeedDto, OutputStream stream); } \ No newline at end of file diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/ReadingTypeService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/ReadingTypeService.java index cbb59bd1..cb2aae6f 100755 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/ReadingTypeService.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/ReadingTypeService.java @@ -23,6 +23,7 @@ import org.greenbuttonalliance.espi.common.domain.usage.ReadingTypeEntity; import java.io.InputStream; +import java.util.List; import java.util.UUID; public interface ReadingTypeService { @@ -41,6 +42,8 @@ public interface ReadingTypeService { ReadingTypeEntity findById(UUID readingTypeId); + List findAll(); + void add(ReadingTypeEntity readingType); void delete(ReadingTypeEntity readingType); diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/DtoExportServiceFacade.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/DtoExportServiceFacade.java index 0c800fb4..7514509d 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/DtoExportServiceFacade.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/DtoExportServiceFacade.java @@ -21,16 +21,29 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.greenbuttonalliance.espi.common.domain.usage.ReadingTypeEntity; +import org.greenbuttonalliance.espi.common.domain.usage.RetailCustomerEntity; +import org.greenbuttonalliance.espi.common.domain.usage.TimeConfigurationEntity; import org.greenbuttonalliance.espi.common.domain.usage.UsagePointEntity; +import org.greenbuttonalliance.espi.common.domain.usage.UsageSummaryEntity; import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto; import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto; import org.greenbuttonalliance.espi.common.dto.atom.CustomerAtomEntryDto; import org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.customer.CustomerDto; +import org.greenbuttonalliance.espi.common.dto.usage.TimeConfigurationDto; import org.greenbuttonalliance.espi.common.dto.usage.UsagePointDto; +import org.greenbuttonalliance.espi.common.mapper.customer.CustomerMapper; +import org.greenbuttonalliance.espi.common.mapper.usage.ReadingTypeMapper; +import org.greenbuttonalliance.espi.common.mapper.usage.TimeConfigurationMapper; import org.greenbuttonalliance.espi.common.mapper.usage.UsagePointMapper; +import org.greenbuttonalliance.espi.common.repositories.usage.ReadingTypeRepository; +import org.greenbuttonalliance.espi.common.repositories.usage.RetailCustomerRepository; +import org.greenbuttonalliance.espi.common.repositories.usage.TimeConfigurationRepository; import org.greenbuttonalliance.espi.common.repositories.usage.UsagePointRepository; import org.greenbuttonalliance.espi.common.service.DtoExportService; import org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Service; @@ -61,15 +74,47 @@ @Slf4j @Service @Primary -@RequiredArgsConstructor public class DtoExportServiceFacade implements DtoExportService { private final UsageExportService usageExportService; private final CustomerExportService customerExportService; private final UsagePointRepository usagePointRepository; private final UsagePointMapper usagePointMapper; + private final TimeConfigurationRepository timeConfigurationRepository; + private final TimeConfigurationMapper timeConfigurationMapper; + private final RetailCustomerRepository retailCustomerRepository; + private final CustomerMapper customerMapper; + private final ReadingTypeRepository readingTypeRepository; + private final ReadingTypeMapper readingTypeMapper; + private final DtoExportService dtoExportService; private final EspiIdGeneratorService espiIdGeneratorService; + public DtoExportServiceFacade(UsageExportService usageExportService, + CustomerExportService customerExportService, + UsagePointRepository usagePointRepository, + UsagePointMapper usagePointMapper, + TimeConfigurationRepository timeConfigurationRepository, + TimeConfigurationMapper timeConfigurationMapper, + RetailCustomerRepository retailCustomerRepository, + CustomerMapper customerMapper, + ReadingTypeRepository readingTypeRepository, + ReadingTypeMapper readingTypeMapper, + @Qualifier("dtoExportServiceImpl") DtoExportService dtoExportService, + EspiIdGeneratorService espiIdGeneratorService) { + this.usageExportService = usageExportService; + this.customerExportService = customerExportService; + this.usagePointRepository = usagePointRepository; + this.usagePointMapper = usagePointMapper; + this.timeConfigurationRepository = timeConfigurationRepository; + this.timeConfigurationMapper = timeConfigurationMapper; + this.retailCustomerRepository = retailCustomerRepository; + this.customerMapper = customerMapper; + this.readingTypeRepository = readingTypeRepository; + this.readingTypeMapper = readingTypeMapper; + this.dtoExportService = dtoExportService; + this.espiIdGeneratorService = espiIdGeneratorService; + } + @Override public void exportUsagePointEntry(UUID usagePointId, OutputStream stream) { Optional entity = usagePointRepository.findById(usagePointId); @@ -155,6 +200,137 @@ public void exportDto(Object dto, OutputStream stream) { } } + @Override + public AtomFeedDto createUsagePointsFeed(List usagePoints) { + List entries = new ArrayList<>(); + + // Convert each entity to DTO and create entry + for (UsagePointEntity entity : usagePoints) { + UsagePointDto dto = usagePointMapper.toDto(entity); + AtomEntryDto entry = createAtomEntry("Usage Point " + entity.getId(), dto); + entries.add(entry); + } + + // Create feed + return createAtomFeed("Usage Points", entries); + } + + @Override + public AtomFeedDto createUsagePointsFeedByIds(List usagePointIds) { + List entities = new ArrayList<>(); + for (UUID id : usagePointIds) { + usagePointRepository.findById(id).ifPresent(entities::add); + } + return createUsagePointsFeed(entities); + } + + @Override + public AtomFeedDto createTimeConfigurationsFeed() { + List entities = timeConfigurationRepository.findAll(); + List entries = entities.stream() + .map(this::createTimeConfigurationEntry) + .toList(); + return createAtomFeed("Time Configurations", entries); + } + + @Override + public AtomFeedDto createTimeConfigurationsFeedByUsagePointId(UUID usagePointId) { + List ids = timeConfigurationRepository.findAllIdsByUsagePointId(usagePointId); + List entries = ids.stream() + .map(this::createTimeConfigurationEntry) + .filter(java.util.Objects::nonNull) + .toList(); + return createAtomFeed("Time Configurations for Usage Point " + usagePointId, entries); + } + + @Override + public AtomEntryDto createTimeConfigurationEntry(UUID timeConfigurationId) { + return timeConfigurationRepository.findById(timeConfigurationId) + .map(this::createTimeConfigurationEntry) + .orElse(null); + } + + @Override + public AtomEntryDto createTimeConfigurationEntry(TimeConfigurationEntity timeConfiguration) { + TimeConfigurationDto dto = timeConfigurationMapper.toDto(timeConfiguration); + return createAtomEntry("Time Configuration " + timeConfiguration.getId(), dto); + } + + @Override + public AtomFeedDto createRetailCustomersFeed() { + List entities = retailCustomerRepository.findAll(); + List entries = entities.stream() + .map(this::createRetailCustomerEntry) + .toList(); + return createAtomFeed("Retail Customers", entries); + } + + @Override + public AtomEntryDto createRetailCustomerEntry(Long retailCustomerId) { + return retailCustomerRepository.findById(retailCustomerId) + .map(this::createRetailCustomerEntry) + .orElse(null); + } + + @Override + public AtomEntryDto createRetailCustomerEntry(RetailCustomerEntity retailCustomer) { + CustomerDto dto = customerMapper.toDto(retailCustomer); + return createAtomEntry("RetailCustomer: " + retailCustomer.getId(), dto); + } + + @Override + public AtomEntryDto createServiceStatusEntry(String currentStatus) { + return usageExportService.createServiceStatusEntry(currentStatus); + } + + @Override + public AtomFeedDto createUsageSummariesFeed() { + return dtoExportService.createUsageSummariesFeed(); + } + + @Override + public AtomFeedDto createUsageSummariesFeedByUsagePointId(UUID usagePointId) { + return dtoExportService.createUsageSummariesFeedByUsagePointId(usagePointId); + } + + @Override + public AtomEntryDto createUsageSummaryEntry(UUID usageSummaryId) { + return dtoExportService.createUsageSummaryEntry(usageSummaryId); + } + + @Override + public AtomEntryDto createUsageSummaryEntry(UsageSummaryEntity usageSummary) { + return dtoExportService.createUsageSummaryEntry(usageSummary); + } + + @Override + public AtomFeedDto createReadingTypesFeed() { + return dtoExportService.createReadingTypesFeed(); + } + + @Override + public AtomEntryDto createReadingTypeEntry(UUID readingTypeId) { + return dtoExportService.createReadingTypeEntry(readingTypeId); + } + + @Override + public AtomEntryDto createReadingTypeEntry(ReadingTypeEntity readingType) { + return dtoExportService.createReadingTypeEntry(readingType); + } + + @Override + public AtomEntryDto createUsagePointEntry(UsagePointEntity usagePoint) { + UsagePointDto dto = usagePointMapper.toDto(usagePoint); + return createAtomEntry("Usage Point " + usagePoint.getId(), dto); + } + + @Override + public AtomEntryDto createUsagePointEntry(UUID usagePointId) { + return usagePointRepository.findById(usagePointId) + .map(this::createUsagePointEntry) + .orElse(null); + } + @Override public void exportAtomFeed(AtomFeedDto atomFeedDto, OutputStream stream) { String domain = detectDomain(atomFeedDto); diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/DtoExportServiceImpl.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/DtoExportServiceImpl.java index d7838192..30d598ee 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/DtoExportServiceImpl.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/DtoExportServiceImpl.java @@ -24,14 +24,31 @@ import jakarta.xml.bind.Marshaller; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.greenbuttonalliance.espi.common.domain.usage.ReadingTypeEntity; +import org.greenbuttonalliance.espi.common.domain.usage.RetailCustomerEntity; import org.greenbuttonalliance.espi.common.domain.usage.UsagePointEntity; +import org.greenbuttonalliance.espi.common.domain.usage.UsageSummaryEntity; import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto; import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto; import org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto; import org.greenbuttonalliance.espi.common.dto.atom.CustomerAtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.customer.CustomerDto; +import org.greenbuttonalliance.espi.common.dto.usage.ReadingTypeDto; +import org.greenbuttonalliance.espi.common.dto.usage.ServiceStatusDto; +import org.greenbuttonalliance.espi.common.dto.usage.TimeConfigurationDto; import org.greenbuttonalliance.espi.common.dto.usage.UsagePointDto; +import org.greenbuttonalliance.espi.common.dto.usage.UsageSummaryDto; +import org.greenbuttonalliance.espi.common.mapper.customer.CustomerMapper; +import org.greenbuttonalliance.espi.common.mapper.usage.ReadingTypeMapper; +import org.greenbuttonalliance.espi.common.mapper.usage.TimeConfigurationMapper; import org.greenbuttonalliance.espi.common.mapper.usage.UsagePointMapper; +import org.greenbuttonalliance.espi.common.mapper.usage.UsageSummaryMapper; +import org.greenbuttonalliance.espi.common.repositories.usage.ReadingTypeRepository; +import org.greenbuttonalliance.espi.common.repositories.usage.RetailCustomerRepository; +import org.greenbuttonalliance.espi.common.repositories.usage.TimeConfigurationRepository; import org.greenbuttonalliance.espi.common.repositories.usage.UsagePointRepository; +import org.greenbuttonalliance.espi.common.repositories.usage.UsageSummaryRepository; +import org.greenbuttonalliance.espi.common.domain.usage.TimeConfigurationEntity; import org.greenbuttonalliance.espi.common.service.DtoExportService; import org.springframework.stereotype.Service; @@ -51,6 +68,14 @@ public class DtoExportServiceImpl implements DtoExportService { private final UsagePointRepository usagePointRepository; private final UsagePointMapper usagePointMapper; + private final TimeConfigurationRepository timeConfigurationRepository; + private final TimeConfigurationMapper timeConfigurationMapper; + private final RetailCustomerRepository retailCustomerRepository; + private final CustomerMapper customerMapper; + private final UsageSummaryRepository usageSummaryRepository; + private final UsageSummaryMapper usageSummaryMapper; + private final ReadingTypeRepository readingTypeRepository; + private final ReadingTypeMapper readingTypeMapper; private final org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService espiIdGeneratorService; private final String XML_HEADER = """ @@ -60,61 +85,184 @@ public class DtoExportServiceImpl implements DtoExportService { @Override public void exportUsagePointEntry(UUID usagePointId, OutputStream stream) { - Optional entity = usagePointRepository.findById(usagePointId); - if (entity.isPresent()) { - exportUsagePointEntry(entity.get(), stream); + AtomEntryDto entry = createUsagePointEntry(usagePointId); + if (entry != null) { + exportDto(entry, stream); } else { log.warn("Usage point not found: " + usagePointId); } } + @Override + public AtomEntryDto createUsagePointEntry(UUID usagePointId) { + return usagePointRepository.findById(usagePointId) + .map(this::createUsagePointEntry) + .orElse(null); + } + + @Override + public AtomFeedDto createTimeConfigurationsFeed() { + List entities = timeConfigurationRepository.findAll(); + List entries = entities.stream() + .map(this::createTimeConfigurationEntry) + .toList(); + return createAtomFeed("Time Configurations", entries); + } + + @Override + public AtomFeedDto createTimeConfigurationsFeedByUsagePointId(UUID usagePointId) { + List ids = timeConfigurationRepository.findAllIdsByUsagePointId(usagePointId); + List entries = ids.stream() + .map(this::createTimeConfigurationEntry) + .filter(Objects::nonNull) + .toList(); + return createAtomFeed("Time Configurations for Usage Point " + usagePointId, entries); + } + + @Override + public AtomEntryDto createTimeConfigurationEntry(UUID timeConfigurationId) { + return timeConfigurationRepository.findById(timeConfigurationId) + .map(this::createTimeConfigurationEntry) + .orElse(null); + } + + @Override + public AtomEntryDto createTimeConfigurationEntry(TimeConfigurationEntity timeConfiguration) { + TimeConfigurationDto dto = timeConfigurationMapper.toDto(timeConfiguration); + return createAtomEntry("Time Configuration " + timeConfiguration.getId(), dto); + } + + @Override + public AtomFeedDto createRetailCustomersFeed() { + List entities = retailCustomerRepository.findAll(); + List entries = entities.stream() + .map(this::createRetailCustomerEntry) + .toList(); + return createAtomFeed("Retail Customers", entries); + } + + @Override + public AtomEntryDto createRetailCustomerEntry(Long retailCustomerId) { + return retailCustomerRepository.findById(retailCustomerId) + .map(this::createRetailCustomerEntry) + .orElse(null); + } + + @Override + public AtomEntryDto createRetailCustomerEntry(RetailCustomerEntity retailCustomer) { + CustomerDto dto = customerMapper.toDto(retailCustomer); + String id = retailCustomer.getId().toString(); + return createAtomEntry("RetailCustomer: " + id, dto); + } + + @Override + public AtomEntryDto createServiceStatusEntry(String currentStatus) { + ServiceStatusDto serviceStatus = ServiceStatusDto.builder() + .currentStatus(currentStatus) + .build(); + return createAtomEntry("ServiceStatus", serviceStatus); + } + + @Override + public AtomFeedDto createUsageSummariesFeed() { + List entities = usageSummaryRepository.findAll(); + List entries = entities.stream() + .map(this::createUsageSummaryEntry) + .toList(); + return createAtomFeed("Usage Summaries", entries); + } + + @Override + public AtomFeedDto createUsageSummariesFeedByUsagePointId(UUID usagePointId) { + List ids = usageSummaryRepository.findAllIdsByUsagePointId(usagePointId); + List entries = ids.stream() + .map(this::createUsageSummaryEntry) + .filter(Objects::nonNull) + .toList(); + return createAtomFeed("Usage Summaries for Usage Point " + usagePointId, entries); + } + + @Override + public AtomEntryDto createUsageSummaryEntry(UUID usageSummaryId) { + return usageSummaryRepository.findById(usageSummaryId) + .map(this::createUsageSummaryEntry) + .orElse(null); + } + + @Override + public AtomEntryDto createUsageSummaryEntry(UsageSummaryEntity usageSummary) { + UsageSummaryDto dto = usageSummaryMapper.toDto(usageSummary); + return createAtomEntry("Usage Summary " + usageSummary.getId(), dto); + } + + @Override + public AtomFeedDto createReadingTypesFeed() { + List entities = readingTypeRepository.findAll(); + List entries = entities.stream() + .map(this::createReadingTypeEntry) + .toList(); + return createAtomFeed("Reading Types", entries); + } + + @Override + public AtomEntryDto createReadingTypeEntry(UUID readingTypeId) { + return readingTypeRepository.findById(readingTypeId) + .map(this::createReadingTypeEntry) + .orElse(null); + } + + @Override + public AtomEntryDto createReadingTypeEntry(ReadingTypeEntity readingType) { + ReadingTypeDto dto = readingTypeMapper.toDto(readingType); + return createAtomEntry("Reading Type " + readingType.getId(), dto); + } + @Override public void exportUsagePointsFeedByIds(List usagePointIds, OutputStream stream) { + AtomFeedDto feed = createUsagePointsFeedByIds(usagePointIds); + exportDto(feed, stream); + } + + @Override + public AtomFeedDto createUsagePointsFeedByIds(List usagePointIds) { List entities = new ArrayList<>(); for (UUID id : usagePointIds) { usagePointRepository.findById(id).ifPresent(entities::add); } - exportUsagePointsFeed(entities, stream); + return createUsagePointsFeed(entities); } @Override public void exportUsagePointEntry(UsagePointEntity usagePoint, OutputStream stream) { - try { - // Convert entity to DTO - UsagePointDto dto = usagePointMapper.toDto(usagePoint); - - // Create Atom entry - AtomEntryDto entry = createAtomEntry("Usage Point " + usagePoint.getId(), dto); - - // Export as XML - exportDto(entry, stream); - - } catch (Exception e) { - log.error("Failed to export usage point entry: " + e.getMessage(), e); - } + AtomEntryDto entry = createUsagePointEntry(usagePoint); + exportDto(entry, stream); + } + + @Override + public AtomEntryDto createUsagePointEntry(UsagePointEntity usagePoint) { + UsagePointDto dto = usagePointMapper.toDto(usagePoint); + return createAtomEntry("Usage Point " + usagePoint.getId(), dto); } @Override public void exportUsagePointsFeed(List usagePoints, OutputStream stream) { - try { - List entries = new ArrayList<>(); - - // Convert each entity to DTO and create entry - for (UsagePointEntity entity : usagePoints) { - UsagePointDto dto = usagePointMapper.toDto(entity); - AtomEntryDto entry = createAtomEntry("Usage Point " + entity.getId(), dto); - entries.add(entry); - } - - // Create feed - AtomFeedDto feed = createAtomFeed("Usage Points", entries); - - // Export as XML - exportDto(feed, stream); - - } catch (Exception e) { - log.error("Failed to export usage points feed: " + e.getMessage(), e); + AtomFeedDto feed = createUsagePointsFeed(usagePoints); + exportDto(feed, stream); + } + + @Override + public AtomFeedDto createUsagePointsFeed(List usagePoints) { + List entries = new ArrayList<>(); + + // Convert each entity to DTO and create entry + for (UsagePointEntity entity : usagePoints) { + UsagePointDto dto = usagePointMapper.toDto(entity); + AtomEntryDto entry = createAtomEntry("Usage Point " + entity.getId(), dto); + entries.add(entry); } + + // Create feed + return createAtomFeed("Usage Points", entries); } @Override diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ReadingTypeServiceImpl.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ReadingTypeServiceImpl.java index 7346a44e..d945fb84 100755 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ReadingTypeServiceImpl.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ReadingTypeServiceImpl.java @@ -31,6 +31,7 @@ import org.springframework.transaction.annotation.Transactional; import java.io.InputStream; +import java.util.List; import java.util.UUID; @Slf4j @@ -54,6 +55,11 @@ public ReadingTypeEntity findById(UUID readingTypeId) { return readingTypeRepository.findById(readingTypeId).orElse(null); } + @Override + public List findAll() { + return readingTypeRepository.findAll(); + } + @Override public ReadingTypeEntity save(ReadingTypeEntity readingType) { return readingTypeRepository.save(readingType); diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/UsageExportService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/UsageExportService.java index 256789e8..c05f4dad 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/UsageExportService.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/UsageExportService.java @@ -92,6 +92,7 @@ protected JAXBContext createJAXBContext() throws JAXBException { org.greenbuttonalliance.espi.common.dto.usage.AuthorizationDto.class, org.greenbuttonalliance.espi.common.dto.usage.SubscriptionDto.class, org.greenbuttonalliance.espi.common.dto.usage.BatchListDto.class, + org.greenbuttonalliance.espi.common.dto.usage.ServiceStatusDto.class, org.greenbuttonalliance.espi.common.dto.usage.LineItemDto.class, org.greenbuttonalliance.espi.common.dto.usage.ServiceDeliveryPointDto.class, org.greenbuttonalliance.espi.common.dto.usage.ReadingQualityDto.class, diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAccountDtoTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAccountDtoTest.java index f5de072c..95a92862 100644 --- a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAccountDtoTest.java +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAccountDtoTest.java @@ -49,7 +49,7 @@ class CustomerAccountDtoTest { void setUp() { org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService espiIdGeneratorService = new org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService(); - dtoExportService = new DtoExportServiceImpl(null, null, espiIdGeneratorService); + dtoExportService = new DtoExportServiceImpl(null, null, null, null, null, null, null, null, null, null, espiIdGeneratorService); } @Test diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAgreementDtoTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAgreementDtoTest.java index 77d130c5..f57d5720 100644 --- a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAgreementDtoTest.java +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAgreementDtoTest.java @@ -50,7 +50,7 @@ class CustomerAgreementDtoTest { void setUp() { org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService espiIdGeneratorService = new org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService(); - dtoExportService = new DtoExportServiceImpl(null, null, espiIdGeneratorService); + dtoExportService = new DtoExportServiceImpl(null, null, null, null, null, null, null, null, null, null, espiIdGeneratorService); } @Test diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerDtoTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerDtoTest.java index 05d1a26d..883fa0f8 100644 --- a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerDtoTest.java +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerDtoTest.java @@ -51,10 +51,9 @@ class CustomerDtoTest { @BeforeEach void setUp() { - // Initialize DtoExportService with null repository/mapper (not needed for DTO-only tests) org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService espiIdGeneratorService = - new org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService(); - dtoExportService = new DtoExportServiceImpl(null, null, espiIdGeneratorService); + new org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService(); + dtoExportService = new DtoExportServiceImpl(null, null, null, null, null, null, null, null, null, null, espiIdGeneratorService); } @Test diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/EndDeviceDtoTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/EndDeviceDtoTest.java index 1cc21918..928e0194 100644 --- a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/EndDeviceDtoTest.java +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/EndDeviceDtoTest.java @@ -51,10 +51,9 @@ class EndDeviceDtoTest { @BeforeEach void setUp() { - // Initialize DtoExportService with null repository/mapper (not needed for DTO-only tests) org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService espiIdGeneratorService = - new org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService(); - dtoExportService = new DtoExportServiceImpl(null, null, espiIdGeneratorService); + new org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService(); + dtoExportService = new DtoExportServiceImpl(null, null, null, null, null, null, null, null, null, null, espiIdGeneratorService); } @Test diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/MeterDtoTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/MeterDtoTest.java index 32b78b97..976f70c0 100644 --- a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/MeterDtoTest.java +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/MeterDtoTest.java @@ -51,10 +51,9 @@ class MeterDtoTest { @BeforeEach void setUp() { - // Initialize DtoExportService with null repository/mapper (not needed for DTO-only tests) org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService espiIdGeneratorService = - new org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService(); - dtoExportService = new DtoExportServiceImpl(null, null, espiIdGeneratorService); + new org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService(); + dtoExportService = new DtoExportServiceImpl(null, null, null, null, null, null, null, null, null, null, espiIdGeneratorService); } @Test diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/ServiceLocationDtoTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/ServiceLocationDtoTest.java index 6d20e112..97b36a86 100644 --- a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/ServiceLocationDtoTest.java +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/ServiceLocationDtoTest.java @@ -50,10 +50,9 @@ class ServiceLocationDtoTest { @BeforeEach void setUp() { - // Initialize DtoExportService with null repository/mapper (not needed for DTO-only tests) org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService espiIdGeneratorService = - new org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService(); - dtoExportService = new DtoExportServiceImpl(null, null, espiIdGeneratorService); + new org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService(); + dtoExportService = new DtoExportServiceImpl(null, null, null, null, null, null, null, null, null, null, espiIdGeneratorService); } @Test diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/ServiceSupplierDtoTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/ServiceSupplierDtoTest.java index 591175b4..89fa2704 100644 --- a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/ServiceSupplierDtoTest.java +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/ServiceSupplierDtoTest.java @@ -50,10 +50,9 @@ class ServiceSupplierDtoTest { @BeforeEach void setUp() { - // Initialize DtoExportService with null repository/mapper (not needed for DTO-only tests) org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService espiIdGeneratorService = - new org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService(); - dtoExportService = new DtoExportServiceImpl(null, null, espiIdGeneratorService); + new org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService(); + dtoExportService = new DtoExportServiceImpl(null, null, null, null, null, null, null, null, null, null, espiIdGeneratorService); } @Test diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationController.java new file mode 100644 index 00000000..62c5c59e --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationController.java @@ -0,0 +1,213 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.greenbuttonalliance.espi.common.domain.usage.ApplicationInformationEntity; +import org.greenbuttonalliance.espi.common.dto.usage.ApplicationInformationDto; +import org.greenbuttonalliance.espi.common.mapper.usage.ApplicationInformationMapper; +import org.greenbuttonalliance.espi.common.repositories.usage.ApplicationInformationRepository; +import org.greenbuttonalliance.espi.datacustodian.web.api.support.ApiRequestValidator; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +/** + * Modern REST Controller for ESPI Application Information resources. + * + * This controller implements the NAESB ESPI REST API for Application Information, + * using modern Spring Boot 4.0 patterns with DTOs and MapStruct mappers. + * + * Supported endpoints: + * - GET /espi/1_1/resource/ApplicationInformation - List all application registrations + * - GET /espi/1_1/resource/ApplicationInformation/{id} - Get specific application registration + * - POST /espi/1_1/resource/ApplicationInformation - Create new application registration + * - PUT /espi/1_1/resource/ApplicationInformation/{id} - Update application registration + * - DELETE /espi/1_1/resource/ApplicationInformation/{id} - Delete application registration + */ +@RestController +@RequestMapping("/espi/1_1/resource") +@Tag(name = "Application Information", description = "OAuth2 Application Registration and Management API") +@SecurityRequirement(name = "oauth2") +public class ApplicationInformationController { + + private final ApplicationInformationRepository applicationInformationRepository; + private final ApplicationInformationMapper applicationInformationMapper; + private final ApiRequestValidator requestValidator; + + public ApplicationInformationController(ApplicationInformationRepository applicationInformationRepository, + ApplicationInformationMapper applicationInformationMapper, + ApiRequestValidator requestValidator) { + this.applicationInformationRepository = applicationInformationRepository; + this.applicationInformationMapper = applicationInformationMapper; + this.requestValidator = requestValidator; + } + + /** + * Get all Application Information (root collection). + */ + @GetMapping(value = "/ApplicationInformation", produces = {MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get ApplicationInformation Collection", + description = "Retrieves all authorized ApplicationInformation resources with optional filtering and pagination", + responses = { + @ApiResponse(responseCode = "200", description = "Application Information list retrieved successfully", + content = @Content(schema = @Schema(implementation = ApplicationInformationDto.class))), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope") + } + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access')") + public ResponseEntity> getAllApplicationInformation( + @Parameter(description = "Maximum number of results to return", example = "50") + @RequestParam(defaultValue = "50") int limit, + @Parameter(description = "Offset for pagination", example = "0") + @RequestParam(defaultValue = "0") int offset) { + + Pageable pageable = requestValidator.toPageable(limit, offset); + List entities = applicationInformationRepository.findAll(pageable).getContent(); + + List dtos = entities.stream() + .map(applicationInformationMapper::toDto) + .toList(); + + return ResponseEntity.ok(dtos); + } + + /** + * Get specific Application Information by ID. + */ + @GetMapping(value = "/ApplicationInformation/{applicationInformationId}", produces = {MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get Application Information by ID", + description = "Retrieve a specific Application Information by its unique identifier", + responses = { + @ApiResponse(responseCode = "200", description = "Application Information retrieved successfully", + content = @Content(schema = @Schema(implementation = ApplicationInformationDto.class))), + @ApiResponse(responseCode = "404", description = "Application Information not found"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope") + } + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access')") + public ResponseEntity getApplicationInformation( + @Parameter(description = "Unique identifier of the Application Information", required = true) + @PathVariable UUID applicationInformationId) { + + return applicationInformationRepository.findById(applicationInformationId) + .map(applicationInformationMapper::toDto) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + /** + * Create a new Application Information resource. + */ + @PostMapping(value = "/ApplicationInformation", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Create Application Information", + description = "Creates a new Application Information registration", + responses = { + @ApiResponse(responseCode = "201", description = "Application Information created successfully"), + @ApiResponse(responseCode = "400", description = "Invalid Application Information data"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope") + } + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access')") + public ResponseEntity createApplicationInformation( + @RequestBody ApplicationInformationDto applicationInformationDto) { + + ApplicationInformationEntity entity = applicationInformationMapper.toEntity(applicationInformationDto); + ApplicationInformationEntity savedEntity = applicationInformationRepository.save(entity); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(applicationInformationMapper.toDto(savedEntity)); + } + + /** + * Update an existing Application Information resource. + */ + @PutMapping(value = "/ApplicationInformation/{applicationInformationId}", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Update Application Information", + description = "Updates an existing Application Information registration", + responses = { + @ApiResponse(responseCode = "200", description = "Application Information updated successfully"), + @ApiResponse(responseCode = "404", description = "Application Information not found"), + @ApiResponse(responseCode = "400", description = "Invalid Application Information data"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope") + } + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access')") + public ResponseEntity updateApplicationInformation( + @PathVariable UUID applicationInformationId, + @RequestBody ApplicationInformationDto applicationInformationDto) { + + if (!applicationInformationRepository.existsById(applicationInformationId)) { + return ResponseEntity.notFound().build(); + } + + ApplicationInformationEntity entity = applicationInformationMapper.toEntity(applicationInformationDto); + entity.setId(applicationInformationId); + ApplicationInformationEntity updatedEntity = applicationInformationRepository.save(entity); + + return ResponseEntity.ok(applicationInformationMapper.toDto(updatedEntity)); + } + + /** + * Delete an Application Information resource. + */ + @DeleteMapping("/ApplicationInformation/{applicationInformationId}") + @Operation( + summary = "Delete Application Information", + description = "Deletes an existing Application Information registration", + responses = { + @ApiResponse(responseCode = "200", description = "Application Information deleted successfully"), + @ApiResponse(responseCode = "404", description = "Application Information not found"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope") + } + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access')") + public ResponseEntity deleteApplicationInformation( + @PathVariable UUID applicationInformationId) { + + if (!applicationInformationRepository.existsById(applicationInformationId)) { + return ResponseEntity.notFound().build(); + } + + applicationInformationRepository.deleteById(applicationInformationId); + return ResponseEntity.ok().build(); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationController.java index 54a2b696..40c5d536 100644 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationController.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationController.java @@ -30,12 +30,11 @@ import org.greenbuttonalliance.espi.common.repositories.usage.AuthorizationRepository; import org.greenbuttonalliance.espi.common.mapper.usage.AuthorizationMapper; import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity; -import org.springframework.data.domain.PageRequest; +import org.greenbuttonalliance.espi.datacustodian.web.api.support.ApiRequestValidator; import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -59,10 +58,14 @@ public class AuthorizationController { private final AuthorizationRepository authorizationRepository; private final AuthorizationMapper authorizationMapper; + private final ApiRequestValidator requestValidator; - public AuthorizationController(AuthorizationRepository authorizationRepository, AuthorizationMapper authorizationMapper) { + public AuthorizationController(AuthorizationRepository authorizationRepository, + AuthorizationMapper authorizationMapper, + ApiRequestValidator requestValidator) { this.authorizationRepository = authorizationRepository; this.authorizationMapper = authorizationMapper; + this.requestValidator = requestValidator; } /** @@ -85,10 +88,9 @@ public ResponseEntity> getAllAuthorizations( @Parameter(description = "Maximum number of results to return", example = "50") @RequestParam(defaultValue = "50") int limit, @Parameter(description = "Offset for pagination", example = "0") - @RequestParam(defaultValue = "0") int offset, - Authentication authentication) { + @RequestParam(defaultValue = "0") int offset) { - Pageable pageable = PageRequest.of(offset / limit, limit); + Pageable pageable = requestValidator.toPageable(limit, offset); List authorizationEntities = authorizationRepository.findAll(pageable).getContent(); List authorizations = authorizationEntities.stream() .map(authorizationMapper::toDto) @@ -115,12 +117,11 @@ public ResponseEntity> getAllAuthorizations( @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access')") public ResponseEntity getAuthorization( @Parameter(description = "Unique identifier of the Authorization", required = true) - @PathVariable UUID authorizationId, - Authentication authentication) { + @PathVariable UUID authorizationId) { return authorizationRepository.findById(authorizationId) .map(authorizationMapper::toDto) .map(authorization -> ResponseEntity.ok(authorization)) .orElse(ResponseEntity.notFound().build()); } -} \ No newline at end of file +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/BatchController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/BatchController.java new file mode 100644 index 00000000..70670379 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/BatchController.java @@ -0,0 +1,262 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.greenbuttonalliance.espi.common.domain.usage.RetailCustomerEntity; +import org.greenbuttonalliance.espi.common.domain.usage.SubscriptionEntity; +import org.greenbuttonalliance.espi.common.domain.usage.UsagePointEntity; +import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto; +import org.greenbuttonalliance.espi.common.service.DtoExportService; +import org.greenbuttonalliance.espi.common.service.RetailCustomerService; +import org.greenbuttonalliance.espi.common.service.SubscriptionService; +import org.greenbuttonalliance.espi.common.service.UsagePointService; +import org.greenbuttonalliance.espi.datacustodian.web.api.support.ApiAccessValidator; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +/** + * Modern REST Controller for ESPI Batch operations. + * + * This controller implements the NAESB ESPI 1.0 REST API for Batch operations, + * replacing the legacy BatchRESTController with modern Spring Boot 3.5 patterns. + */ +@RestController +@RequestMapping("/espi/1_1/resource/Batch") +@Tag(name = "Batch Operations", description = "Green Button Bulk Data Processing API") +@SecurityRequirement(name = "oauth2") +public class BatchController { + + private final RetailCustomerService retailCustomerService; + private final UsagePointService usagePointService; + private final SubscriptionService subscriptionService; + private final DtoExportService exportService; + private final ApiAccessValidator accessValidator; + + public BatchController(RetailCustomerService retailCustomerService, + UsagePointService usagePointService, + SubscriptionService subscriptionService, + DtoExportService exportService, + ApiAccessValidator accessValidator) { + this.retailCustomerService = retailCustomerService; + this.usagePointService = usagePointService; + this.subscriptionService = subscriptionService; + this.exportService = exportService; + this.accessValidator = accessValidator; + } + + /** + * Bulk Upload Green Button Data. + */ + @PostMapping(value = "/RetailCustomer/{retailCustomerId}/UsagePoint", + consumes = MediaType.APPLICATION_ATOM_XML_VALUE, + produces = MediaType.APPLICATION_ATOM_XML_VALUE) + @Operation( + summary = "Bulk Upload Green Button Data", + description = "Uploads Green Button DMD files for batch processing." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "Bulk upload successful"), + @ApiResponse(responseCode = "400", description = "Invalid ATOM XML or batch data"), + @ApiResponse(responseCode = "501", description = "Not Implemented - ImportService missing") + }) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access')") + public ResponseEntity upload() { + // Legacy implementation was empty/incomplete due to missing ImportService + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } + + /** + * Download Green Button Data Collection for a Retail Customer. + */ + @GetMapping(value = "/RetailCustomer/{retailCustomerId}/UsagePoint", + produces = MediaType.APPLICATION_ATOM_XML_VALUE) + @Operation( + summary = "Download Green Button Data Collection", + description = "Downloads all usage points for a retail customer as Green Button DMD file" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "DMD file generated successfully"), + @ApiResponse(responseCode = "401", description = "Unauthorized access"), + @ApiResponse(responseCode = "404", description = "Retail customer not found") + }) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or hasAuthority('SCOPE_FB_15_READ_3rd_party')") + public ResponseEntity downloadCollection(@Parameter(description = "Retail customer identifier", required = true) + @PathVariable Long retailCustomerId, + @RequestHeader(value = "Authorization", required = false) String authHeader, + Authentication authentication) { + accessValidator.enforceRetailCustomerAccess(authentication, authHeader, retailCustomerId); + + RetailCustomerEntity retailCustomer = retailCustomerService.findById(retailCustomerId); + if (retailCustomer == null) { + return ResponseEntity.notFound().build(); + } + + List usagePoints = usagePointService.findAllByRetailCustomer(retailCustomer); + AtomFeedDto feed = exportService.createUsagePointsFeed(usagePoints); + + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=GreenButtonDownload.xml") + .body(feed); + } + + /** + * Download Green Button Data Member (Specific Usage Point) for a Retail Customer. + */ + @GetMapping(value = "/RetailCustomer/{retailCustomerId}/UsagePoint/{usagePointId}", + produces = MediaType.APPLICATION_ATOM_XML_VALUE) + @Operation( + summary = "Download Green Button Data Member", + description = "Downloads specific usage point data for a retail customer as Green Button DMD file" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "DMD file generated successfully"), + @ApiResponse(responseCode = "404", description = "Usage point not found") + }) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or hasAuthority('SCOPE_FB_15_READ_3rd_party')") + public ResponseEntity downloadMember(@Parameter(description = "Retail customer identifier", required = true) + @PathVariable Long retailCustomerId, + @Parameter(description = "Usage point identifier", required = true) + @PathVariable UUID usagePointId, + @RequestHeader(value = "Authorization", required = false) String authHeader, + Authentication authentication) { + accessValidator.enforceRetailCustomerAccess(authentication, authHeader, retailCustomerId); + + UsagePointEntity usagePoint = usagePointService.findById(retailCustomerId, usagePointId); + if (usagePoint == null) { + return ResponseEntity.notFound().build(); + } + + AtomEntryDto entry = exportService.createUsagePointEntry(usagePoint); + + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=GreenButtonDownload.xml") + .body(entry); + } + + /** + * Download Subscription Data. + */ + @Transactional(readOnly = true) + @GetMapping(value = "/Subscription/{subscriptionId}", + produces = MediaType.APPLICATION_ATOM_XML_VALUE) + @Operation( + summary = "Download Subscription Data", + description = "Downloads usage points associated with a subscription as Green Button feed" + ) + @PreAuthorize("hasAuthority('SCOPE_FB_15_READ_3rd_party')") + public ResponseEntity subscription(@Parameter(description = "Subscription identifier", required = true) + @PathVariable UUID subscriptionId, + @RequestHeader(value = "Authorization", required = false) String authHeader, + Authentication authentication) { + accessValidator.enforceSubscriptionPathAccess(authentication, authHeader, subscriptionId); + + SubscriptionEntity subscription = subscriptionService.findById(subscriptionId); + if (subscription == null) { + return ResponseEntity.notFound().build(); + } + + List usagePointIds = subscriptionService.findUsagePointIds(subscriptionId); + AtomFeedDto feed = exportService.createUsagePointsFeedByIds(usagePointIds); + + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=GreenButtonDownload.xml") + .body(feed); + } + + /** + * Download Subscription Usage Points. + */ + @Transactional(readOnly = true) + @GetMapping(value = "/Subscription/{subscriptionId}/UsagePoint", + produces = MediaType.APPLICATION_ATOM_XML_VALUE) + @Operation( + summary = "Download Subscription Usage Points", + description = "Downloads all usage points for a specific subscription as Green Button feed" + ) + @PreAuthorize("hasAuthority('SCOPE_FB_15_READ_3rd_party')") + public ResponseEntity subscriptionUsagePoint(@Parameter(description = "Subscription identifier", required = true) + @PathVariable UUID subscriptionId, + @RequestHeader(value = "Authorization", required = false) String authHeader, + Authentication authentication) { + return subscription(subscriptionId, authHeader, authentication); + } + + /** + * Download Specific Subscription Usage Point. + */ + @Transactional(readOnly = true) + @GetMapping(value = "/Subscription/{subscriptionId}/UsagePoint/{usagePointId}", + produces = MediaType.APPLICATION_ATOM_XML_VALUE) + @Operation( + summary = "Download Specific Subscription Usage Point", + description = "Downloads a specific usage point for a subscription as Green Button feed" + ) + @PreAuthorize("hasAuthority('SCOPE_FB_15_READ_3rd_party')") + public ResponseEntity subscriptionUsagePointMember(@Parameter(description = "Subscription identifier", required = true) + @PathVariable UUID subscriptionId, + @Parameter(description = "Usage point identifier", required = true) + @PathVariable UUID usagePointId, + @RequestHeader(value = "Authorization", required = false) String authHeader, + Authentication authentication) { + accessValidator.enforceSubscriptionPathAccess(authentication, authHeader, subscriptionId); + accessValidator.enforceUsagePointInSubscription(subscriptionId, usagePointId); + + SubscriptionEntity subscription = subscriptionService.findById(subscriptionId); + if (subscription == null) { + return ResponseEntity.notFound().build(); + } + + AtomEntryDto entry = exportService.createUsagePointEntry(usagePointId); + + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=GreenButtonDownload.xml") + .body(entry); + } + + /** + * Bulk Data Delivery. + */ + @GetMapping(value = "/Bulk/{bulkId}", + produces = MediaType.APPLICATION_ATOM_XML_VALUE) + @Operation( + summary = "Bulk Data Delivery", + description = "Provides bulk delivery of information for third-party applications." + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access')") + public ResponseEntity bulk() { + // Legacy implementation was heavily dependent on SFTP and caching which aren't fully migrated. + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountController.java new file mode 100644 index 00000000..9c96b690 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountController.java @@ -0,0 +1,136 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.greenbuttonalliance.espi.common.domain.customer.entity.CustomerAccountEntity; +import org.greenbuttonalliance.espi.common.dto.customer.CustomerAccountDto; +import org.greenbuttonalliance.espi.common.mapper.customer.CustomerAccountMapper; +import org.greenbuttonalliance.espi.common.service.customer.CustomerAccountService; +import org.greenbuttonalliance.espi.datacustodian.web.api.support.ApiRequestValidator; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +/** + * Modern REST Controller for ESPI Customer Account resources. + * + * This controller implements the NAESB ESPI 1.0 REST API for Customer Accounts, + * using modern Spring Boot 4.0 patterns with DTOs and MapStruct mappers. + * + * Supported endpoints: + * - GET /espi/1_1/resource/CustomerAccount - List all authorized customer accounts + * - GET /espi/1_1/resource/CustomerAccount/{id} - Get specific customer account details + */ +@RestController +@RequestMapping("/espi/1_1/resource") +@Tag(name = "Customer Account", description = "Customer Billing Account Management API") +@SecurityRequirement(name = "oauth2") +public class CustomerAccountController { + + private final CustomerAccountService customerAccountService; + private final CustomerAccountMapper customerAccountMapper; + private final ApiRequestValidator requestValidator; + + public CustomerAccountController(CustomerAccountService customerAccountService, + CustomerAccountMapper customerAccountMapper, + ApiRequestValidator requestValidator) { + this.customerAccountService = customerAccountService; + this.customerAccountMapper = customerAccountMapper; + this.requestValidator = requestValidator; + } + + /** + * Get all Customer Accounts (root collection). + * Requires DataCustodian admin access or appropriate read scope. + */ + @GetMapping(value = "/CustomerAccount", produces = {MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get CustomerAccount Collection", + description = "Retrieves all authorized CustomerAccount resources with optional filtering and pagination", + responses = { + @ApiResponse(responseCode = "200", description = "Customer Accounts retrieved successfully", + content = @Content(schema = @Schema(implementation = CustomerAccountDto.class))), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope") + } + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " + + "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " + + "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " + + "hasAuthority('SCOPE_FB_36_READ_3rd_party')") + public ResponseEntity> getAllCustomerAccounts( + @Parameter(description = "Maximum number of results to return", example = "50") + @RequestParam(defaultValue = "50") int limit, + @Parameter(description = "Offset for pagination", example = "0") + @RequestParam(defaultValue = "0") int offset) { + + // Note: CustomerAccountService current implementation doesn't support pagination, + // so we retrieve all and filter. For production, service should be updated. + List entities = customerAccountService.findAll(); + + List dtos = requestValidator.paginate( + entities.stream().map(customerAccountMapper::toDto).toList(), + limit, + offset + ); + + return ResponseEntity.ok(dtos); + } + + /** + * Get specific Customer Account by ID (root resource). + */ + @GetMapping(value = "/CustomerAccount/{customerAccountId}", produces = {MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get Customer Account by ID", + description = "Retrieve a specific Customer Account by its unique identifier", + responses = { + @ApiResponse(responseCode = "200", description = "Customer Account retrieved successfully", + content = @Content(schema = @Schema(implementation = CustomerAccountDto.class))), + @ApiResponse(responseCode = "404", description = "Customer Account not found"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope") + } + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " + + "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " + + "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " + + "hasAuthority('SCOPE_FB_36_READ_3rd_party')") + public ResponseEntity getCustomerAccount( + @Parameter(description = "Unique identifier of the Customer Account", required = true) + @PathVariable UUID customerAccountId) { + + return customerAccountService.findById(customerAccountId) + .map(customerAccountMapper::toDto) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerController.java new file mode 100644 index 00000000..075e5860 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerController.java @@ -0,0 +1,128 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.greenbuttonalliance.espi.common.domain.customer.entity.CustomerEntity; +import org.greenbuttonalliance.espi.common.dto.customer.CustomerDto; +import org.greenbuttonalliance.espi.common.mapper.customer.CustomerMapper; +import org.greenbuttonalliance.espi.common.service.customer.CustomerService; +import org.greenbuttonalliance.espi.datacustodian.web.api.support.ApiRequestValidator; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +/** + * Modern REST Controller for ESPI Customer resources. + * + * This controller implements the NAESB ESPI 4.0 REST API for Customers, + * using modern Spring Boot 4.0 patterns with DTOs and MapStruct mappers. + * + * Supported endpoints: + * - GET /espi/1_1/resource/Customer - List all customers + * - GET /espi/1_1/resource/Customer/{customerId} - Get specific customer + */ +@RestController +@RequestMapping("/espi/1_1/resource") +@Tag(name = "Customer", description = "ESPI Customer PII Data Management API") +@SecurityRequirement(name = "oauth2") +public class CustomerController { + + private final CustomerService customerService; + private final CustomerMapper customerMapper; + private final ApiRequestValidator requestValidator; + + public CustomerController(CustomerService customerService, + CustomerMapper customerMapper, + ApiRequestValidator requestValidator) { + this.customerService = customerService; + this.customerMapper = customerMapper; + this.requestValidator = requestValidator; + } + + /** + * Get all Customers (root collection). + * Requires DataCustodian admin access or appropriate read scope. + */ + @GetMapping(value = "/Customer", produces = {MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get Customer Collection", + description = "Retrieves all Customer resources accessible to the authenticated client", + responses = { + @ApiResponse(responseCode = "200", description = "Customers retrieved successfully", + content = @Content(schema = @Schema(implementation = CustomerDto.class))), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope") + } + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access')") + public ResponseEntity> getAllCustomers( + @Parameter(description = "Maximum number of results to return", example = "50") + @RequestParam(defaultValue = "50") int limit, + @Parameter(description = "Offset for pagination", example = "0") + @RequestParam(defaultValue = "0") int offset) { + + List entities = customerService.findAll(); + + List dtos = requestValidator.paginate( + entities.stream().map(customerMapper::toDto).toList(), + limit, + offset + ); + + return ResponseEntity.ok(dtos); + } + + /** + * Get specific Customer by ID (root resource). + */ + @GetMapping(value = "/Customer/{customerId}", produces = {MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get Customer by ID", + description = "Retrieve a specific Customer by its unique identifier", + responses = { + @ApiResponse(responseCode = "200", description = "Customer retrieved successfully", + content = @Content(schema = @Schema(implementation = CustomerDto.class))), + @ApiResponse(responseCode = "404", description = "Customer not found"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope") + } + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access')") + public ResponseEntity getCustomer( + @Parameter(description = "Unique identifier of the Customer", required = true) + @PathVariable UUID customerId) { + + return customerService.findById(customerId) + .map(customerMapper::toDto) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryController.java new file mode 100644 index 00000000..679143cc --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryController.java @@ -0,0 +1,134 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.greenbuttonalliance.espi.common.domain.usage.ElectricPowerQualitySummaryEntity; +import org.greenbuttonalliance.espi.common.dto.usage.ElectricPowerQualitySummaryDto; +import org.greenbuttonalliance.espi.common.mapper.usage.ElectricPowerQualitySummaryMapper; +import org.greenbuttonalliance.espi.common.repositories.usage.ElectricPowerQualitySummaryRepository; +import org.greenbuttonalliance.espi.datacustodian.web.api.support.ApiRequestValidator; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +/** + * Modern REST Controller for ESPI Electric Power Quality Summary resources. + * + * This controller implements the NAESB ESPI 1.0 REST API for Electric Power Quality Summaries, + * using modern Spring Boot 4.0 patterns with DTOs and MapStruct mappers. + * + * Supported endpoints: + * - GET /espi/1_1/resource/ElectricPowerQualitySummary - List all power quality summaries + * - GET /espi/1_1/resource/ElectricPowerQualitySummary/{id} - Get specific power quality summary + */ +@RestController +@RequestMapping("/espi/1_1/resource") +@Tag(name = "Electric Power Quality Summary", description = "Power Quality Measurement Data Management API") +@SecurityRequirement(name = "oauth2") +public class ElectricPowerQualitySummaryController { + + private final ElectricPowerQualitySummaryRepository electricPowerQualitySummaryRepository; + private final ElectricPowerQualitySummaryMapper electricPowerQualitySummaryMapper; + private final ApiRequestValidator requestValidator; + + public ElectricPowerQualitySummaryController(ElectricPowerQualitySummaryRepository electricPowerQualitySummaryRepository, + ElectricPowerQualitySummaryMapper electricPowerQualitySummaryMapper, + ApiRequestValidator requestValidator) { + this.electricPowerQualitySummaryRepository = electricPowerQualitySummaryRepository; + this.electricPowerQualitySummaryMapper = electricPowerQualitySummaryMapper; + this.requestValidator = requestValidator; + } + + /** + * Get all Electric Power Quality Summaries (root collection). + * Requires DataCustodian admin access or appropriate read scope. + */ + @GetMapping(value = "/ElectricPowerQualitySummary", produces = {MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get ElectricPowerQualitySummary Collection", + description = "Retrieves all authorized ElectricPowerQualitySummary resources with optional filtering and pagination", + responses = { + @ApiResponse(responseCode = "200", description = "Electric Power Quality Summaries retrieved successfully", + content = @Content(schema = @Schema(implementation = ElectricPowerQualitySummaryDto.class))), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope") + } + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " + + "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " + + "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " + + "hasAuthority('SCOPE_FB_36_READ_3rd_party')") + public ResponseEntity> getAllElectricPowerQualitySummaries( + @Parameter(description = "Maximum number of results to return", example = "50") + @RequestParam(defaultValue = "50") int limit, + @Parameter(description = "Offset for pagination", example = "0") + @RequestParam(defaultValue = "0") int offset) { + + Pageable pageable = requestValidator.toPageable(limit, offset); + List entities = electricPowerQualitySummaryRepository.findAll(pageable).getContent(); + + List dtos = entities.stream() + .map(electricPowerQualitySummaryMapper::toDto) + .toList(); + + return ResponseEntity.ok(dtos); + } + + /** + * Get specific Electric Power Quality Summary by ID (root resource). + */ + @GetMapping(value = "/ElectricPowerQualitySummary/{electricPowerQualitySummaryId}", produces = {MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get Electric Power Quality Summary by ID", + description = "Retrieve a specific Electric Power Quality Summary by its unique identifier", + responses = { + @ApiResponse(responseCode = "200", description = "Electric Power Quality Summary retrieved successfully", + content = @Content(schema = @Schema(implementation = ElectricPowerQualitySummaryDto.class))), + @ApiResponse(responseCode = "404", description = "Electric Power Quality Summary not found"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope") + } + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " + + "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " + + "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " + + "hasAuthority('SCOPE_FB_36_READ_3rd_party')") + public ResponseEntity getElectricPowerQualitySummary( + @Parameter(description = "Unique identifier of the Electric Power Quality Summary", required = true) + @PathVariable UUID electricPowerQualitySummaryId) { + + return electricPowerQualitySummaryRepository.findById(electricPowerQualitySummaryId) + .map(electricPowerQualitySummaryMapper::toDto) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockController.java new file mode 100644 index 00000000..f46a0f0a --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockController.java @@ -0,0 +1,132 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.greenbuttonalliance.espi.common.domain.usage.IntervalBlockEntity; +import org.greenbuttonalliance.espi.common.dto.usage.IntervalBlockDto; +import org.greenbuttonalliance.espi.common.mapper.usage.IntervalBlockMapper; +import org.greenbuttonalliance.espi.common.repositories.usage.IntervalBlockRepository; +import org.greenbuttonalliance.espi.datacustodian.web.api.support.ApiRequestValidator; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +/** + * Modern REST Controller for ESPI Interval Block resources. + * + * This controller implements the NAESB ESPI 1.0 REST API for Interval Blocks, + * using modern Spring Boot 4.0 patterns with DTOs and MapStruct mappers. + * + * Supported endpoints: + * - GET /espi/1_1/resource/IntervalBlock - List all interval blocks + * - GET /espi/1_1/resource/IntervalBlock/{intervalBlockId} - Get specific interval block + */ +@RestController +@RequestMapping("/espi/1_1/resource") +@Tag(name = "Interval Blocks", description = "ESPI Interval Block resource endpoints") +@SecurityRequirement(name = "oauth2") +public class IntervalBlockController { + + private final IntervalBlockRepository intervalBlockRepository; + private final IntervalBlockMapper intervalBlockMapper; + private final ApiRequestValidator requestValidator; + + public IntervalBlockController(IntervalBlockRepository intervalBlockRepository, + IntervalBlockMapper intervalBlockMapper, + ApiRequestValidator requestValidator) { + this.intervalBlockRepository = intervalBlockRepository; + this.intervalBlockMapper = intervalBlockMapper; + this.requestValidator = requestValidator; + } + + /** + * Get all Interval Blocks (root collection). + * Requires DataCustodian admin access or appropriate read scope. + */ + @GetMapping(value = "/IntervalBlock", produces = {MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get all Interval Blocks", + description = "Retrieve all Interval Blocks accessible to the authenticated client", + responses = { + @ApiResponse(responseCode = "200", description = "Interval Blocks retrieved successfully", + content = @Content(schema = @Schema(implementation = IntervalBlockDto.class))), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope") + } + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " + + "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " + + "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " + + "hasAuthority('SCOPE_FB_36_READ_3rd_party')") + public ResponseEntity> getAllIntervalBlocks( + @Parameter(description = "Maximum number of results to return", example = "50") + @RequestParam(defaultValue = "50") int limit, + @Parameter(description = "Offset for pagination", example = "0") + @RequestParam(defaultValue = "0") int offset) { + + Pageable pageable = requestValidator.toPageable(limit, offset); + List intervalBlockEntities = intervalBlockRepository.findAll(pageable).getContent(); + List intervalBlocks = intervalBlockEntities.stream() + .map(intervalBlockMapper::toDto) + .toList(); + return ResponseEntity.ok(intervalBlocks); + } + + /** + * Get specific Interval Block by ID (root resource). + */ + @GetMapping(value = "/IntervalBlock/{intervalBlockId}", produces = {MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get Interval Block by ID", + description = "Retrieve a specific Interval Block by its unique identifier", + responses = { + @ApiResponse(responseCode = "200", description = "Interval Block retrieved successfully", + content = @Content(schema = @Schema(implementation = IntervalBlockDto.class))), + @ApiResponse(responseCode = "404", description = "Interval Block not found"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope") + } + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " + + "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " + + "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " + + "hasAuthority('SCOPE_FB_36_READ_3rd_party')") + public ResponseEntity getIntervalBlock( + @Parameter(description = "Unique identifier of the Interval Block", required = true) + @PathVariable UUID intervalBlockId) { + + return intervalBlockRepository.findById(intervalBlockId) + .map(intervalBlockMapper::toDto) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ManageController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ManageController.java new file mode 100644 index 00000000..1606d936 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ManageController.java @@ -0,0 +1,148 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Modern REST Controller for ESPI Data Custodian Management operations. + * + * This controller provides administrative management capabilities within the Data Custodian, + * such as database initialization and resetting. + * + * Supported endpoints: + * - GET /espi/1_1/resource/DataCustodian/manage - Execute administrative commands + */ +@RestController +@RequestMapping("/espi/1_1/resource/DataCustodian/manage") +@Tag(name = "Data Custodian Management", description = "Administrative commands for Data Custodian maintenance") +@SecurityRequirement(name = "oauth2") +public class ManageController { + + private static final Logger log = LoggerFactory.getLogger(ManageController.class); + + /** + * Execute an administrative command. + * + * @param params Map of request parameters, specifically 'command' + * @return Output of the command as plain text + */ + @GetMapping(produces = MediaType.TEXT_PLAIN_VALUE) + @Operation( + summary = "Execute Administrative Command", + description = "Execute a restricted management command on the Data Custodian" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Command executed successfully"), + @ApiResponse(responseCode = "400", description = "Invalid command or execution error"), + @ApiResponse(responseCode = "403", description = "Forbidden - insufficient permissions") + }) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access')") + public ResponseEntity doCommand( + @Parameter(description = "Map containing 'command' parameter (resetDataCustodianDB or initializeDataCustodianDB)") + @RequestParam Map params) { + + String commandString = params.get("command"); + if (commandString == null) { + return ResponseEntity.badRequest().body("[Manage] Missing 'command' parameter"); + } + + log.info("[Manage] Request: {}", commandString); + + StringBuilder outputBuilder = new StringBuilder(); + outputBuilder.append("[Manage] Restricted Management Interface\n"); + outputBuilder.append("[Manage] Request: ").append(commandString).append("\n"); + + String commandPath = null; + if ("resetDataCustodianDB".equals(commandString)) { + commandPath = "/etc/OpenESPI/DataCustodian/resetDatabase.sh"; + } else if ("initializeDataCustodianDB".equals(commandString)) { + commandPath = "/etc/OpenESPI/DataCustodian/initializeDatabase.sh"; + } + + if (commandPath == null) { + outputBuilder.append("[Manage] Error: Unsupported command '").append(commandString).append("'\n"); + return ResponseEntity.badRequest().body(outputBuilder.toString()); + } + + try { + Process process = new ProcessBuilder(commandPath).start(); + boolean completed = process.waitFor(60, TimeUnit.SECONDS); + if (!completed) { + process.destroyForcibly(); + outputBuilder.append("[Manage] Exception: Command timed out\n"); + return ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT).body(outputBuilder.toString()); + } + int exitCode = process.exitValue(); + + outputBuilder.append("[Manage] Result: \n"); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + log.info("[Manage] Output: {}", line); + outputBuilder.append("[Manage]: ").append(line).append("\n"); + } + } + + outputBuilder.append("[Manage] Errors: \n"); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { + String line; + while ((line = reader.readLine()) != null) { + log.error("[Manage] Error line: {}", line); + outputBuilder.append("[Manage]: ").append(line).append("\n"); + } + } + + outputBuilder.append("[Manage] Process exited with code: ").append(exitCode).append("\n"); + outputBuilder.append("[Manage] Done\n"); + + if (exitCode == 0) { + return ResponseEntity.ok(outputBuilder.toString()); + } + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(outputBuilder.toString()); + + } catch (IOException | InterruptedException e) { + log.error("[Manage] Error executing command {}: {}", commandString, e.getMessage()); + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + outputBuilder.append("[Manage] Exception: ").append(e.getMessage()).append("\n"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(outputBuilder.toString()); + } + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingController.java index fa738635..06cf58ef 100644 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingController.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingController.java @@ -30,12 +30,11 @@ import org.greenbuttonalliance.espi.common.repositories.usage.MeterReadingRepository; import org.greenbuttonalliance.espi.common.mapper.usage.MeterReadingMapper; import org.greenbuttonalliance.espi.common.domain.usage.MeterReadingEntity; -import org.springframework.data.domain.PageRequest; +import org.greenbuttonalliance.espi.datacustodian.web.api.support.ApiRequestValidator; import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -59,10 +58,14 @@ public class MeterReadingController { private final MeterReadingRepository meterReadingRepository; private final MeterReadingMapper meterReadingMapper; + private final ApiRequestValidator requestValidator; - public MeterReadingController(MeterReadingRepository meterReadingRepository, MeterReadingMapper meterReadingMapper) { + public MeterReadingController(MeterReadingRepository meterReadingRepository, + MeterReadingMapper meterReadingMapper, + ApiRequestValidator requestValidator) { this.meterReadingRepository = meterReadingRepository; this.meterReadingMapper = meterReadingMapper; + this.requestValidator = requestValidator; } /** @@ -88,10 +91,9 @@ public ResponseEntity> getAllMeterReadings( @Parameter(description = "Maximum number of results to return", example = "50") @RequestParam(defaultValue = "50") int limit, @Parameter(description = "Offset for pagination", example = "0") - @RequestParam(defaultValue = "0") int offset, - Authentication authentication) { + @RequestParam(defaultValue = "0") int offset) { - Pageable pageable = PageRequest.of(offset / limit, limit); + Pageable pageable = requestValidator.toPageable(limit, offset); List meterReadingEntities = meterReadingRepository.findAll(pageable).getContent(); List meterReadings = meterReadingEntities.stream() .map(meterReadingMapper::toDto) @@ -120,12 +122,11 @@ public ResponseEntity> getAllMeterReadings( "hasAuthority('SCOPE_FB_36_READ_3rd_party')") public ResponseEntity getMeterReading( @Parameter(description = "Unique identifier of the Meter Reading", required = true) - @PathVariable UUID meterReadingId, - Authentication authentication) { + @PathVariable UUID meterReadingId) { return meterReadingRepository.findById(meterReadingId) .map(meterReadingMapper::toDto) .map(meterReading -> ResponseEntity.ok(meterReading)) .orElse(ResponseEntity.notFound().build()); } -} \ No newline at end of file +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeController.java new file mode 100644 index 00000000..a4d7bd20 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeController.java @@ -0,0 +1,140 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.greenbuttonalliance.espi.common.domain.usage.ReadingTypeEntity; +import org.greenbuttonalliance.espi.common.dto.usage.ReadingTypeDto; +import org.greenbuttonalliance.espi.common.mapper.usage.ReadingTypeMapper; +import org.greenbuttonalliance.espi.common.service.ReadingTypeService; +import org.greenbuttonalliance.espi.datacustodian.web.api.support.ApiRequestValidator; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +/** + * Modern REST Controller for ESPI Reading Type resources. + * + * This controller implements the NAESB ESPI 1.0 REST API for Reading Types, + * using modern Spring Boot 3.5 patterns with DTOs and MapStruct mappers. + * + * Supported endpoints: + * - GET /espi/1_1/resource/ReadingType - List all reading types + * - GET /espi/1_1/resource/ReadingType/{readingTypeId} - Get specific reading type + */ +@RestController +@RequestMapping("/espi/1_1/resource") +@Tag(name = "Reading Type", description = "Smart Meter Reading Type Metadata Management API") +@SecurityRequirement(name = "oauth2") +public class ReadingTypeController { + + private final ReadingTypeService readingTypeService; + private final ReadingTypeMapper readingTypeMapper; + private final ApiRequestValidator requestValidator; + + public ReadingTypeController(ReadingTypeService readingTypeService, + ReadingTypeMapper readingTypeMapper, + ApiRequestValidator requestValidator) { + this.readingTypeService = readingTypeService; + this.readingTypeMapper = readingTypeMapper; + this.requestValidator = requestValidator; + } + + /** + * Get all Reading Types (root collection). + * Requires DataCustodian admin access or appropriate read scope. + */ + @GetMapping(value = "/ReadingType", produces = {MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get ReadingType Collection", + description = "Retrieves all ReadingType resources accessible to the authenticated client", + responses = { + @ApiResponse(responseCode = "200", description = "Reading Types retrieved successfully", + content = @Content(schema = @Schema(implementation = ReadingTypeDto.class))), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope") + } + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " + + "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " + + "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " + + "hasAuthority('SCOPE_FB_36_READ_3rd_party')") + public ResponseEntity> getAllReadingTypes( + @Parameter(description = "Maximum number of results to return", example = "50") + @RequestParam(defaultValue = "50") int limit, + @Parameter(description = "Offset for pagination", example = "0") + @RequestParam(defaultValue = "0") int offset) { + + List readingTypeEntities = readingTypeService.findAll(); + // Manual pagination if needed, but for now we follow the reference patterns + // Some reference controllers use repositories directly, some use services. + // ReadingTypeService.findAll() returns a list. + + List readingTypes = requestValidator.paginate( + readingTypeEntities.stream().map(readingTypeMapper::toDto).toList(), + limit, + offset + ); + + return ResponseEntity.ok(readingTypes); + } + + /** + * Get specific Reading Type by ID (root resource). + */ + @GetMapping(value = "/ReadingType/{readingTypeId}", produces = {MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get Reading Type by ID", + description = "Retrieve a specific Reading Type by its unique identifier", + responses = { + @ApiResponse(responseCode = "200", description = "Reading Type retrieved successfully", + content = @Content(schema = @Schema(implementation = ReadingTypeDto.class))), + @ApiResponse(responseCode = "404", description = "Reading Type not found"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope") + } + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " + + "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " + + "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " + + "hasAuthority('SCOPE_FB_36_READ_3rd_party')") + public ResponseEntity getReadingType( + @Parameter(description = "Unique identifier of the Reading Type", required = true) + @PathVariable UUID readingTypeId) { + + ReadingTypeEntity readingTypeEntity = readingTypeService.findById(readingTypeId); + if (readingTypeEntity == null) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok(readingTypeMapper.toDto(readingTypeEntity)); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/RetailCustomerController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/RetailCustomerController.java new file mode 100644 index 00000000..3dee0531 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/RetailCustomerController.java @@ -0,0 +1,153 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.greenbuttonalliance.espi.common.domain.usage.RetailCustomerEntity; +import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto; +import org.greenbuttonalliance.espi.common.service.DtoExportService; +import org.greenbuttonalliance.espi.common.service.RetailCustomerService; +import org.greenbuttonalliance.espi.datacustodian.web.api.support.ApiAccessValidator; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +/** + * Modern REST Controller for ESPI RetailCustomer. + * + * This controller implements the NAESB ESPI 1.1 REST API for RetailCustomer, + * using modern Spring Boot 3.5 patterns with DTOs and Atom feeds. + */ +@RestController("retailCustomerApiController") +@RequestMapping("/espi/1_1/resource/RetailCustomer") +@Tag(name = "Retail Customer", description = "Utility Customer Account Management API") +public class RetailCustomerController { + + private final RetailCustomerService retailCustomerService; + private final DtoExportService exportService; + private final ApiAccessValidator accessValidator; + + public RetailCustomerController(RetailCustomerService retailCustomerService, + DtoExportService exportService, + ApiAccessValidator accessValidator) { + this.retailCustomerService = retailCustomerService; + this.exportService = exportService; + this.accessValidator = accessValidator; + } + + /** + * Get RetailCustomer Collection. + */ + @GetMapping(produces = {MediaType.APPLICATION_ATOM_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get RetailCustomer Collection", + description = "Retrieves all authorized RetailCustomer resources with optional filtering and pagination." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved RetailCustomer collection", + content = @Content( + mediaType = MediaType.APPLICATION_ATOM_XML_VALUE, + schema = @Schema(implementation = AtomFeedDto.class) + ) + ), + @ApiResponse(responseCode = "401", description = "Unauthorized access"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access')") + public ResponseEntity index() { + AtomFeedDto feed = exportService.createRetailCustomersFeed(); + return ResponseEntity.ok(feed); + } + + /** + * Get RetailCustomer by ID. + */ + @GetMapping(value = "/{retailCustomerId}", produces = {MediaType.APPLICATION_ATOM_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get RetailCustomer by ID", + description = "Retrieves a specific RetailCustomer resource by its unique identifier." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved RetailCustomer", + content = @Content( + mediaType = MediaType.APPLICATION_ATOM_XML_VALUE, + schema = @Schema(implementation = AtomEntryDto.class) + ) + ), + @ApiResponse(responseCode = "404", description = "RetailCustomer not found"), + @ApiResponse(responseCode = "401", description = "Unauthorized access") + }) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " + + "hasAuthority('SCOPE_FB_15_READ_3rd_party')") + public ResponseEntity show(@PathVariable Long retailCustomerId, + @RequestHeader(value = "Authorization", required = false) String authHeader, + Authentication authentication) { + accessValidator.enforceRetailCustomerAccess(authentication, authHeader, retailCustomerId); + + RetailCustomerEntity entity = retailCustomerService.findById(retailCustomerId); + if (entity == null) { + return ResponseEntity.notFound().build(); + } + + AtomEntryDto entry = exportService.createRetailCustomerEntry(entity); + return ResponseEntity.ok(entry); + } + + /** + * Create RetailCustomer (Not Implemented). + */ + @PostMapping + @Operation(summary = "Create RetailCustomer", description = "NOT IMPLEMENTED - Returns 501") + public ResponseEntity create() { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } + + /** + * Update RetailCustomer (Not Implemented). + */ + @PutMapping(value = "/{retailCustomerId}") + @Operation(summary = "Update RetailCustomer", description = "NOT IMPLEMENTED - Returns 501") + public ResponseEntity update() { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } + + /** + * Delete RetailCustomer (Not Implemented). + */ + @DeleteMapping("/{retailCustomerId}") + @Operation(summary = "Delete RetailCustomer", description = "NOT IMPLEMENTED - Returns 501") + public ResponseEntity delete() { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ServiceStatusController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ServiceStatusController.java new file mode 100644 index 00000000..b8173590 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ServiceStatusController.java @@ -0,0 +1,101 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.greenbuttonalliance.espi.common.domain.usage.ApplicationInformationEntity; +import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity; +import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto; +import org.greenbuttonalliance.espi.common.service.AuthorizationService; +import org.greenbuttonalliance.espi.common.service.DtoExportService; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Modern REST Controller for ESPI Service Status. + * + * This controller implements the NAESB ESPI 1.1 REST API for ServiceStatus, + * using modern Spring Boot 3.5 patterns with DTOs. + */ +@RestController +@RequestMapping("/espi/1_1/resource") +@Tag(name = "Service Status", description = "System Service Status Information API") +@SecurityRequirement(name = "oauth2") +public class ServiceStatusController { + + private final AuthorizationService authorizationService; + private final DtoExportService exportService; + + public ServiceStatusController(AuthorizationService authorizationService, + DtoExportService exportService) { + this.authorizationService = authorizationService; + this.exportService = exportService; + } + + /** + * Get Service Status. + */ + @GetMapping(value = "/ServiceStatus", produces = {MediaType.APPLICATION_ATOM_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get Service Status", + description = "Returns the current service status information including application status and system health" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Service status retrieved successfully", + content = @Content( + mediaType = MediaType.APPLICATION_ATOM_XML_VALUE, + schema = @Schema(implementation = AtomEntryDto.class) + ) + ), + @ApiResponse(responseCode = "401", description = "Unauthorized access"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or hasAuthority('SCOPE_FB_15_READ_3rd_party')") + public ResponseEntity index(@RequestHeader(value = "Authorization", required = false) String authHeader) { + String applicationStatus = "0"; + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + AuthorizationEntity authorization = authorizationService.findByAccessToken(token); + if (authorization != null) { + ApplicationInformationEntity applicationInformation = authorization.getApplicationInformation(); + if (applicationInformation != null && applicationInformation.getDataCustodianApplicationStatus() != null) { + applicationStatus = applicationInformation.getDataCustodianApplicationStatus(); + } + } + } + + AtomEntryDto entry = exportService.createServiceStatusEntry(applicationStatus); + return ResponseEntity.ok(entry); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/TimeConfigurationController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/TimeConfigurationController.java new file mode 100644 index 00000000..e1cf01ec --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/TimeConfigurationController.java @@ -0,0 +1,157 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.greenbuttonalliance.espi.common.domain.usage.TimeConfigurationEntity; +import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto; +import org.greenbuttonalliance.espi.common.service.DtoExportService; +import org.greenbuttonalliance.espi.common.service.TimeConfigurationService; +import org.greenbuttonalliance.espi.datacustodian.web.api.support.ApiRequestValidator; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +/** + * Modern REST Controller for ESPI Time Configuration resources. + * + * This controller implements the NAESB ESPI 1.0 REST API for LocalTimeParameters (TimeConfiguration), + * using modern Spring Boot 3.5 patterns with DTOs and MapStruct mappers. + */ +@RestController +@RequestMapping("/espi/1_1/resource/TimeConfiguration") +@Tag(name = "Time Configurations", description = "ESPI Time Configuration resource endpoints") +@SecurityRequirement(name = "oauth2") +public class TimeConfigurationController { + + private final TimeConfigurationService timeConfigurationService; + private final DtoExportService exportService; + private final ApiRequestValidator requestValidator; + + public TimeConfigurationController(TimeConfigurationService timeConfigurationService, + DtoExportService exportService, + ApiRequestValidator requestValidator) { + this.timeConfigurationService = timeConfigurationService; + this.exportService = exportService; + this.requestValidator = requestValidator; + } + + /** + * Get all Time Configurations (root collection). + */ + @GetMapping(produces = {MediaType.APPLICATION_ATOM_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get all Time Configurations", + description = "Retrieve all Time Configurations accessible to the authenticated client" + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or hasAuthority('SCOPE_FB_15_READ_3rd_party')") + public ResponseEntity index(@Parameter(description = "Maximum number of results to return", example = "50") + @RequestParam(defaultValue = "50") int limit, + @Parameter(description = "Offset for pagination", example = "0") + @RequestParam(defaultValue = "0") int offset) { + requestValidator.validateLimitOffset(limit, offset); + AtomFeedDto feed = exportService.createTimeConfigurationsFeed(); + return ResponseEntity.ok(feed); + } + + /** + * Get specific Time Configuration by ID. + */ + @GetMapping(value = "/{timeConfigurationId}", produces = {MediaType.APPLICATION_ATOM_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get Time Configuration by ID", + description = "Retrieve a specific Time Configuration by its unique identifier" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Time Configuration retrieved successfully"), + @ApiResponse(responseCode = "404", description = "Time Configuration not found") + }) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or hasAuthority('SCOPE_FB_15_READ_3rd_party')") + public ResponseEntity show(@Parameter(description = "Unique identifier of the Time Configuration", required = true) + @PathVariable UUID timeConfigurationId) { + + AtomEntryDto entry = exportService.createTimeConfigurationEntry(timeConfigurationId); + if (entry == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(entry); + } + + /** + * Create a new Time Configuration. + */ + @PostMapping(consumes = MediaType.APPLICATION_ATOM_XML_VALUE) + @Operation( + summary = "Create Time Configuration", + description = "Creates a new Time Configuration from an Atom entry" + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access')") + public ResponseEntity create() { + // Implementation for importing resources to be added + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * Update an existing Time Configuration. + */ + @PutMapping(value = "/{timeConfigurationId}", consumes = MediaType.APPLICATION_ATOM_XML_VALUE) + @Operation( + summary = "Update Time Configuration", + description = "Updates an existing Time Configuration" + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access')") + public ResponseEntity update() { + // Implementation for updating resources to be added + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * Delete a Time Configuration. + */ + @DeleteMapping(value = "/{timeConfigurationId}") + @Operation( + summary = "Delete Time Configuration", + description = "Deletes a specific Time Configuration" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "Time Configuration deleted successfully"), + @ApiResponse(responseCode = "404", description = "Time Configuration not found") + }) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access')") + public ResponseEntity delete(@PathVariable UUID timeConfigurationId) { + + TimeConfigurationEntity timeConfiguration = timeConfigurationService.findById(timeConfigurationId); + if (timeConfiguration == null) { + return ResponseEntity.notFound().build(); + } + timeConfigurationService.delete(timeConfiguration); + return ResponseEntity.noContent().build(); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointController.java index 366b771f..ec244696 100644 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointController.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointController.java @@ -30,7 +30,9 @@ import org.greenbuttonalliance.espi.common.repositories.usage.UsagePointRepository; import org.greenbuttonalliance.espi.common.mapper.usage.UsagePointMapper; import org.greenbuttonalliance.espi.common.domain.usage.UsagePointEntity; -import org.springframework.data.domain.PageRequest; +import org.greenbuttonalliance.espi.common.service.SubscriptionService; +import org.greenbuttonalliance.espi.datacustodian.web.api.support.ApiAccessValidator; +import org.greenbuttonalliance.espi.datacustodian.web.api.support.ApiRequestValidator; import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -61,10 +63,20 @@ public class UsagePointController { private final UsagePointRepository usagePointRepository; private final UsagePointMapper usagePointMapper; + private final SubscriptionService subscriptionService; + private final ApiRequestValidator requestValidator; + private final ApiAccessValidator accessValidator; - public UsagePointController(UsagePointRepository usagePointRepository, UsagePointMapper usagePointMapper) { + public UsagePointController(UsagePointRepository usagePointRepository, + UsagePointMapper usagePointMapper, + SubscriptionService subscriptionService, + ApiRequestValidator requestValidator, + ApiAccessValidator accessValidator) { this.usagePointRepository = usagePointRepository; this.usagePointMapper = usagePointMapper; + this.subscriptionService = subscriptionService; + this.requestValidator = requestValidator; + this.accessValidator = accessValidator; } /** @@ -91,10 +103,22 @@ public ResponseEntity> getAllUsagePoints( @RequestParam(defaultValue = "50") int limit, @Parameter(description = "Offset for pagination", example = "0") @RequestParam(defaultValue = "0") int offset, + @RequestHeader(value = "Authorization", required = false) String authHeader, Authentication authentication) { - - Pageable pageable = PageRequest.of(offset / limit, limit); - List usagePointEntities = usagePointRepository.findAll(pageable).getContent(); + + List usagePointEntities; + if (accessValidator.isAdmin(authentication)) { + Pageable pageable = requestValidator.toPageable(limit, offset); + usagePointEntities = usagePointRepository.findAll(pageable).getContent(); + } else { + UUID subscriptionId = accessValidator.requireSubscriptionId(authHeader); + List usagePointIds = subscriptionService.findUsagePointIds(subscriptionId); + List entities = usagePointIds.stream() + .map(id -> usagePointRepository.findById(id).orElse(null)) + .filter(java.util.Objects::nonNull) + .toList(); + usagePointEntities = requestValidator.paginate(entities, limit, offset); + } List usagePoints = usagePointEntities.stream() .map(usagePointMapper::toDto) .toList(); @@ -123,8 +147,14 @@ public ResponseEntity> getAllUsagePoints( public ResponseEntity getUsagePoint( @Parameter(description = "Unique identifier of the Usage Point", required = true) @PathVariable UUID usagePointId, + @RequestHeader(value = "Authorization", required = false) String authHeader, Authentication authentication) { - + + if (!accessValidator.isAdmin(authentication)) { + UUID subscriptionId = accessValidator.requireSubscriptionId(authHeader); + accessValidator.enforceUsagePointInSubscription(subscriptionId, usagePointId); + } + return usagePointRepository.findById(usagePointId) .map(usagePointMapper::toDto) .map(usagePoint -> ResponseEntity.ok(usagePoint)) @@ -156,12 +186,17 @@ public ResponseEntity> getSubscriptionUsagePoints( @RequestParam(defaultValue = "50") int limit, @Parameter(description = "Offset for pagination", example = "0") @RequestParam(defaultValue = "0") int offset, + @RequestHeader(value = "Authorization", required = false) String authHeader, Authentication authentication) { - - // TODO: Implement subscription-based filtering when subscription relationship is available - // For now, return all usage points with pagination as a temporary solution - Pageable pageable = PageRequest.of(offset / limit, limit); - List usagePointEntities = usagePointRepository.findAll(pageable).getContent(); + + accessValidator.enforceSubscriptionPathAccess(authentication, authHeader, subscriptionId); + + List usagePointIds = subscriptionService.findUsagePointIds(subscriptionId); + List usagePointEntities = usagePointIds.stream() + .map(id -> usagePointRepository.findById(id).orElse(null)) + .filter(java.util.Objects::nonNull) + .toList(); + usagePointEntities = requestValidator.paginate(usagePointEntities, limit, offset); List usagePoints = usagePointEntities.stream() .map(usagePointMapper::toDto) .toList(); @@ -191,13 +226,15 @@ public ResponseEntity getSubscriptionUsagePoint( @PathVariable UUID subscriptionId, @Parameter(description = "Unique identifier of the Usage Point", required = true) @PathVariable UUID usagePointId, + @RequestHeader(value = "Authorization", required = false) String authHeader, Authentication authentication) { - - // TODO: Implement subscription-based validation when subscription relationship is available - // For now, just return the usage point if it exists + + accessValidator.enforceSubscriptionPathAccess(authentication, authHeader, subscriptionId); + accessValidator.enforceUsagePointInSubscription(subscriptionId, usagePointId); + return usagePointRepository.findById(usagePointId) .map(usagePointMapper::toDto) .map(usagePoint -> ResponseEntity.ok(usagePoint)) .orElse(ResponseEntity.notFound().build()); } -} \ No newline at end of file +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryController.java new file mode 100644 index 00000000..187f974b --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryController.java @@ -0,0 +1,204 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.greenbuttonalliance.espi.common.domain.usage.UsageSummaryEntity; +import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto; +import org.greenbuttonalliance.espi.common.service.DtoExportService; +import org.greenbuttonalliance.espi.common.service.UsageSummaryService; +import org.greenbuttonalliance.espi.datacustodian.web.api.support.ApiAccessValidator; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +/** + * Modern REST Controller for ESPI UsageSummary. + * + * This controller implements the NAESB ESPI 1.1 REST API for UsageSummary, + * using modern Spring Boot 3.5 patterns with DTOs and Atom feeds. + */ +@RestController("usageSummaryApiController") +@RequestMapping("/espi/1_1/resource") +@Tag(name = "Usage Summary", description = "Energy Usage Summary API") +public class UsageSummaryController { + + private final UsageSummaryService usageSummaryService; + private final DtoExportService exportService; + private final ApiAccessValidator accessValidator; + + public UsageSummaryController(UsageSummaryService usageSummaryService, + DtoExportService exportService, + ApiAccessValidator accessValidator) { + this.usageSummaryService = usageSummaryService; + this.exportService = exportService; + this.accessValidator = accessValidator; + } + + /** + * Get UsageSummary Collection. + */ + @GetMapping(value = "/UsageSummary", produces = {MediaType.APPLICATION_ATOM_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get UsageSummary Collection", + description = "Retrieves all authorized UsageSummary resources." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved UsageSummary collection", + content = @Content( + mediaType = MediaType.APPLICATION_ATOM_XML_VALUE, + schema = @Schema(implementation = AtomFeedDto.class) + ) + ), + @ApiResponse(responseCode = "401", description = "Unauthorized access"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access')") + public ResponseEntity index() { + AtomFeedDto feed = exportService.createUsageSummariesFeed(); + return ResponseEntity.ok(feed); + } + + /** + * Get UsageSummary by ID. + */ + @GetMapping(value = "/UsageSummary/{usageSummaryId}", produces = {MediaType.APPLICATION_ATOM_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get UsageSummary by ID", + description = "Retrieves a specific UsageSummary resource by its unique identifier." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved UsageSummary", + content = @Content( + mediaType = MediaType.APPLICATION_ATOM_XML_VALUE, + schema = @Schema(implementation = AtomEntryDto.class) + ) + ), + @ApiResponse(responseCode = "404", description = "UsageSummary not found"), + @ApiResponse(responseCode = "401", description = "Unauthorized access") + }) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or hasAuthority('SCOPE_FB_15_READ_3rd_party')") + public ResponseEntity show(@PathVariable UUID usageSummaryId, + @RequestHeader(value = "Authorization", required = false) String authHeader, + org.springframework.security.core.Authentication authentication) { + UsageSummaryEntity entity = usageSummaryService.findById(usageSummaryId); + if (entity == null) { + return ResponseEntity.notFound().build(); + } + + if (!accessValidator.isAdmin(authentication)) { + UUID tokenSubscriptionId = accessValidator.requireSubscriptionId(authHeader); + if (entity.getUsagePoint() == null || entity.getUsagePoint().getId() == null) { + return ResponseEntity.notFound().build(); + } + accessValidator.enforceUsagePointInSubscription(tokenSubscriptionId, entity.getUsagePoint().getId()); + } + + AtomEntryDto entry = exportService.createUsageSummaryEntry(entity); + return ResponseEntity.ok(entry); + } + + /** + * Get UsageSummary Collection for a specific UsagePoint. + */ + @GetMapping(value = "/Subscription/{subscriptionId}/UsagePoint/{usagePointId}/UsageSummary", produces = {MediaType.APPLICATION_ATOM_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get UsageSummary Collection by UsagePoint", + description = "Retrieves all UsageSummary resources associated with a specific UsagePoint." + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or hasAuthority('SCOPE_FB_15_READ_3rd_party')") + public ResponseEntity indexByUsagePoint(@PathVariable UUID subscriptionId, + @PathVariable UUID usagePointId, + @RequestHeader(value = "Authorization", required = false) String authHeader, + org.springframework.security.core.Authentication authentication) { + accessValidator.enforceSubscriptionPathAccess(authentication, authHeader, subscriptionId); + accessValidator.enforceUsagePointInSubscription(subscriptionId, usagePointId); + AtomFeedDto feed = exportService.createUsageSummariesFeedByUsagePointId(usagePointId); + return ResponseEntity.ok(feed); + } + + /** + * Get UsageSummary by ID for a specific UsagePoint. + */ + @GetMapping(value = "/Subscription/{subscriptionId}/UsagePoint/{usagePointId}/UsageSummary/{usageSummaryId}", produces = {MediaType.APPLICATION_ATOM_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation( + summary = "Get UsageSummary by ID for UsagePoint", + description = "Retrieves a specific UsageSummary resource associated with a specific UsagePoint." + ) + @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or hasAuthority('SCOPE_FB_15_READ_3rd_party')") + public ResponseEntity showByUsagePoint(@PathVariable UUID subscriptionId, + @PathVariable UUID usagePointId, + @PathVariable UUID usageSummaryId, + @RequestHeader(value = "Authorization", required = false) String authHeader, + org.springframework.security.core.Authentication authentication) { + accessValidator.enforceSubscriptionPathAccess(authentication, authHeader, subscriptionId); + accessValidator.enforceUsagePointInSubscription(subscriptionId, usagePointId); + + UsageSummaryEntity entity = usageSummaryService.findById(usageSummaryId); + if (entity == null || entity.getUsagePoint() == null || !entity.getUsagePoint().getId().equals(usagePointId)) { + return ResponseEntity.notFound().build(); + } + + AtomEntryDto entry = exportService.createUsageSummaryEntry(entity); + return ResponseEntity.ok(entry); + } + + /** + * Create UsageSummary (Not Implemented). + */ + @PostMapping(value = {"/UsageSummary", "/Subscription/{subscriptionId}/UsagePoint/{usagePointId}/UsageSummary"}) + @Operation(summary = "Create UsageSummary", description = "NOT IMPLEMENTED - Returns 501") + public ResponseEntity create() { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } + + /** + * Update UsageSummary (Not Implemented). + */ + @PutMapping(value = {"/UsageSummary/{usageSummaryId}", "/Subscription/{subscriptionId}/UsagePoint/{usagePointId}/UsageSummary/{usageSummaryId}"}) + @Operation(summary = "Update UsageSummary", description = "NOT IMPLEMENTED - Returns 501") + public ResponseEntity update() { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } + + /** + * Delete UsageSummary (Not Implemented). + */ + @DeleteMapping(value = {"/UsageSummary/{usageSummaryId}", "/Subscription/{subscriptionId}/UsagePoint/{usagePointId}/UsageSummary/{usageSummaryId}"}) + @Operation(summary = "Delete UsageSummary", description = "NOT IMPLEMENTED - Returns 501") + public ResponseEntity delete() { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/support/ApiAccessValidator.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/support/ApiAccessValidator.java new file mode 100644 index 00000000..199a79f4 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/support/ApiAccessValidator.java @@ -0,0 +1,78 @@ +package org.greenbuttonalliance.espi.datacustodian.web.api.support; + +import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity; +import org.greenbuttonalliance.espi.common.domain.usage.SubscriptionEntity; +import org.greenbuttonalliance.espi.common.service.AuthorizationService; +import org.greenbuttonalliance.espi.common.service.SubscriptionService; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +import java.util.UUID; + +@Component +public class ApiAccessValidator { + + private final AuthorizationService authorizationService; + private final SubscriptionService subscriptionService; + + public ApiAccessValidator(AuthorizationService authorizationService, SubscriptionService subscriptionService) { + this.authorizationService = authorizationService; + this.subscriptionService = subscriptionService; + } + + public boolean isAdmin(Authentication authentication) { + return authentication != null + && authentication.getAuthorities().stream() + .anyMatch(a -> "SCOPE_DataCustodian_Admin_Access".equals(a.getAuthority())); + } + + public UUID requireSubscriptionId(String authHeader) { + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Bearer token is required"); + } + + String token = authHeader.substring(7); + AuthorizationEntity authorization = authorizationService.findByAccessToken(token); + if (authorization == null || authorization.getSubscription() == null || authorization.getSubscription().getId() == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "No active subscription found for token"); + } + + return authorization.getSubscription().getId(); + } + + public void enforceSubscriptionPathAccess(Authentication authentication, String authHeader, UUID subscriptionId) { + if (isAdmin(authentication)) { + return; + } + + UUID tokenSubscriptionId = requireSubscriptionId(authHeader); + if (!tokenSubscriptionId.equals(subscriptionId)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Token is not authorized for requested subscription"); + } + } + + public void enforceUsagePointInSubscription(UUID subscriptionId, UUID usagePointId) { + Long retailCustomerId = subscriptionService.findRetailCustomerId(subscriptionId, usagePointId); + if (retailCustomerId == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "UsagePoint not found in subscription"); + } + } + + public void enforceRetailCustomerAccess(Authentication authentication, String authHeader, Long retailCustomerId) { + if (isAdmin(authentication)) { + return; + } + + UUID tokenSubscriptionId = requireSubscriptionId(authHeader); + SubscriptionEntity subscription = subscriptionService.findById(tokenSubscriptionId); + if (subscription == null || subscription.getRetailCustomer() == null || subscription.getRetailCustomer().getId() == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "No retail customer bound to subscription"); + } + + if (!subscription.getRetailCustomer().getId().equals(retailCustomerId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "RetailCustomer not found for token subscription"); + } + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/support/ApiRequestValidator.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/support/ApiRequestValidator.java new file mode 100644 index 00000000..4d04b8b4 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/support/ApiRequestValidator.java @@ -0,0 +1,102 @@ +package org.greenbuttonalliance.espi.datacustodian.web.api.support; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@Component +public class ApiRequestValidator { + + public Pageable toPageable(int limit, int offset) { + validateLimitOffset(limit, offset); + return new OffsetPageable(limit, offset); + } + + public void validateLimitOffset(int limit, int offset) { + if (limit <= 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "'limit' must be greater than 0"); + } + if (offset < 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "'offset' must be 0 or greater"); + } + } + + public List paginate(List items, int limit, int offset) { + validateLimitOffset(limit, offset); + return items.stream() + .skip(offset) + .limit(limit) + .toList(); + } + + private static final class OffsetPageable implements Pageable { + private final int limit; + private final int offset; + private final Sort sort; + + private OffsetPageable(int limit, int offset) { + this(limit, offset, Sort.unsorted()); + } + + private OffsetPageable(int limit, int offset, Sort sort) { + this.limit = limit; + this.offset = offset; + this.sort = sort; + } + + @Override + public int getPageNumber() { + return offset / limit; + } + + @Override + public int getPageSize() { + return limit; + } + + @Override + public long getOffset() { + return offset; + } + + @Override + public Sort getSort() { + return sort; + } + + @Override + public Pageable next() { + return new OffsetPageable(limit, offset + limit, sort); + } + + @Override + public Pageable previousOrFirst() { + if (!hasPrevious()) { + return first(); + } + return new OffsetPageable(limit, offset - limit, sort); + } + + @Override + public Pageable first() { + return new OffsetPageable(limit, 0, sort); + } + + @Override + public Pageable withPage(int pageNumber) { + if (pageNumber < 0) { + throw new IllegalArgumentException("Page index must not be less than zero"); + } + return new OffsetPageable(limit, pageNumber * limit, sort); + } + + @Override + public boolean hasPrevious() { + return offset > 0; + } + } +} diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationControllerTest.java new file mode 100644 index 00000000..f1b5430f --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationControllerTest.java @@ -0,0 +1,232 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.greenbuttonalliance.espi.common.domain.usage.ApplicationInformationEntity; +import org.greenbuttonalliance.espi.common.dto.usage.ApplicationInformationDto; +import org.greenbuttonalliance.espi.common.mapper.usage.ApplicationInformationMapper; +import org.greenbuttonalliance.espi.common.repositories.usage.ApplicationInformationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class ApplicationInformationControllerTest { + + @Autowired + private MockMvc mockMvc; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @MockitoBean + private ApplicationInformationRepository applicationInformationRepository; + + @MockitoBean + private ApplicationInformationMapper applicationInformationMapper; + + private final UUID applicationInformationId = UUID.randomUUID(); + private ApplicationInformationEntity applicationInformationEntity; + private ApplicationInformationDto applicationInformationDto; + + @BeforeEach + void setUp() { + applicationInformationEntity = new ApplicationInformationEntity(); + applicationInformationEntity.setId(applicationInformationId); + applicationInformationEntity.setClientId("test-client"); + + applicationInformationDto = new ApplicationInformationDto(); + applicationInformationDto.setClientId("test-client"); + applicationInformationDto.setClientName("Test Application"); + + Page page = new PageImpl<>(Collections.singletonList(applicationInformationEntity)); + + when(applicationInformationRepository.findAll(any(Pageable.class))).thenReturn(page); + when(applicationInformationRepository.findById(applicationInformationId)).thenReturn(Optional.of(applicationInformationEntity)); + when(applicationInformationRepository.existsById(applicationInformationId)).thenReturn(true); + when(applicationInformationRepository.save(any(ApplicationInformationEntity.class))).thenReturn(applicationInformationEntity); + + when(applicationInformationMapper.toDto(any(ApplicationInformationEntity.class))).thenReturn(applicationInformationDto); + when(applicationInformationMapper.toEntity(any(ApplicationInformationDto.class))).thenReturn(applicationInformationEntity); + } + + @Nested + @DisplayName("Get All ApplicationInformation Tests") + class GetAllApplicationInformationTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /ApplicationInformation - Should return 200 and List") + void getAll_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/ApplicationInformation") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$[0].clientId").value("test-client")); + } + + @Test + @DisplayName("GET /ApplicationInformation - Should return 401 for unauthenticated") + void getAll_Unauthenticated_Returns401() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/ApplicationInformation") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /ApplicationInformation - Should return 400 for invalid limit") + void getAll_InvalidLimit_Returns400() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/ApplicationInformation") + .param("limit", "0") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /ApplicationInformation - Should return 400 for invalid offset") + void getAll_InvalidOffset_Returns400() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/ApplicationInformation") + .param("offset", "-1") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("Get ApplicationInformation By ID Tests") + class GetByIdTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /ApplicationInformation/{id} - Should return 200 and Resource") + void getById_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/ApplicationInformation/" + applicationInformationId) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.clientId").value("test-client")); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /ApplicationInformation/{id} - Should return 404 for unknown ID") + void getById_UnknownId_Returns404() throws Exception { + UUID unknownId = UUID.randomUUID(); + when(applicationInformationRepository.findById(unknownId)).thenReturn(Optional.empty()); + + mockMvc.perform(get("/espi/1_1/resource/ApplicationInformation/" + unknownId) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("Create ApplicationInformation Tests") + class CreateTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("POST /ApplicationInformation - Should return 201 and Created Resource") + void create_Returns201() throws Exception { + mockMvc.perform(post("/espi/1_1/resource/ApplicationInformation") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(objectMapper.writeValueAsString(applicationInformationDto)) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.clientId").value("test-client")); + } + } + + @Nested + @DisplayName("Update ApplicationInformation Tests") + class UpdateTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("PUT /ApplicationInformation/{id} - Should return 200 and Updated Resource") + void update_Returns200() throws Exception { + mockMvc.perform(put("/espi/1_1/resource/ApplicationInformation/" + applicationInformationId) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(objectMapper.writeValueAsString(applicationInformationDto)) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.clientId").value("test-client")); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("PUT /ApplicationInformation/{id} - Should return 404 for unknown ID") + void update_UnknownId_Returns404() throws Exception { + UUID unknownId = UUID.randomUUID(); + when(applicationInformationRepository.existsById(unknownId)).thenReturn(false); + + mockMvc.perform(put("/espi/1_1/resource/ApplicationInformation/" + unknownId) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(objectMapper.writeValueAsString(applicationInformationDto)) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("Delete ApplicationInformation Tests") + class DeleteTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("DELETE /ApplicationInformation/{id} - Should return 200") + void delete_Returns200() throws Exception { + mockMvc.perform(delete("/espi/1_1/resource/ApplicationInformation/" + applicationInformationId)) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("DELETE /ApplicationInformation/{id} - Should return 404 for unknown ID") + void delete_UnknownId_Returns404() throws Exception { + UUID unknownId = UUID.randomUUID(); + when(applicationInformationRepository.existsById(unknownId)).thenReturn(false); + + mockMvc.perform(delete("/espi/1_1/resource/ApplicationInformation/" + unknownId)) + .andExpect(status().isNotFound()); + } + } +} diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationControllerTest.java new file mode 100644 index 00000000..64810352 --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationControllerTest.java @@ -0,0 +1,149 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity; +import org.greenbuttonalliance.espi.common.dto.usage.AuthorizationDto; +import org.greenbuttonalliance.espi.common.mapper.usage.AuthorizationMapper; +import org.greenbuttonalliance.espi.common.repositories.usage.AuthorizationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Collections; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AuthorizationControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AuthorizationRepository authorizationRepository; + + @MockitoBean + private AuthorizationMapper authorizationMapper; + + @MockitoBean + private org.greenbuttonalliance.espi.datacustodian.web.api.support.ApiRequestValidator requestValidator; + + private UUID authorizationId; + private AuthorizationEntity authorizationEntity; + private AuthorizationDto authorizationDto; + + @BeforeEach + void setUp() { + authorizationId = UUID.randomUUID(); + authorizationEntity = new AuthorizationEntity(); + authorizationEntity.setId(authorizationId); + + authorizationDto = new AuthorizationDto(); + + Page page = new PageImpl<>(Collections.singletonList(authorizationEntity)); + + when(requestValidator.toPageable(eq(50), eq(0))).thenReturn(PageRequest.of(0, 50)); + when(requestValidator.toPageable(eq(0), eq(0))) + .thenThrow(new ResponseStatusException(BAD_REQUEST, "'limit' must be greater than 0")); + when(authorizationRepository.findAll(any(Pageable.class))).thenReturn(page); + when(authorizationRepository.findById(authorizationId)).thenReturn(Optional.of(authorizationEntity)); + when(authorizationMapper.toDto(any(AuthorizationEntity.class))).thenReturn(authorizationDto); + } + + @Nested + @DisplayName("GET /Authorization") + class GetAllTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + void getAll_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/Authorization") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON_VALUE)); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + void getAll_InvalidLimit_Returns400() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/Authorization") + .param("limit", "0") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isBadRequest()); + } + + @Test + void getAll_Unauthenticated_Returns401() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/Authorization") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("GET /Authorization/{id}") + class GetByIdTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + void getById_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/Authorization/{id}", authorizationId) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON_VALUE)); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + void getById_NotFound_Returns404() throws Exception { + UUID unknownId = UUID.randomUUID(); + when(authorizationRepository.findById(unknownId)).thenReturn(Optional.empty()); + + mockMvc.perform(get("/espi/1_1/resource/Authorization/{id}", unknownId) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isNotFound()); + } + } +} + diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/BatchControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/BatchControllerTest.java new file mode 100644 index 00000000..ece43808 --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/BatchControllerTest.java @@ -0,0 +1,215 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import org.greenbuttonalliance.espi.common.domain.usage.RetailCustomerEntity; +import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity; +import org.greenbuttonalliance.espi.common.domain.usage.SubscriptionEntity; +import org.greenbuttonalliance.espi.common.domain.usage.UsagePointEntity; +import org.greenbuttonalliance.espi.common.service.AuthorizationService; +import org.greenbuttonalliance.espi.common.service.DtoExportService; +import org.greenbuttonalliance.espi.common.service.RetailCustomerService; +import org.greenbuttonalliance.espi.common.service.SubscriptionService; +import org.greenbuttonalliance.espi.common.service.UsagePointService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.io.OutputStream; +import java.util.Collections; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class BatchControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private RetailCustomerService retailCustomerService; + + @MockitoBean + private UsagePointService usagePointService; + + @MockitoBean + private SubscriptionService subscriptionService; + + @MockitoBean + private DtoExportService exportService; + + @MockitoBean + private AuthorizationService authorizationService; + + private final Long retailCustomerId = 1L; + private final UUID usagePointId = UUID.randomUUID(); + private final UUID subscriptionId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + RetailCustomerEntity customer = new RetailCustomerEntity(); + customer.setId(retailCustomerId); + when(retailCustomerService.findById(retailCustomerId)).thenReturn(customer); + + UsagePointEntity usagePoint = new UsagePointEntity(); + usagePoint.setId(usagePointId); + when(usagePointService.findAllByRetailCustomer(any(RetailCustomerEntity.class))) + .thenReturn(Collections.singletonList(usagePoint)); + when(usagePointService.findById(retailCustomerId, usagePointId)).thenReturn(usagePoint); + + SubscriptionEntity subscription = new SubscriptionEntity(); + subscription.setId(subscriptionId); + subscription.setRetailCustomer(customer); + when(subscriptionService.findById(subscriptionId)).thenReturn(subscription); + when(subscriptionService.findUsagePointIds(subscriptionId)) + .thenReturn(Collections.singletonList(usagePointId)); + when(subscriptionService.findRetailCustomerId(subscriptionId, usagePointId)) + .thenReturn(retailCustomerId); + + AuthorizationEntity authorization = new AuthorizationEntity(); + authorization.setSubscription(subscription); + when(authorizationService.findByAccessToken("test-token")).thenReturn(authorization); + } + + @Nested + @DisplayName("Bulk Upload Tests") + class UploadTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("POST /Batch/RetailCustomer/{id}/UsagePoint - Should return 501 Not Implemented") + void upload_Returns501() throws Exception { + mockMvc.perform(post("/espi/1_1/resource/Batch/RetailCustomer/" + retailCustomerId + "/UsagePoint") + .contentType(MediaType.APPLICATION_ATOM_XML_VALUE) + .accept(MediaType.APPLICATION_ATOM_XML_VALUE) + .content("")) + .andExpect(status().isNotImplemented()); + } + } + + @Nested + @DisplayName("Download Collection Tests") + class DownloadCollectionTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /Batch/RetailCustomer/{id}/UsagePoint - Should return 200 and XML") + void downloadCollection_Returns200() throws Exception { + when(exportService.createUsagePointsFeed(anyList())).thenReturn(new org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto()); + + mockMvc.perform(get("/espi/1_1/resource/Batch/RetailCustomer/" + retailCustomerId + "/UsagePoint") + .accept(MediaType.APPLICATION_ATOM_XML_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_ATOM_XML_VALUE)) + .andExpect(header().string("Content-Disposition", "attachment; filename=GreenButtonDownload.xml")); + + verify(exportService).createUsagePointsFeed(anyList()); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /Batch/RetailCustomer/{id}/UsagePoint - Should return 404 for unknown customer") + void downloadCollection_Returns404() throws Exception { + when(retailCustomerService.findById(99L)).thenReturn(null); + mockMvc.perform(get("/espi/1_1/resource/Batch/RetailCustomer/99/UsagePoint") + .accept(MediaType.APPLICATION_ATOM_XML_VALUE)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("Download Member Tests") + class DownloadMemberTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /Batch/RetailCustomer/{id}/UsagePoint/{uid} - Should return 200 and XML") + void downloadMember_Returns200() throws Exception { + when(exportService.createUsagePointEntry(any(UsagePointEntity.class))) + .thenReturn(new org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto()); + + mockMvc.perform(get("/espi/1_1/resource/Batch/RetailCustomer/" + retailCustomerId + "/UsagePoint/" + usagePointId) + .accept(MediaType.APPLICATION_ATOM_XML_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_ATOM_XML_VALUE)); + + verify(exportService).createUsagePointEntry(any(UsagePointEntity.class)); + } + } + + @Nested + @DisplayName("Subscription Tests") + class SubscriptionTests { + @Test + @WithMockUser(authorities = {"SCOPE_FB_15_READ_3rd_party", "SCOPE_DataCustodian_Admin_Access"}) + @DisplayName("GET /Batch/Subscription/{sid} - Should return 200 and XML") + void subscription_Returns200() throws Exception { + when(exportService.createUsagePointsFeedByIds(anyList())).thenReturn(new org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto()); + + mockMvc.perform(get("/espi/1_1/resource/Batch/Subscription/" + subscriptionId) + .accept(MediaType.APPLICATION_ATOM_XML_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_ATOM_XML_VALUE)); + + verify(exportService).createUsagePointsFeedByIds(anyList()); + } + + @Test + @WithMockUser(authorities = {"SCOPE_FB_15_READ_3rd_party", "SCOPE_DataCustodian_Admin_Access"}) + @DisplayName("GET /Batch/Subscription/{sid}/UsagePoint/{uid} - Should return 200 and XML") + void subscriptionMember_Returns200() throws Exception { + when(exportService.createUsagePointEntry(eq(usagePointId))) + .thenReturn(new org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto()); + + mockMvc.perform(get("/espi/1_1/resource/Batch/Subscription/" + subscriptionId + "/UsagePoint/" + usagePointId) + .accept(MediaType.APPLICATION_ATOM_XML_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_ATOM_XML_VALUE)); + + verify(exportService).createUsagePointEntry(eq(usagePointId)); + } + } + + @Nested + @DisplayName("Bulk Tests") + class BulkTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /Batch/Bulk/{bid} - Should return 501 Not Implemented") + void bulk_Returns501() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/Batch/Bulk/123") + .accept(MediaType.APPLICATION_ATOM_XML_VALUE)) + .andExpect(status().isNotImplemented()); + } + } +} diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountControllerTest.java new file mode 100644 index 00000000..b283afa8 --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountControllerTest.java @@ -0,0 +1,139 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import org.greenbuttonalliance.espi.common.domain.customer.entity.CustomerAccountEntity; +import org.greenbuttonalliance.espi.common.dto.customer.CustomerAccountDto; +import org.greenbuttonalliance.espi.common.mapper.customer.CustomerAccountMapper; +import org.greenbuttonalliance.espi.common.service.customer.CustomerAccountService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class CustomerAccountControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private CustomerAccountService customerAccountService; + + @MockitoBean + private CustomerAccountMapper customerAccountMapper; + + private final UUID customerAccountId = UUID.randomUUID(); + private CustomerAccountEntity customerAccountEntity; + private CustomerAccountDto customerAccountDto; + + @BeforeEach + void setUp() { + customerAccountEntity = new CustomerAccountEntity(); + customerAccountEntity.setId(customerAccountId); + + customerAccountDto = new CustomerAccountDto(); + customerAccountDto.setAccountId("ACCT-12345"); + + when(customerAccountService.findAll()).thenReturn(Collections.singletonList(customerAccountEntity)); + when(customerAccountService.findById(customerAccountId)).thenReturn(Optional.of(customerAccountEntity)); + when(customerAccountMapper.toDto(any(CustomerAccountEntity.class))).thenReturn(customerAccountDto); + } + + @Nested + @DisplayName("Get All Customer Accounts Tests") + class GetAllCustomerAccountsTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /CustomerAccount - Should return 200 and List of Customer Accounts") + void getAllCustomerAccounts_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/CustomerAccount") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$[0].accountId").value("ACCT-12345")); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /CustomerAccount - Should handle pagination") + void getAllCustomerAccounts_WithPagination_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/CustomerAccount") + .param("limit", "10") + .param("offset", "0") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("GET /CustomerAccount - Should return 401 for unauthenticated") + void getAllCustomerAccounts_Unauthenticated_Returns401() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/CustomerAccount") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("Get Customer Account By ID Tests") + class GetCustomerAccountByIdTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /CustomerAccount/{id} - Should return 200 and Customer Account") + void getCustomerAccount_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/CustomerAccount/" + customerAccountId) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.accountId").value("ACCT-12345")); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /CustomerAccount/{id} - Should return 404 for unknown ID") + void getCustomerAccount_UnknownId_Returns404() throws Exception { + UUID unknownId = UUID.randomUUID(); + when(customerAccountService.findById(unknownId)).thenReturn(Optional.empty()); + + mockMvc.perform(get("/espi/1_1/resource/CustomerAccount/" + unknownId) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isNotFound()); + } + } +} diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerControllerTest.java new file mode 100644 index 00000000..011ab2b2 --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerControllerTest.java @@ -0,0 +1,139 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import org.greenbuttonalliance.espi.common.domain.customer.entity.CustomerEntity; +import org.greenbuttonalliance.espi.common.dto.customer.CustomerDto; +import org.greenbuttonalliance.espi.common.mapper.customer.CustomerMapper; +import org.greenbuttonalliance.espi.common.service.customer.CustomerService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class CustomerControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private CustomerService customerService; + + @MockitoBean + private CustomerMapper customerMapper; + + private final UUID customerId = UUID.randomUUID(); + private CustomerEntity customerEntity; + private CustomerDto customerDto; + + @BeforeEach + void setUp() { + customerEntity = new CustomerEntity(); + customerEntity.setId(customerId); + + customerDto = new CustomerDto(); + customerDto.setCustomerName("John Doe"); + + when(customerService.findAll()).thenReturn(Collections.singletonList(customerEntity)); + when(customerService.findById(customerId)).thenReturn(Optional.of(customerEntity)); + when(customerMapper.toDto(any(CustomerEntity.class))).thenReturn(customerDto); + } + + @Nested + @DisplayName("Get All Customers Tests") + class GetAllCustomersTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /Customer - Should return 200 and List of Customers") + void getAllCustomers_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/Customer") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$[0].customerName").value("John Doe")); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /Customer - Should handle pagination") + void getAllCustomers_WithPagination_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/Customer") + .param("limit", "10") + .param("offset", "0") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("GET /Customer - Should return 401 for unauthenticated") + void getAllCustomers_Unauthenticated_Returns401() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/Customer") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("Get Customer By ID Tests") + class GetCustomerByIdTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /Customer/{id} - Should return 200 and Customer") + void getCustomer_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/Customer/" + customerId) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.customerName").value("John Doe")); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /Customer/{id} - Should return 404 for unknown ID") + void getCustomer_UnknownId_Returns404() throws Exception { + UUID unknownId = UUID.randomUUID(); + when(customerService.findById(unknownId)).thenReturn(Optional.empty()); + + mockMvc.perform(get("/espi/1_1/resource/Customer/" + unknownId) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isNotFound()); + } + } +} diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryControllerTest.java new file mode 100644 index 00000000..1d48539e --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryControllerTest.java @@ -0,0 +1,144 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import org.greenbuttonalliance.espi.common.domain.usage.ElectricPowerQualitySummaryEntity; +import org.greenbuttonalliance.espi.common.dto.usage.ElectricPowerQualitySummaryDto; +import org.greenbuttonalliance.espi.common.mapper.usage.ElectricPowerQualitySummaryMapper; +import org.greenbuttonalliance.espi.common.repositories.usage.ElectricPowerQualitySummaryRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class ElectricPowerQualitySummaryControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ElectricPowerQualitySummaryRepository electricPowerQualitySummaryRepository; + + @MockitoBean + private ElectricPowerQualitySummaryMapper electricPowerQualitySummaryMapper; + + private final UUID summaryId = UUID.randomUUID(); + private ElectricPowerQualitySummaryEntity summaryEntity; + private ElectricPowerQualitySummaryDto summaryDto; + + @BeforeEach + void setUp() { + summaryEntity = new ElectricPowerQualitySummaryEntity(); + summaryEntity.setId(summaryId); + + summaryDto = new ElectricPowerQualitySummaryDto(); + summaryDto.setFlickerPst(123L); + + Page page = new PageImpl<>(Collections.singletonList(summaryEntity)); + + when(electricPowerQualitySummaryRepository.findAll(any(Pageable.class))).thenReturn(page); + when(electricPowerQualitySummaryRepository.findById(summaryId)).thenReturn(Optional.of(summaryEntity)); + when(electricPowerQualitySummaryMapper.toDto(any(ElectricPowerQualitySummaryEntity.class))).thenReturn(summaryDto); + } + + @Nested + @DisplayName("Get All Electric Power Quality Summaries Tests") + class GetAllSummariesTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /ElectricPowerQualitySummary - Should return 200 and List of Summaries") + void getAllSummaries_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/ElectricPowerQualitySummary") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$[0].flickerPst").value(123)); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /ElectricPowerQualitySummary - Should handle pagination") + void getAllSummaries_WithPagination_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/ElectricPowerQualitySummary") + .param("limit", "10") + .param("offset", "0") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("GET /ElectricPowerQualitySummary - Should return 401 for unauthenticated") + void getAllSummaries_Unauthenticated_Returns401() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/ElectricPowerQualitySummary") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("Get Electric Power Quality Summary By ID Tests") + class GetSummaryByIdTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /ElectricPowerQualitySummary/{id} - Should return 200 and Summary") + void getSummary_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/ElectricPowerQualitySummary/" + summaryId) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.flickerPst").value(123)); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /ElectricPowerQualitySummary/{id} - Should return 404 for unknown ID") + void getSummary_UnknownId_Returns404() throws Exception { + UUID unknownId = UUID.randomUUID(); + when(electricPowerQualitySummaryRepository.findById(unknownId)).thenReturn(Optional.empty()); + + mockMvc.perform(get("/espi/1_1/resource/ElectricPowerQualitySummary/" + unknownId) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isNotFound()); + } + } +} diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockControllerTest.java new file mode 100644 index 00000000..98b84b5a --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockControllerTest.java @@ -0,0 +1,139 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import org.greenbuttonalliance.espi.common.domain.usage.IntervalBlockEntity; +import org.greenbuttonalliance.espi.common.dto.usage.IntervalBlockDto; +import org.greenbuttonalliance.espi.common.mapper.usage.IntervalBlockMapper; +import org.greenbuttonalliance.espi.common.repositories.usage.IntervalBlockRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class IntervalBlockControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private IntervalBlockRepository intervalBlockRepository; + + @MockitoBean + private IntervalBlockMapper intervalBlockMapper; + + private final UUID intervalBlockId = UUID.randomUUID(); + private IntervalBlockEntity intervalBlockEntity; + private IntervalBlockDto intervalBlockDto; + + @BeforeEach + void setUp() { + intervalBlockEntity = new IntervalBlockEntity(); + intervalBlockEntity.setId(intervalBlockId); + + intervalBlockDto = new IntervalBlockDto(); + // Set some dummy data if needed for assertions + + when(intervalBlockRepository.findById(any(UUID.class))).thenReturn(Optional.of(intervalBlockEntity)); + when(intervalBlockRepository.findAll(any(Pageable.class))).thenReturn(new PageImpl<>(Collections.singletonList(intervalBlockEntity))); + when(intervalBlockMapper.toDto(any(IntervalBlockEntity.class))).thenReturn(intervalBlockDto); + } + + @Nested + @DisplayName("Get All Interval Blocks Tests") + class GetAllIntervalBlocksTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /IntervalBlock - Should return 200 and List of IntervalBlocks") + void getAllIntervalBlocks_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/IntervalBlock") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON_VALUE)); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /IntervalBlock - Should handle pagination parameters") + void getAllIntervalBlocks_WithPagination_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/IntervalBlock") + .param("limit", "10") + .param("offset", "0") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("GET /IntervalBlock - Should return 401 for unauthenticated user") + void getAllIntervalBlocks_Unauthenticated_Returns401() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/IntervalBlock") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("Get Interval Block By ID Tests") + class GetIntervalBlockByIdTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /IntervalBlock/{id} - Should return 200 and IntervalBlock") + void getIntervalBlock_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/IntervalBlock/" + intervalBlockId) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON_VALUE)); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /IntervalBlock/{id} - Should return 404 for unknown ID") + void getIntervalBlock_UnknownId_Returns404() throws Exception { + UUID unknownId = UUID.randomUUID(); + when(intervalBlockRepository.findById(unknownId)).thenReturn(Optional.empty()); + + mockMvc.perform(get("/espi/1_1/resource/IntervalBlock/" + unknownId) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isNotFound()); + } + } +} diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ManageControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ManageControllerTest.java new file mode 100644 index 00000000..46f940e9 --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ManageControllerTest.java @@ -0,0 +1,99 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class ManageControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Nested + @DisplayName("Manage Command Tests") + class ManageCommandTests { + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /manage - Should return 400 for missing command") + void doCommand_MissingCommand_Returns400() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/DataCustodian/manage") + .accept(MediaType.TEXT_PLAIN_VALUE)) + .andExpect(status().isBadRequest()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Missing 'command' parameter"))); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /manage - Should return 400 for unsupported command") + void doCommand_UnsupportedCommand_Returns400() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/DataCustodian/manage") + .param("command", "unsupportedCommand") + .accept(MediaType.TEXT_PLAIN_VALUE)) + .andExpect(status().isBadRequest()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Unsupported command"))); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /manage - Should handle resetDataCustodianDB request") + void doCommand_ResetCommand_ReturnsResponse() throws Exception { + // Even if the script doesn't exist, we expect a response indicating it tried or failed to start + mockMvc.perform(get("/espi/1_1/resource/DataCustodian/manage") + .param("command", "resetDataCustodianDB") + .accept(MediaType.TEXT_PLAIN_VALUE)) + .andExpect(status().is(org.hamcrest.Matchers.oneOf(200, 500))); + } + + @Test + @DisplayName("GET /manage - Should return 401 for unauthenticated user") + void doCommand_Unauthenticated_Returns401() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/DataCustodian/manage") + .param("command", "resetDataCustodianDB") + .accept(MediaType.TEXT_PLAIN_VALUE)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "SCOPE_RetailCustomer_Read_Access") + @DisplayName("GET /manage - Should return 403 for insufficient authority") + void doCommand_Forbidden_Returns403() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/DataCustodian/manage") + .param("command", "resetDataCustodianDB") + .accept(MediaType.TEXT_PLAIN_VALUE)) + .andExpect(status().isForbidden()); + } + } +} diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingControllerTest.java new file mode 100644 index 00000000..67a611d9 --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingControllerTest.java @@ -0,0 +1,148 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import org.greenbuttonalliance.espi.common.domain.usage.MeterReadingEntity; +import org.greenbuttonalliance.espi.common.dto.usage.MeterReadingDto; +import org.greenbuttonalliance.espi.common.mapper.usage.MeterReadingMapper; +import org.greenbuttonalliance.espi.common.repositories.usage.MeterReadingRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Collections; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class MeterReadingControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private MeterReadingRepository meterReadingRepository; + + @MockitoBean + private MeterReadingMapper meterReadingMapper; + + @MockitoBean + private org.greenbuttonalliance.espi.datacustodian.web.api.support.ApiRequestValidator requestValidator; + + private UUID meterReadingId; + private MeterReadingEntity meterReadingEntity; + private MeterReadingDto meterReadingDto; + + @BeforeEach + void setUp() { + meterReadingId = UUID.randomUUID(); + meterReadingEntity = new MeterReadingEntity(); + meterReadingEntity.setId(meterReadingId); + + meterReadingDto = new MeterReadingDto(); + + Page page = new PageImpl<>(Collections.singletonList(meterReadingEntity)); + + when(requestValidator.toPageable(eq(50), eq(0))).thenReturn(PageRequest.of(0, 50)); + when(requestValidator.toPageable(eq(50), eq(-1))) + .thenThrow(new ResponseStatusException(BAD_REQUEST, "'offset' must be 0 or greater")); + when(meterReadingRepository.findAll(any(Pageable.class))).thenReturn(page); + when(meterReadingRepository.findById(meterReadingId)).thenReturn(Optional.of(meterReadingEntity)); + when(meterReadingMapper.toDto(any(MeterReadingEntity.class))).thenReturn(meterReadingDto); + } + + @Nested + @DisplayName("GET /MeterReading") + class GetAllTests { + @Test + @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party") + void getAll_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/MeterReading") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON_VALUE)); + } + + @Test + @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party") + void getAll_InvalidOffset_Returns400() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/MeterReading") + .param("offset", "-1") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isBadRequest()); + } + + @Test + void getAll_Unauthenticated_Returns401() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/MeterReading") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("GET /MeterReading/{id}") + class GetByIdTests { + @Test + @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party") + void getById_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/MeterReading/{id}", meterReadingId) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON_VALUE)); + } + + @Test + @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party") + void getById_NotFound_Returns404() throws Exception { + UUID unknownId = UUID.randomUUID(); + when(meterReadingRepository.findById(unknownId)).thenReturn(Optional.empty()); + + mockMvc.perform(get("/espi/1_1/resource/MeterReading/{id}", unknownId) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isNotFound()); + } + } +} + diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeControllerTest.java new file mode 100644 index 00000000..62e71a1c --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeControllerTest.java @@ -0,0 +1,138 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import org.greenbuttonalliance.espi.common.domain.usage.ReadingTypeEntity; +import org.greenbuttonalliance.espi.common.dto.usage.ReadingTypeDto; +import org.greenbuttonalliance.espi.common.mapper.usage.ReadingTypeMapper; +import org.greenbuttonalliance.espi.common.service.ReadingTypeService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class ReadingTypeControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ReadingTypeService readingTypeService; + + @MockitoBean + private ReadingTypeMapper readingTypeMapper; + + private final UUID readingTypeId = UUID.randomUUID(); + private ReadingTypeEntity readingTypeEntity; + private ReadingTypeDto readingTypeDto; + + @BeforeEach + void setUp() { + readingTypeEntity = new ReadingTypeEntity(); + readingTypeEntity.setId(readingTypeId); + + readingTypeDto = new ReadingTypeDto(); + readingTypeDto.setAccumulationBehaviour("SUMMATION"); + + when(readingTypeService.findById(any(UUID.class))).thenReturn(readingTypeEntity); + when(readingTypeService.findAll()).thenReturn(Collections.singletonList(readingTypeEntity)); + when(readingTypeMapper.toDto(any(ReadingTypeEntity.class))).thenReturn(readingTypeDto); + } + + @Nested + @DisplayName("Get All Reading Types Tests") + class GetAllReadingTypesTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /ReadingType - Should return 200 and List of ReadingTypes") + void getAllReadingTypes_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/ReadingType") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$[0].accumulationBehaviour").value("SUMMATION")); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /ReadingType - Should handle pagination parameters") + void getAllReadingTypes_WithPagination_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/ReadingType") + .param("limit", "10") + .param("offset", "0") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("GET /ReadingType - Should return 401 for unauthenticated user") + void getAllReadingTypes_Unauthenticated_Returns401() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/ReadingType") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("Get Reading Type By ID Tests") + class GetReadingTypeByIdTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /ReadingType/{id} - Should return 200 and ReadingType") + void getReadingType_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/ReadingType/" + readingTypeId) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.accumulationBehaviour").value("SUMMATION")); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /ReadingType/{id} - Should return 404 for unknown ID") + void getReadingType_UnknownId_Returns404() throws Exception { + UUID unknownId = UUID.randomUUID(); + when(readingTypeService.findById(unknownId)).thenReturn(null); + + mockMvc.perform(get("/espi/1_1/resource/ReadingType/" + unknownId) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isNotFound()); + } + } +} diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/RetailCustomerControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/RetailCustomerControllerTest.java new file mode 100644 index 00000000..6e1eb276 --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/RetailCustomerControllerTest.java @@ -0,0 +1,144 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import org.greenbuttonalliance.espi.common.domain.usage.SubscriptionEntity; +import org.greenbuttonalliance.espi.common.domain.usage.RetailCustomerEntity; +import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto; +import org.greenbuttonalliance.espi.common.dto.atom.CustomerAtomEntryDto; +import org.greenbuttonalliance.espi.common.service.DtoExportService; +import org.greenbuttonalliance.espi.common.service.RetailCustomerService; +import org.greenbuttonalliance.espi.common.service.SubscriptionService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class RetailCustomerControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private RetailCustomerService retailCustomerService; + + @MockitoBean + private DtoExportService exportService; + + @MockitoBean + private SubscriptionService subscriptionService; + + private final Long retailCustomerId = 1L; + private final java.util.UUID subscriptionId = java.util.UUID.randomUUID(); + + @BeforeEach + void setUp() { + RetailCustomerEntity customer = new RetailCustomerEntity(); + customer.setId(retailCustomerId); + customer.setFirstName("John"); + customer.setLastName("Doe"); + when(retailCustomerService.findById(retailCustomerId)).thenReturn(customer); + + when(exportService.createRetailCustomersFeed()).thenReturn(new AtomFeedDto()); + when(exportService.createRetailCustomerEntry(any(RetailCustomerEntity.class))).thenReturn(new CustomerAtomEntryDto()); + + SubscriptionEntity subscription = new SubscriptionEntity(); + subscription.setId(subscriptionId); + subscription.setRetailCustomer(customer); + + when(subscriptionService.findById(subscriptionId)).thenReturn(subscription); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /RetailCustomer - Should return 200 and Atom XML") + void index_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/RetailCustomer") + .accept(MediaType.APPLICATION_ATOM_XML_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_ATOM_XML_VALUE)); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /RetailCustomer/{id} - Should return 200 and Atom XML") + void show_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/RetailCustomer/" + retailCustomerId) + .accept(MediaType.APPLICATION_ATOM_XML_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_ATOM_XML_VALUE)); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("GET /RetailCustomer/{id} - Should return 404 when not found") + void show_Returns404() throws Exception { + when(retailCustomerService.findById(retailCustomerId + 1)).thenReturn(null); + + mockMvc.perform(get("/espi/1_1/resource/RetailCustomer/" + (retailCustomerId + 1)) + .accept(MediaType.APPLICATION_ATOM_XML_VALUE)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("POST /RetailCustomer - Should return 501 Not Implemented") + void create_Returns501() throws Exception { + mockMvc.perform(post("/espi/1_1/resource/RetailCustomer") + .contentType(MediaType.APPLICATION_ATOM_XML_VALUE) + .content("")) + .andExpect(status().isNotImplemented()); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("PUT /RetailCustomer/{id} - Should return 501 Not Implemented") + void update_Returns501() throws Exception { + mockMvc.perform(put("/espi/1_1/resource/RetailCustomer/" + retailCustomerId) + .contentType(MediaType.APPLICATION_ATOM_XML_VALUE) + .content("")) + .andExpect(status().isNotImplemented()); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("DELETE /RetailCustomer/{id} - Should return 501 Not Implemented") + void delete_Returns501() throws Exception { + mockMvc.perform(delete("/espi/1_1/resource/RetailCustomer/" + retailCustomerId)) + .andExpect(status().isNotImplemented()); + } +} diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ServiceStatusControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ServiceStatusControllerTest.java new file mode 100644 index 00000000..fd2e1ad5 --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ServiceStatusControllerTest.java @@ -0,0 +1,104 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import org.greenbuttonalliance.espi.common.domain.usage.ApplicationInformationEntity; +import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity; +import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.usage.ServiceStatusDto; +import org.greenbuttonalliance.espi.common.service.AuthorizationService; +import org.greenbuttonalliance.espi.common.service.DtoExportService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@DisplayName("ServiceStatusController Tests") +public class ServiceStatusControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AuthorizationService authorizationService; + + @MockitoBean + private DtoExportService exportService; + + private static final String SERVICE_STATUS_URL = "/espi/1_1/resource/ServiceStatus"; + + @Test + @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party") + @DisplayName("index returns 200 and Atom entry for authorized user") + void index_Returns200() throws Exception { + String status = "1"; + + AtomEntryDto entry = new UsageAtomEntryDto(UUID.randomUUID().toString(), "ServiceStatus", + new ServiceStatusDto(status)); + when(exportService.createServiceStatusEntry(anyString())).thenReturn(entry); + + mockMvc.perform(get(SERVICE_STATUS_URL) + .accept(MediaType.APPLICATION_ATOM_XML_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_ATOM_XML_VALUE)); + } + + @Test + @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party") + @DisplayName("index returns 200 with default status '0' when no token provided") + void index_NoToken_ReturnsDefaultStatus() throws Exception { + String defaultStatus = "0"; + + AtomEntryDto entry = new UsageAtomEntryDto(UUID.randomUUID().toString(), "ServiceStatus", + new ServiceStatusDto(defaultStatus)); + when(exportService.createServiceStatusEntry(defaultStatus)).thenReturn(entry); + + mockMvc.perform(get(SERVICE_STATUS_URL) + .accept(MediaType.APPLICATION_ATOM_XML_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_ATOM_XML_VALUE)); + } + + @Test + @DisplayName("index returns 401 for unauthenticated user") + void index_Unauthenticated_Returns401() throws Exception { + mockMvc.perform(get(SERVICE_STATUS_URL) + .accept(MediaType.APPLICATION_ATOM_XML_VALUE)) + .andExpect(status().isUnauthorized()); + } +} diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/TimeConfigurationControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/TimeConfigurationControllerTest.java new file mode 100644 index 00000000..5ec9f97b --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/TimeConfigurationControllerTest.java @@ -0,0 +1,171 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import org.greenbuttonalliance.espi.common.domain.usage.TimeConfigurationEntity; +import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto; +import org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.usage.TimeConfigurationDto; +import org.greenbuttonalliance.espi.common.mapper.usage.TimeConfigurationMapper; +import org.greenbuttonalliance.espi.common.repositories.usage.TimeConfigurationRepository; +import org.greenbuttonalliance.espi.common.service.DtoExportService; +import org.greenbuttonalliance.espi.common.service.TimeConfigurationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@DisplayName("TimeConfigurationController Tests") +public class TimeConfigurationControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private TimeConfigurationRepository timeConfigurationRepository; + + @MockitoBean + private TimeConfigurationMapper timeConfigurationMapper; + + @MockitoBean + private TimeConfigurationService timeConfigurationService; + + @MockitoBean + private DtoExportService exportService; + + private static final String BASE_URL = "/espi/1_1/resource/TimeConfiguration"; + private UUID timeConfigurationId; + + @BeforeEach + void setUp() { + timeConfigurationId = UUID.randomUUID(); + } + + @Nested + @DisplayName("GET " + BASE_URL) + class IndexTests { + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("index returns 200 and Atom feed") + void index_Returns200() throws Exception { + AtomFeedDto feed = new AtomFeedDto(UUID.randomUUID().toString(), "Time Configurations", + OffsetDateTime.now(), OffsetDateTime.now(), null, new ArrayList<>()); + when(exportService.createTimeConfigurationsFeed()).thenReturn(feed); + + mockMvc.perform(get(BASE_URL) + .accept(MediaType.APPLICATION_ATOM_XML_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_ATOM_XML_VALUE)); + } + } + + @Nested + @DisplayName("GET " + BASE_URL + "/{timeConfigurationId}") + class ShowTests { + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("show returns 200 and Atom entry") + void show_Returns200() throws Exception { + AtomEntryDto entry = new UsageAtomEntryDto(UUID.randomUUID().toString(), "Time Configuration", + OffsetDateTime.now(), OffsetDateTime.now(), null, new TimeConfigurationDto()); + when(exportService.createTimeConfigurationEntry(timeConfigurationId)).thenReturn(entry); + + mockMvc.perform(get(BASE_URL + "/{id}", timeConfigurationId) + .accept(MediaType.APPLICATION_ATOM_XML_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_ATOM_XML_VALUE)); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("show returns 404 when not found") + void show_Returns404() throws Exception { + when(exportService.createTimeConfigurationEntry(timeConfigurationId)).thenReturn(null); + + mockMvc.perform(get(BASE_URL + "/{id}", timeConfigurationId) + .accept(MediaType.APPLICATION_ATOM_XML_VALUE)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("POST " + BASE_URL) + class CreateTests { + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("create returns 501 Not Implemented") + void create_Returns501() throws Exception { + mockMvc.perform(post(BASE_URL) + .contentType(MediaType.APPLICATION_ATOM_XML_VALUE) + .content("")) + .andExpect(status().isNotImplemented()); + } + } + + @Nested + @DisplayName("DELETE " + BASE_URL + "/{timeConfigurationId}") + class DeleteTests { + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("delete returns 204 No Content when successful") + void delete_Returns204() throws Exception { + TimeConfigurationEntity entity = new TimeConfigurationEntity(); + when(timeConfigurationService.findById(timeConfigurationId)).thenReturn(entity); + + mockMvc.perform(delete(BASE_URL + "/{id}", timeConfigurationId)) + .andExpect(status().isNoContent()); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("delete returns 404 when not found") + void delete_Returns404() throws Exception { + when(timeConfigurationService.findById(timeConfigurationId)).thenReturn(null); + + mockMvc.perform(delete(BASE_URL + "/{id}", timeConfigurationId)) + .andExpect(status().isNotFound()); + } + } +} diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointControllerTest.java new file mode 100644 index 00000000..f22c7bef --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointControllerTest.java @@ -0,0 +1,171 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import org.greenbuttonalliance.espi.common.domain.usage.UsagePointEntity; +import org.greenbuttonalliance.espi.common.dto.usage.UsagePointDto; +import org.greenbuttonalliance.espi.common.mapper.usage.UsagePointMapper; +import org.greenbuttonalliance.espi.common.repositories.usage.UsagePointRepository; +import org.greenbuttonalliance.espi.common.service.SubscriptionService; +import org.greenbuttonalliance.espi.datacustodian.web.api.support.ApiAccessValidator; +import org.greenbuttonalliance.espi.datacustodian.web.api.support.ApiRequestValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Collections; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class UsagePointControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private UsagePointRepository usagePointRepository; + + @MockitoBean + private UsagePointMapper usagePointMapper; + + @MockitoBean + private SubscriptionService subscriptionService; + + @MockitoBean + private ApiRequestValidator requestValidator; + + @MockitoBean + private ApiAccessValidator accessValidator; + + private UUID usagePointId; + private UUID subscriptionId; + private UsagePointEntity usagePointEntity; + private UsagePointDto usagePointDto; + + @BeforeEach + void setUp() { + usagePointId = UUID.randomUUID(); + subscriptionId = UUID.randomUUID(); + + usagePointEntity = new UsagePointEntity(); + usagePointEntity.setId(usagePointId); + usagePointDto = new UsagePointDto(); + + Page page = new PageImpl<>(Collections.singletonList(usagePointEntity)); + + when(accessValidator.isAdmin(any())).thenReturn(true); + when(requestValidator.toPageable(eq(50), eq(0))).thenReturn(PageRequest.of(0, 50)); + when(requestValidator.toPageable(eq(0), eq(0))) + .thenThrow(new ResponseStatusException(BAD_REQUEST, "'limit' must be greater than 0")); + + when(usagePointRepository.findAll(any(Pageable.class))).thenReturn(page); + when(usagePointRepository.findById(usagePointId)).thenReturn(Optional.of(usagePointEntity)); + when(usagePointMapper.toDto(any(UsagePointEntity.class))).thenReturn(usagePointDto); + } + + @Nested + @DisplayName("GET /UsagePoint") + class GetAllTests { + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + void getAll_AsAdmin_Returns200() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/UsagePoint") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON_VALUE)); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + void getAll_InvalidLimit_Returns400() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/UsagePoint") + .param("limit", "0") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party") + void getAll_ThirdPartyWithoutToken_Returns403() throws Exception { + when(accessValidator.isAdmin(any())).thenReturn(false); + when(accessValidator.requireSubscriptionId(eq(null))) + .thenThrow(new ResponseStatusException(FORBIDDEN, "Bearer token is required")); + + mockMvc.perform(get("/espi/1_1/resource/UsagePoint") + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isForbidden()); + } + } + + @Nested + @DisplayName("GET /Subscription/{subscriptionId}/UsagePoint") + class SubscriptionTests { + @Test + @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party") + void getSubscriptionUsagePoints_AccessDenied_Returns403() throws Exception { + doThrow(new ResponseStatusException(FORBIDDEN, "Token is not authorized for requested subscription")) + .when(accessValidator).enforceSubscriptionPathAccess(any(), eq(null), eq(subscriptionId)); + + mockMvc.perform(get("/espi/1_1/resource/Subscription/{subscriptionId}/UsagePoint", subscriptionId) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party") + void getSubscriptionUsagePoint_NotFound_Returns404() throws Exception { + doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "UsagePoint not found in subscription")) + .when(accessValidator).enforceUsagePointInSubscription(eq(subscriptionId), eq(usagePointId)); + + mockMvc.perform(get("/espi/1_1/resource/Subscription/{subscriptionId}/UsagePoint/{usagePointId}", + subscriptionId, usagePointId) + .accept(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isNotFound()); + } + } +} + diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryControllerTest.java new file mode 100644 index 00000000..eacaa23b --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryControllerTest.java @@ -0,0 +1,217 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.api; + +import org.greenbuttonalliance.espi.common.domain.usage.UsagePointEntity; +import org.greenbuttonalliance.espi.common.domain.usage.UsageSummaryEntity; +import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto; +import org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto; +import org.greenbuttonalliance.espi.common.service.DtoExportService; +import org.greenbuttonalliance.espi.common.service.SubscriptionService; +import org.greenbuttonalliance.espi.common.service.UsageSummaryService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.UUID; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@DisplayName("UsageSummaryController Tests") +public class UsageSummaryControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private UsageSummaryService usageSummaryService; + + @MockitoBean + private DtoExportService exportService; + + @MockitoBean + private SubscriptionService subscriptionService; + + private UUID usageSummaryId; + private UUID usagePointId; + private UUID subscriptionId; + private UsageSummaryEntity usageSummaryEntity; + private UsagePointEntity usagePointEntity; + + @BeforeEach + void setUp() { + usageSummaryId = UUID.randomUUID(); + usagePointId = UUID.randomUUID(); + subscriptionId = UUID.randomUUID(); + + usagePointEntity = new UsagePointEntity(); + usagePointEntity.setId(usagePointId); + + usageSummaryEntity = new UsageSummaryEntity(); + usageSummaryEntity.setId(usageSummaryId); + usageSummaryEntity.setUsagePoint(usagePointEntity); + + when(subscriptionService.findRetailCustomerId(subscriptionId, usagePointId)).thenReturn(1L); + } + + @Nested + @DisplayName("GET /espi/1_1/resource/UsageSummary") + class IndexTests { + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("index returns 200 and Atom feed for admin") + void index_Returns200() throws Exception { + when(exportService.createUsageSummariesFeed()).thenReturn(new AtomFeedDto()); + + mockMvc.perform(get("/espi/1_1/resource/UsageSummary") + .accept(MediaType.APPLICATION_ATOM_XML)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_ATOM_XML)); + } + + @Test + @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party") + @DisplayName("index returns 403 for non-admin") + void index_Returns403ForNonAdmin() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/UsageSummary")) + .andExpect(status().isForbidden()); + } + } + + @Nested + @DisplayName("GET /espi/1_1/resource/UsageSummary/{usageSummaryId}") + class ShowTests { + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("show returns 200 and Atom entry when found") + void show_Returns200() throws Exception { + when(usageSummaryService.findById(usageSummaryId)).thenReturn(usageSummaryEntity); + when(exportService.createUsageSummaryEntry(usageSummaryEntity)).thenReturn(new UsageAtomEntryDto()); + + mockMvc.perform(get("/espi/1_1/resource/UsageSummary/{usageSummaryId}", usageSummaryId) + .accept(MediaType.APPLICATION_ATOM_XML)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_ATOM_XML)); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("show returns 404 when not found") + void show_Returns404() throws Exception { + when(usageSummaryService.findById(usageSummaryId)).thenReturn(null); + + mockMvc.perform(get("/espi/1_1/resource/UsageSummary/{usageSummaryId}", usageSummaryId)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("GET /espi/1_1/resource/Subscription/{subscriptionId}/UsagePoint/{usagePointId}/UsageSummary") + class NestedIndexTests { + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("indexByUsagePoint returns 403 without bearer token") + void indexByUsagePoint_Returns200() throws Exception { + when(exportService.createUsageSummariesFeedByUsagePointId(usagePointId)).thenReturn(new AtomFeedDto()); + + mockMvc.perform(get("/espi/1_1/resource/Subscription/{subscriptionId}/UsagePoint/{usagePointId}/UsageSummary", + subscriptionId, usagePointId) + .accept(MediaType.APPLICATION_ATOM_XML)) + .andExpect(status().isForbidden()); + } + } + + @Nested + @DisplayName("GET /espi/1_1/resource/Subscription/{subscriptionId}/UsagePoint/{usagePointId}/UsageSummary/{usageSummaryId}") + class NestedShowTests { + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("showByUsagePoint returns 403 without bearer token") + void showByUsagePoint_Returns200() throws Exception { + when(usageSummaryService.findById(usageSummaryId)).thenReturn(usageSummaryEntity); + when(exportService.createUsageSummaryEntry(usageSummaryEntity)).thenReturn(new UsageAtomEntryDto()); + + mockMvc.perform(get("/espi/1_1/resource/Subscription/{subscriptionId}/UsagePoint/{usagePointId}/UsageSummary/{usageSummaryId}", + subscriptionId, usagePointId, usageSummaryId) + .accept(MediaType.APPLICATION_ATOM_XML)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("showByUsagePoint returns 403 without bearer token") + void showByUsagePoint_Returns404_WrongUsagePoint() throws Exception { + UUID otherUsagePointId = UUID.randomUUID(); + when(usageSummaryService.findById(usageSummaryId)).thenReturn(usageSummaryEntity); + + mockMvc.perform(get("/espi/1_1/resource/Subscription/{subscriptionId}/UsagePoint/{usagePointId}/UsageSummary/{usageSummaryId}", + subscriptionId, otherUsagePointId, usageSummaryId)) + .andExpect(status().isForbidden()); + } + } + + @Nested + @DisplayName("Unimplemented Operations") + class UnimplementedTests { + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("POST returns 501") + void create_Returns501() throws Exception { + mockMvc.perform(post("/espi/1_1/resource/UsageSummary")) + .andExpect(status().isNotImplemented()); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("PUT returns 501") + void update_Returns501() throws Exception { + mockMvc.perform(put("/espi/1_1/resource/UsageSummary/{usageSummaryId}", usageSummaryId)) + .andExpect(status().isNotImplemented()); + } + + @Test + @WithMockUser(authorities = "SCOPE_DataCustodian_Admin_Access") + @DisplayName("DELETE returns 501") + void delete_Returns501() throws Exception { + mockMvc.perform(delete("/espi/1_1/resource/UsageSummary/{usageSummaryId}", usageSummaryId)) + .andExpect(status().isNotImplemented()); + } + } +} diff --git a/openespi-datacustodian/temp.txt b/openespi-datacustodian/temp.txt new file mode 100644 index 00000000..55933491 --- /dev/null +++ b/openespi-datacustodian/temp.txt @@ -0,0 +1,145 @@ +Module Objectives +After this module, you will be able to: +describe the impact, risks, and mitigation methods related to aquatic invasive species. +outline the federal and state regulations regarding environmental protection and methods for adhering to those requirements. +describe actions you can take to prevent and respond to aquatic invasive species. +In this module, you will cover the Clean Boating Act, the impact of pollution and invasive species on the ecosystem, and how you can preserve habitats as a recreational boater. +Aquatic Invasive Species +Aquatic invasive species are animal or plants in waterways that are not native to their current location. According to the U.S. Fish and Wildlife Commission, invasive species have a negative effect on surrounding ecosystems by hunting native species, altering habitats, introducing diseases, and decreasing resources in the area for other species. Some common aquatic invasive species include: +zebra mussels, +lionfish, +algae, +snails, and +quagga mussels. +Recreational boaters can unintentionally spread invasive species beyond their native ecosystems. Aquatic invasive species—especially mussels and algae—may stick to parts of your boat or fishing gear. Common hiding spots include your rudder, anchor, live well, floatation devices, and boat trailer. If you do not take precautions while boating, you may have unwanted guests for the ride who will endanger native aquatic populations. + +To learn about aquatic invasive species in your region, view the Marine Invasive Species map from the National Park Service. +Impact, Risk, and Mitigation +Case Study: Zebra Mussels and the Great Lakes + +The National Oceanic and Atmospheric Administration (NOAA) has identified approximately 180 invasive species in the Great Lakes region. One of the Great Lakes’ aquatic invasive species, zebra mussels, established booming colonies by hitchhiking on vessels. Numbers of zebra mussels are as high as 700,000 members per square meter in certain areas of the Great Lakes, which restricts the movement, population growth, and food of native mussels. + +Zebra mussels, however, negatively impact more than just native species. These mussels are known to restrict the movement of recreational vessels in waterways, as well as inflict damage on boat engines, docks, and boat ramps. The U.S. Fish and Wildlife Commission has reported that the projected impact of zebra mussels in this region may be as high as $5 billion over the next 10 years. +Aquatic invasive species have a far-reaching impact beyond economic damage. Invasive species, such as mussels, can threaten public safety by populating irrigation and drinking systems, decreasing marine sanitation. They can damage boat engines and rudders, as well as docks, ramps, and other public infrastructure. By feeding on limited resources, aquatic invasive species starve local population, forming a competing or even parasitic relationship. Although your state and the federal government have established regulations that address issue of aquatic invasive species, recreational boaters must also follow safety protocols to ensure that these agency efforts are effective. +Prevention and Response +Federal Efforts +The federal legislature has issued several laws and regulations that address the control of aquatic invasive species populations across the United States. Click each tab below to explore how each federal response affects your responsibilities as a recreational boater. +The Lacey Act + +This act, established in 1900, is the oldest eco-conservation legislation in the United States. The Lacey Act prohibits any importation or procurement of aquatic invasive species that may damage habitats, as well as negatively impact the economy. The Lacey Act established a precedent for your responsibilities as a recreational boater in protecting the environment. +Nonindigenous Aquatic Nuisance Prevention and Control Act + +The U.S. federal legislature established the Nonindigenous Aquatic Nuisance Prevention and Control Act in 1990 as a first step towards reducing the populations of aquatic invasive species. This act established the Aquatic Nuisance Species Task Force, which tracked unintentional introductions of invasive species by vessel and advised legislators on relevant legislation. The Aquatic Nuisance Prevention and Control Act also prohibited vessels with ballast tanks from releasing untreated water—which could contain nonindigenous, invasive species, such as zebra mussels—into waterways. +National Invasive Species Act + +The Nonindigenous Aquatic Nuisance Prevention and Control Act was expanded and updated in 1996 with the National Invasive Species Act. The National Invasive Species Act of 1996 still addresses untreated ballast water, empowers the Aquatic Nuisance Species Task Force, and regulates specific regions affected by zebra mussels; however, this legislation now also includes the following initiatives: +Vessels must report where they have released their ballast water. +Target regions for invasive species expand beyond the Great Lakes ecosystem. +The Aquatic Task Force may manage state policies for invasive species and are charged with conducting research. +Safeguarding the Nation From the Impacts of Invasive Species (Executive Order 13751) + +This executive order, originally issued in 1999, created the National Invasive Species Council and Invasive Species Advisory Committee. The council assesses the current impact of aquatic invasive species in waterways and executes on rapid response plans for controlling invasive species +State Efforts +Depending on your state, you may be required to purchase a decal for your recreational vessel that acknowledges the issue of aquatic invasive species and funds removal efforts. Twelve states have established mandatory decals as a response to invasive species; each state’s regulations can be accessed below: +Wyoming +Oregon +Minnesota +Nevada +Washington +Maine +Nebraska +Colorado +Idaho +Montana +California +Your Responsibility +As a recreational vessel operator, you have a responsibility to maintain the integrity of your local ecosystem by limiting your negative impact where possible. Your role in environmental protection extends to preventing the spread of aquatic invasive species while boating. To avoid this type of accidental introduction, take the following precautions: +Inspect your vessel and equipment for invasive hitchhikers before and after use. +Clean and drain your vessel and equipment after use. +Allow your vessel and equipment to dry completely before the next use. To be most effective, plan to dry out your boat for about a week to eradicate any hitchhiking aquatic invasive species. +Dispose any found aquatic invasive species such that they don’t return to the water. +Remember: Prevention is key. Once an aquatic invasive inhabits an ecosystem, they are often difficult or impossible to eliminate. Even the smallest vessel can transfer nonnative species to new habitats. +Littering and Environmental Protection +According to the Environmental Protection Agency (EPA), recreational vessel operators accumulate 3.5 billion gallons of untreated sewage each year. Pollution and littering on this scale can: +decrease available oxygen, +change the water’s pH, +injure or kill wildlife, +decrease resources for local species, +damage aquatic habitats, +introduce toxins into the water, +affect consumed foods from marine sources, +impact local tourism, and +block the path of vessels. +Recreational vessels often negatively impact the environment by discarding plastics; sewage; fishing nets, hooks, and lines; and other trash into waterways. Take a look at some of the sources of marine debris and their impact on the ecosystem in the infographic below: + +To mitigate the impact of water pollution, the EPA established the Clean Boating Act of 2008, which prohibits recreational vessel operators from discharging untreated waste into the surrounding environment. This act is applicable in waters within twelve miles of the shoreline and affects vessel operators and owners alike. While the act exists under the EPA, the U.S. Coast Guard can enforce its standards. +The U.S. Coast Guard also requires all recreational boat operators that have installed toilet systems to follow these regulations: +Toilets should have an approved marine sanitation device that treats sewage before discharge. +Operators may not discharge any type of sewage—even treated—in designated no-discharge zones. +Recreational vessel operators may not discharge sewage in any freshwater body of water that cannot be traveled through by non-recreational vessels. +No-Discharge Zone Compliance +Recreational vessels must take extra precautions in designated no-discharge zones so that no sewage is accidentally discharged. These zones may contain endangered species, potable water for human populations, or habitats for reproducing species (i.e. shellfish beds). Recreational vessels with installed toilets may require different protocols depending on the type of system. +Flow-Through Device System +To completely seal a toilet equipped with a flow-through device and prevent any discharge leaks, follow one of the below steps: +Close the hull valve to obstruct path of discharge from the boat, and disengage the handle. +Apply a padlock to shut the outtake valve. +Use a wire-tie to shut the outtake valve securely. +Lock the toilet lid closed. +Holding Tank System +Holding tanks require one of two methods to prevent accidental discharge into no-discharge zones: +Shut the y-valve on the holding tank and disengage the handle, or +Padlock or otherwise secure the valve in the closed position. +Types of Marine Sanitation Devices +If your recreational vessel is equipped with a toilet, you should be aware of the type of marine sanitation device that it uses. There are three main types of marine sanitation devices that correspond to both the size of your vessel and your type of toilet system. Click on the tabs below to learn more: +Type I +Type II +Type III +This type of marine sanitation device is used for vessels under 65 feet in length that have toilets equipped with flow-through devices. Sanitation for these toilets must use disinfectants that reduce bacteria to under 1,000 fecal coliforms for every 100 mL of water. +Pumping Out a Holding Tank System + +When you are at an approved pumpout station or have access to mobile pumpout services, follow these steps to safely discharge untreated sewage: +Ensure that the valve on the pumpout hose is in the closed position. +Insert the pumpout hose’s nozzle into the deck waste fitting, attaching the fitting to the nozzle guard as applicable. +Start the pump. +Open the pumpout hose’s nozzle valve. +Ensure that the nozzle is pumping sewage properly. If not, troubleshoot by cleaning out the nozzle and/or looking for obstructions. +Remove the nozzle from the deck waste fitting after pumping is complete. +Submerge the nozzle in water for at least thirty seconds to rinse out the pumpout hose. +Close the pumpout hose’s valve, turn off the pump, and return equipment to its original position. +Protecting Your Environment +Operators of recreational vessels equipped with toilets can take a few safety precautions to avoid polluting waterways. These precautions are outlined below: +Educate yourself on how your vessel’s sewage system works, and know what type of marine sanitation device your toilet uses. +Maintain your sewage and toilet system. +Follow the manufacturer’s instructions for your marine sanitation device. +If you have a holding tank, visit a pumpout station well before your tank potentially overflows. +Call to ensure that the pumpout station is open before you plan your visit. +Know where no-discharge zones are and avoid discharging treated and untreated sewage in those areas. +Only dispose of sewage in your toilet; use dissolvable sanitation products. +Clean your holding tank on a regular basis. +Use the restroom on land when possible. +Module Review +This concludes Module 9: Protecting the Environment. In this module, you have learned about how recreational vessel operators can prevent aquatic invasive hitchhikers from spreading into their local ecosystem. You also learned about the consequences of invasive species on native populations. You should be able to relate the importance of sanitation measures to the health of humans and other species. Finally, you can now list safety measures that you can take to protect your environment while boating. + +Some key takeaways from this module include the following: +Aquatic invasive species are animals, plants, and microorganisms that are nonnative to their current environment, causing a parasitic relationship with local species and habitats. +Federal laws prohibit the introduction of aquatic invasive species into endangered ecosystems, research the hazards of invasive species, form regulatory bodies to delegate enforcement power, and establishes precedents for future action to protect the environment. Laws around aquatic invasive species also vary by state; twelve states have instituted decals and regulations for recreational boaters to support aquatic invasive task force initiatives. +To avoid spreading aquatic invasive species, you should inspect your vessel before boating; clean, drain, and dry your vessel after boating; and safely dispose of any invasive species that you find in a way that inhibits their path to waterways. +Littering and pollution affects waterways by injuring or killing local aquatic species, affecting the water’s pH, reducing oxygenation, introducing toxins, decreasing resources, damaging habitats, and causing obstructions for vessels. +The U.S. Coast Guard and EPA prohibit recreational vessel operators to discharge untreated waste into waters within 12 miles of the coastline; recreational vessel operators are also not allowed to discharge any type of waste in no-discharge zones. +Toilet systems on recreational vessels may be equipped with flow-through devices or holding tanks. Flow-through devices sanitize waste with either disinfectants or a combination of disinfectants and bacteria. Holding tanks must be safely pumped at designated stations. +To protect the environment as a recreational boat operator, you should avoid littering, discharge waste safely, understand your boat’s toilet system and sanitation measures, maintain your sewage system, plan ahead if your vessel requires pumping services, and use on-land restrooms if possible. + + +2) Which steps must you take to eliminate aquatic invasive species on your vessel? +Inspect your vessel for aquatic invasive species before use. +*** Not correct *** Clean and drain your vessel after use. +Incorrect. +Allow your vessel to dry out completely for at least a week after use. +Dispose of any live organisms into the water before docking. + +In which of the following areas should you discharge sewage treated by Type I or Type II marine sanitation devices? +***NOT Correct *** Water over 12 miles away from the shoreline +Incorrect. +Freshwater areas used by non-recreational vessels +Saltwater areas used by non-recreational vessels +Near habitats of reproducing species \ No newline at end of file