From 681540bf9174c05ee0564a1cec60df06709e0442 Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Sun, 15 Nov 2026 01:20:58 -0500 Subject: [PATCH 1/2] feat(#253): seed a deterministic ESPI sample dataset on the DC local profile LocalSampleDataSeedRunner (local profile only, idempotent, UUID v5 ids from fixed sample hrefs): - RetailCustomer customer/customer (ROLE_USER) owning one electricity UsagePoint with LocalTimeParameters (US Eastern), an hourly kWh ReadingType, a MeterReading with 2 daily IntervalBlocks of 24 hourly readings with cost, a UsageSummary and an ElectricPowerQualitySummary. - Customer, CustomerAccount, CustomerAgreement, ServiceLocation and Meter for one residential customer. - ApplicationInformation for the AS-seeded third_party client with the TP's local URIs. Schema values follow the most common shapes in the Green Button CMD certification test feeds; every identifying value is synthetic (example.com, 555-01xx, SAMPLE- numbers). The data exposed a real defect: with open-in-view off, the IntervalBlock, UsageSummary and CustomerAccount controllers mapped entities outside any session, so a lazy collection (intervalReadings, costAdditionalDetailLastPeriod, notifications) made every non-empty feed 500. Those controllers now run read-only transactional. LocalSampleDataSeedRunnerTest runs the seed against the Flyway-migrated H2 schema and checks that each feed marshals its entries, the customer login and readings, and idempotency. Runbook and both smoke scripts updated (new check: the IntervalBlock feed contains the sample data). Co-Authored-By: Claude Opus 5.5 --- docs/LOCAL_TESTING.md | 19 +- .../sample/LocalSampleDataSeedRunner.java | 450 ++++++++++++++++++ .../api/CustomerAccountRESTController.java | 3 + .../web/api/IntervalBlockRESTController.java | 3 + .../web/api/UsageSummaryRESTController.java | 4 + .../sample/LocalSampleDataSeedRunnerTest.java | 113 +++++ scripts/local/smoke.ps1 | 4 + scripts/local/smoke.sh | 2 + 8 files changed, 596 insertions(+), 2 deletions(-) create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/sample/LocalSampleDataSeedRunner.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/sample/LocalSampleDataSeedRunnerTest.java diff --git a/docs/LOCAL_TESTING.md b/docs/LOCAL_TESTING.md index ce206e7e..5a6b23a7 100644 --- a/docs/LOCAL_TESTING.md +++ b/docs/LOCAL_TESTING.md @@ -31,7 +31,7 @@ Logs go to `.local-run/.log`. Run the smoke check before testing by hand; i |---|---|---| | AS admin | `admin` / `admin` at http://localhost:9999/login | A passkey is enrolled at first login (`/webauthn/register`), by design. Override with `ESPI_BOOTSTRAP_ADMIN_USERNAME` / `ESPI_BOOTSTRAP_ADMIN_PASSWORD_HASH`. | | DC admin | `admin` / `admin` | Sandbox custodian user (dev/test profiles only). | -| DC customer | *not yet* | Sample customer and usage data: #253. | +| DC customer | `customer` / `customer` | Owns the sample UsagePoint (see "Sample data"). `local` profile only. | | TP user | *not yet* | TP username/password login: #249. | ## OAuth clients (AS) @@ -88,11 +88,26 @@ open "$TP/oauth2/authorization/datacustodian-monthly" On the `local` profile the DC writes every ESPI resource response to `dev-xml-capture/run-/` at the repository root (`GET_UsagePoint.xml`, …), and an `.error.txt` with the status, body or exception chain for any 4xx/5xx or unhandled exception. The folder is git-ignored. +## Sample data (DC) + +On the `local` profile the DC seeds a small, deterministic ESPI dataset at startup (`LocalSampleDataSeedRunner`, #253). The data is in-memory, so it is recreated on every restart, with the same ids. + +| Resource | Sample | +|---|---| +| RetailCustomer | `customer` / `customer` (`ROLE_USER`), owner of the UsagePoint | +| UsagePoint | Electricity, LocalTimeParameters US Eastern (UTC-5, DST rules `360E2000` / `B40E2000`) | +| ReadingType | Energy delivered, hourly, Wh × 10⁻³, USD (`accumulationBehaviour 4`, `kind 12`, `uom 72`) | +| MeterReading / IntervalBlock | 2 daily blocks (2026-01-01 and 02) of 24 hourly readings, with cost | +| UsageSummary / ElectricPowerQualitySummary | January 2026 billing period | +| Customer, CustomerAccount, CustomerAgreement, ServiceLocation, Meter | One residential customer | +| ApplicationInformation | The `third_party` client, with the TP's local redirect and notification URIs | + +The schema values follow the most common shapes in the Green Button CMD certification test feeds. Every identifying value is synthetic (`example.com`, `555-01xx`, `SAMPLE-…` numbers). + ## Known limitations The customer flow does not complete yet. Open blockers, in the order they are hit (see the roadmap, #255): -- **#253:** no sample customer or usage data on the DC, so feeds are empty. - **#249:** the TP has no user login page. - **#232:** the AS → DC delegation (`/authorize/delegate`) drops the authorization parameters and returns 400. - **#146 / #202:** the TP's token handling and the resource URIs in the token response. diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/sample/LocalSampleDataSeedRunner.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/sample/LocalSampleDataSeedRunner.java new file mode 100644 index 00000000..e6b58a0d --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/sample/LocalSampleDataSeedRunner.java @@ -0,0 +1,450 @@ +/* + * + * Copyright (c) 2018-2026 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.sample; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.greenbuttonalliance.espi.common.domain.common.DateTimeInterval; +import org.greenbuttonalliance.espi.common.domain.common.IdentifiedObject; +import org.greenbuttonalliance.espi.common.domain.common.ServiceCategory; +import org.greenbuttonalliance.espi.common.domain.common.SummaryMeasurement; +import org.greenbuttonalliance.espi.common.domain.common.enums.GrantType; +import org.greenbuttonalliance.espi.common.domain.common.enums.ResponseType; +import org.greenbuttonalliance.espi.common.domain.customer.common.ElectronicAddress; +import org.greenbuttonalliance.espi.common.domain.customer.common.StreetAddress; +import org.greenbuttonalliance.espi.common.domain.customer.common.TelephoneNumber; +import org.greenbuttonalliance.espi.common.domain.customer.entity.CustomerAccountEntity; +import org.greenbuttonalliance.espi.common.domain.customer.entity.CustomerAgreementEntity; +import org.greenbuttonalliance.espi.common.domain.customer.entity.CustomerEntity; +import org.greenbuttonalliance.espi.common.domain.customer.entity.MeterEntity; +import org.greenbuttonalliance.espi.common.domain.customer.entity.Organisation; +import org.greenbuttonalliance.espi.common.domain.customer.entity.ServiceLocationEntity; +import org.greenbuttonalliance.espi.common.domain.customer.entity.Status; +import org.greenbuttonalliance.espi.common.domain.customer.enums.CustomerKind; +import org.greenbuttonalliance.espi.common.domain.usage.ApplicationInformationEntity; +import org.greenbuttonalliance.espi.common.domain.usage.ElectricPowerQualitySummaryEntity; +import org.greenbuttonalliance.espi.common.domain.usage.IntervalBlockEntity; +import org.greenbuttonalliance.espi.common.domain.usage.IntervalReadingEntity; +import org.greenbuttonalliance.espi.common.domain.usage.MeterReadingEntity; +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.domain.usage.enums.UnitMultiplierKind; +import org.greenbuttonalliance.espi.common.domain.usage.enums.UnitSymbolKind; +import org.greenbuttonalliance.espi.common.repositories.customer.CustomerAccountRepository; +import org.greenbuttonalliance.espi.common.repositories.customer.CustomerAgreementRepository; +import org.greenbuttonalliance.espi.common.repositories.customer.CustomerRepository; +import org.greenbuttonalliance.espi.common.repositories.customer.MeterRepository; +import org.greenbuttonalliance.espi.common.repositories.customer.ServiceLocationRepository; +import org.greenbuttonalliance.espi.common.repositories.usage.ApplicationInformationRepository; +import org.greenbuttonalliance.espi.common.repositories.usage.RetailCustomerRepository; +import org.greenbuttonalliance.espi.common.repositories.usage.UsagePointRepository; +import org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * Seeds a small, deterministic ESPI sample dataset on the {@code local} profile (#253), so the customer + * consent flow and every ESPI feed can be tested by hand right after startup (see docs/LOCAL_TESTING.md). + * + *

LOCAL ONLY. The {@code customer}/{@code customer} login is public by virtue of this file.

+ * + *

Where the values come from. The ESPI schema values (reading type, interval layout, local time + * parameters, summary and power-quality shapes, customer enumerations) are the ones most common across the + * Green Button Connect My Data certification test feeds. Every identifying value (names, addresses, phone + * numbers, e-mail, account / agreement / serial numbers, tokens, URLs) is synthetic: {@code example.com} + * addresses and {@code 555-01xx} numbers, never data copied from a certification record.

+ * + *

Ids are UUID v5 of fixed sample hrefs ({@link EspiIdGeneratorService}), so they are stable across + * restarts and the runbook URLs keep working. Idempotent: does nothing when the sample UsagePoint exists.

+ */ +@Slf4j +@Component +@Profile("local") +@Order(100) // after DevSandboxAdminSeedRunner +@RequiredArgsConstructor +public class LocalSampleDataSeedRunner implements CommandLineRunner { + + public static final String CUSTOMER_USERNAME = "customer"; + private static final String CUSTOMER_PASSWORD = "customer"; + + /** The AS-seeded local Third Party client (openespi-authserver, local profile). */ + public static final String THIRD_PARTY_CLIENT_ID = "third_party"; + + /** 2026-01-01T00:00 US Eastern (UTC-5): start of the sample usage. */ + static final long START = 1_767_243_600L; + static final long HOUR = 3_600L; + static final long DAY = 86_400L; + static final int DAYS = 2; + + /** Hourly consumption in Wh: a residential day (night base load, morning and evening peaks). */ + static final long[] HOURLY_WH = { + 420, 390, 380, 375, 390, 450, 620, 810, 760, 640, 590, 570, + 560, 580, 600, 690, 880, 1150, 1320, 1240, 1050, 860, 650, 500}; + + private final RetailCustomerRepository retailCustomerRepository; + private final UsagePointRepository usagePointRepository; + private final ApplicationInformationRepository applicationInformationRepository; + private final CustomerRepository customerRepository; + private final CustomerAccountRepository customerAccountRepository; + private final CustomerAgreementRepository customerAgreementRepository; + private final ServiceLocationRepository serviceLocationRepository; + private final MeterRepository meterRepository; + private final EspiIdGeneratorService idGenerator; + private final PasswordEncoder customerPasswordEncoder; + + @Value("${espi.datacustodian.base-url:http://localhost:8081/DataCustodian}") + private String dataCustodianBaseUrl; + + @Value("${espi.thirdparty.base-url:http://localhost:8082/ThirdParty}") + private String thirdPartyBaseUrl; + + @Value("${espi.authorization-server.issuer-uri:http://localhost:9999}") + private String authorizationServerUri; + + @Override + @Transactional + public void run(String... args) { + UUID usagePointId = id("UsagePoint/1"); + if (usagePointRepository.existsById(usagePointId)) { + log.debug("Local sample data already present; skipping seed."); + return; + } + + RetailCustomerEntity customer = retailCustomerRepository.findByUsername(CUSTOMER_USERNAME) + .orElseGet(() -> retailCustomerRepository.save(sampleRetailCustomer())); + + usagePointRepository.save(sampleUsagePoint(usagePointId, customer)); + applicationInformationRepository.save(sampleThirdPartyApplication()); + + CustomerEntity customerResource = customerRepository.save(sampleCustomer()); + customerAccountRepository.save(sampleCustomerAccount(customerResource)); + customerAgreementRepository.save(sampleCustomerAgreement()); + serviceLocationRepository.save(sampleServiceLocation()); + meterRepository.save(sampleMeter()); + + log.warn("Seeded LOCAL sample ESPI data: customer login '{}'/'{}', UsagePoint {} with {} days of hourly" + + " readings, customer resources, ApplicationInformation for '{}' - DO NOT USE IN PRODUCTION", + CUSTOMER_USERNAME, CUSTOMER_PASSWORD, usagePointId, DAYS, THIRD_PARTY_CLIENT_ID); + } + + // --- usage domain ------------------------------------------------------------------------------------- + + private RetailCustomerEntity sampleRetailCustomer() { + RetailCustomerEntity customer = new RetailCustomerEntity(CUSTOMER_USERNAME, "Sample", "Customer", + "customer@example.com", RetailCustomerEntity.ROLE_USER); + customer.setPassword(customerPasswordEncoder.encode(CUSTOMER_PASSWORD)); + customer.setEnabled(Boolean.TRUE); + customer.setAccountLocked(Boolean.FALSE); + return customer; + } + + private UsagePointEntity sampleUsagePoint(UUID usagePointId, RetailCustomerEntity customer) { + UsagePointEntity usagePoint = new UsagePointEntity(); + usagePoint.setId(usagePointId); + usagePoint.setDescription("Sample residential electric service"); + usagePoint.setServiceCategory(ServiceCategory.ELECTRICITY); + usagePoint.setStatus((short) 1); + usagePoint.setRetailCustomer(customer); + usagePoint.setLocalTimeParameters(sampleLocalTimeParameters()); + + MeterReadingEntity meterReading = new MeterReadingEntity(); + meterReading.setId(id("MeterReading/1")); + meterReading.setDescription("Hourly electricity consumption"); + meterReading.setUsagePoint(usagePoint); + meterReading.setReadingType(sampleReadingType()); + for (int day = 0; day < DAYS; day++) { + meterReading.getIntervalBlocks().add(sampleIntervalBlock(meterReading, day)); + } + usagePoint.getMeterReadings().add(meterReading); + + usagePoint.getUsageSummaries().add(sampleUsageSummary(usagePoint)); + usagePoint.getElectricPowerQualitySummaries().add(samplePowerQualitySummary(usagePoint)); + return usagePoint; + } + + /** US Eastern: UTC-5, DST +1 h, the ESPI DST rules the certification feeds use most. */ + private TimeConfigurationEntity sampleLocalTimeParameters() { + TimeConfigurationEntity time = new TimeConfigurationEntity(); + time.setId(id("LocalTimeParameters/1")); + time.setDescription("US Eastern"); + time.setTzOffset(-18_000L); + time.setDstOffset(3_600L); + time.setDstStartRule(new byte[]{0x36, 0x0E, 0x20, 0x00}); // 360E2000 + time.setDstEndRule(new byte[]{(byte) 0xB4, 0x0E, 0x20, 0x00}); // B40E2000 + return time; + } + + /** Hourly real energy delivered, Wh x 10^-3, USD: the most common certified kWh ReadingType. */ + private ReadingTypeEntity sampleReadingType() { + ReadingTypeEntity readingType = new ReadingTypeEntity(); + readingType.setId(id("ReadingType/1")); + readingType.setDescription("Energy Delivered (kWh), hourly"); + readingType.setAccumulationBehaviour("4"); // deltaData + readingType.setCommodity("1"); // electricity, secondary metered + readingType.setCurrency("840"); // USD + readingType.setFlowDirection("1"); // forward (delivered) + readingType.setIntervalLength(HOUR); + readingType.setKind("12"); // energy + readingType.setPhase("0"); + readingType.setPowerOfTenMultiplier("-3"); + readingType.setUom("72"); // Wh + return readingType; + } + + /** One day of 24 hourly readings, as most certification feeds block them. */ + private IntervalBlockEntity sampleIntervalBlock(MeterReadingEntity meterReading, int day) { + IntervalBlockEntity block = new IntervalBlockEntity(); + block.setId(id("IntervalBlock/" + (day + 1))); + block.setMeterReading(meterReading); + long dayStart = START + day * DAY; + block.setInterval(interval(dayStart, DAY)); + for (int hour = 0; hour < HOURLY_WH.length; hour++) { + long wh = HOURLY_WH[hour] + day * 15L; // second day slightly higher, so the blocks differ + IntervalReadingEntity reading = new IntervalReadingEntity(); + reading.setIntervalBlock(block); + reading.setTimePeriod(interval(dayStart + hour * HOUR, HOUR)); + reading.setValue(wh * 1_000); // Wh x 10^-3 + reading.setCost(wh * 15); // USD x 10^-5 at 0.15 USD/kWh + block.getIntervalReadings().add(reading); + } + return block; + } + + private UsageSummaryEntity sampleUsageSummary(UsagePointEntity usagePoint) { + UsageSummaryEntity summary = new UsageSummaryEntity(); + summary.setId(id("UsageSummary/1")); + summary.setDescription("January 2026 billing period"); + summary.setUsagePoint(usagePoint); + summary.setBillingPeriod(interval(START, 31 * DAY)); + summary.setBillLastPeriod(21_102_000L); // USD x 10^-5: 211.02 + summary.setBillToDate(21_102_000L); + summary.setCostAdditionalLastPeriod(9_310_000L); + summary.setCurrency("840"); + summary.setOverallConsumptionLastPeriod(energy(1_325_000_000L)); // 1,325 kWh + summary.setCurrentBillingPeriodOverAllConsumption(energy(1_325_000_000L)); + summary.setQualityOfReading("17"); // validated + summary.setStatusTimeStamp(START + 31 * DAY); + summary.setCommodity(1); + return summary; + } + + private ElectricPowerQualitySummaryEntity samplePowerQualitySummary(UsagePointEntity usagePoint) { + ElectricPowerQualitySummaryEntity quality = new ElectricPowerQualitySummaryEntity(); + quality.setId(id("ElectricPowerQualitySummary/1")); + quality.setDescription("January 2026 power quality"); + quality.setUsagePoint(usagePoint); + quality.setSummaryInterval(interval(START, 31 * DAY)); + quality.setMainsVoltage(240_000L); + quality.setMeasurementProtocol((short) 0); + quality.setFlickerPlt(0L); + quality.setFlickerPst(0L); + quality.setHarmonicVoltage(0L); + quality.setLongInterruptions(0L); + quality.setPowerFrequency(0L); + quality.setRapidVoltageChanges(0L); + quality.setShortInterruptions(0L); + quality.setSupplyVoltageDips(0L); + quality.setSupplyVoltageImbalance(0L); + quality.setSupplyVoltageVariations(0L); + quality.setTempOvervoltage(0L); + return quality; + } + + /** The local Third Party, matching the AS-seeded {@code third_party} client and the TP local profile. */ + private ApplicationInformationEntity sampleThirdPartyApplication() { + ApplicationInformationEntity app = new ApplicationInformationEntity(); + app.setId(id("ApplicationInformation/1")); + app.setDescription("Local Third Party (sample)"); + app.setClientId(THIRD_PARTY_CLIENT_ID); + app.setClientName("ThirdParty Application"); + app.setDataCustodianId("data_custodian"); + app.setDataCustodianApplicationStatus("1"); + app.setThirdPartyApplicationDescription("Local manual-test Third Party"); + app.setThirdPartyApplicationStatus("1"); + app.setThirdPartyApplicationType("1"); + app.setThirdPartyApplicationUse("1"); + app.setClientUri(thirdPartyBaseUrl); + app.setRedirectUri(thirdPartyBaseUrl + "/login/oauth2/code/datacustodian-monthly"); + app.setThirdPartyNotifyUri(thirdPartyBaseUrl + "/espi/1_1/Notification"); + app.setAuthorizationServerUri(authorizationServerUri); + app.setAuthorizationServerAuthorizationEndpoint(authorizationServerUri + "/oauth2/authorize"); + app.setAuthorizationServerTokenEndpoint(authorizationServerUri + "/oauth2/token"); + app.setDataCustodianResourceEndpoint(dataCustodianBaseUrl + "/espi/1_1/resource"); + app.setDataCustodianBulkRequestURI(dataCustodianBaseUrl + "/espi/1_1/resource/Batch/Bulk"); + app.setContacts("thirdparty@example.com"); + app.setTokenEndpointAuthMethod("client_secret_basic"); + app.setClientIdIssuedAt(START); + app.setScope(Set.of( + "FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13", + "FB=4_5_15;IntervalDuration=900;BlockDuration=monthly;HistoryLength=13")); + app.setGrantTypes(Set.of(GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN, GrantType.CLIENT_CREDENTIALS)); + app.setResponseTypes(ResponseType.CODE); + return app; + } + + // --- customer domain (synthetic identity) --------------------------------------------------------- + + private CustomerEntity sampleCustomer() { + CustomerEntity customer = new CustomerEntity(); + customer.setId(id("Customer/1")); + customer.setDescription("Sample residential customer"); + customer.setCustomerName("Sample Customer"); + customer.setKind(CustomerKind.RESIDENTIAL); + customer.setLocale("en_US"); + customer.setSpecialNeed("n/a"); + customer.setVip(Boolean.FALSE); + CustomerEntity.Status customerStatus = new CustomerEntity.Status(); + customerStatus.setValue("Good"); + customerStatus.setDateTime(OffsetDateTime.ofInstant(Instant.ofEpochSecond(START), ZoneOffset.UTC)); + customer.setStatus(customerStatus); + customer.setOrganisation(organisation("Sample Customer", "customer@example.com")); + return customer; + } + + private CustomerAccountEntity sampleCustomerAccount(CustomerEntity customer) { + CustomerAccountEntity account = new CustomerAccountEntity(); + account.setId(id("CustomerAccount/1")); + account.setDescription("Sample residential account"); + account.setCustomer(customer); + account.setType("Residential"); + account.setAccountId("SAMPLE-ACCT-0001"); + account.setBillingCycle("007"); + account.setLastBillAmount(21_102_000L); + account.setIsPrePay(Boolean.FALSE); + account.setContactInfo(organisation("Sample Customer", "customer@example.com")); + return account; + } + + private CustomerAgreementEntity sampleCustomerAgreement() { + CustomerAgreementEntity agreement = new CustomerAgreementEntity(); + agreement.setId(id("CustomerAgreement/1")); + agreement.setDescription("Sample residential service agreement"); + agreement.setType("Service Agreement"); + agreement.setAgreementId("SAMPLE-AGR-0001"); + agreement.setCurrency("840"); + agreement.setIsPrePay(Boolean.FALSE); + agreement.setSignDate(OffsetDateTime.ofInstant(Instant.ofEpochSecond(START), ZoneOffset.UTC)); + agreement.setStatus(status("Active")); + return agreement; + } + + private ServiceLocationEntity sampleServiceLocation() { + ServiceLocationEntity location = new ServiceLocationEntity(); + location.setId(id("ServiceLocation/1")); + location.setDescription("Sample service location"); + location.setType("Residential"); + location.setMainAddress(address()); + location.setAccessMethod("Meter on the north wall"); + location.setNeedsInspection(Boolean.FALSE); + location.setStatus(status("Normal")); + return location; + } + + private MeterEntity sampleMeter() { + MeterEntity meter = new MeterEntity(); + meter.setId(id("Meter/1")); + meter.setDescription("Sample smart meter"); + meter.setType("Smart Meter"); + meter.setSerialNumber("SAMPLE-MTR-0001"); + meter.setAmrSystem("AMI"); + meter.setFormNumber("2S"); + meter.setIntervalLength(900L); + meter.setIsVirtual(Boolean.FALSE); + meter.setIsPan(Boolean.FALSE); + return meter; + } + + // --- helpers ----------------------------------------------------------------------------------------- + + /** UUID v5 of a fixed sample href, so the id is the same on every start. */ + UUID id(String resourcePath) { + return idGenerator.generateEspiId(dataCustodianBaseUrl + "/espi/1_1/resource/" + resourcePath); + } + + private static DateTimeInterval interval(long start, long duration) { + DateTimeInterval interval = new DateTimeInterval(); + interval.setStart(start); + interval.setDuration(duration); + return interval; + } + + private static SummaryMeasurement energy(long milliWattHours) { + SummaryMeasurement measurement = new SummaryMeasurement(); + measurement.setPowerOfTenMultiplier(UnitMultiplierKind.MILLI); + measurement.setUom(UnitSymbolKind.WH); + measurement.setValue(milliWattHours); + measurement.setTimeStamp(START + 31 * DAY); + return measurement; + } + + private static Status status(String value) { + Status status = new Status(); + status.setValue(value); + status.setDateTime(OffsetDateTime.ofInstant(Instant.ofEpochSecond(START), ZoneOffset.UTC)); + return status; + } + + private static StreetAddress address() { + StreetAddress address = new StreetAddress(); + address.setStreetDetail("100 Example Street"); + address.setTownDetail("Sampletown"); + address.setStateOrProvince("NY"); + address.setPostalCode("00000"); + address.setCountry("US"); + return address; + } + + private static Organisation organisation(String name, String email) { + TelephoneNumber phone = new TelephoneNumber(); + phone.setCountryCode("1"); + phone.setAreaCode("555"); + phone.setCityCode("555"); + phone.setLocalNumber("0100"); + ElectronicAddress electronic = new ElectronicAddress(); + electronic.setEmail1(email); + Organisation organisation = new Organisation(); + organisation.setOrganisationName(name); + organisation.setStreetAddress(address()); + organisation.setPhone1(phone); + organisation.setElectronicAddress(electronic); + return organisation; + } + + /** All sample resource ids, for tests and the runbook. */ + List sampleResourceIds() { + return List.of(id("UsagePoint/1"), id("MeterReading/1"), id("ReadingType/1"), id("IntervalBlock/1"), + id("UsageSummary/1"), id("ElectricPowerQualitySummary/1"), id("ApplicationInformation/1"), + id("Customer/1"), id("CustomerAccount/1")); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountRESTController.java index 7873706b..e4a7e922 100644 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountRESTController.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountRESTController.java @@ -35,6 +35,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; @@ -56,6 +57,8 @@ @Tag(name = "Customer Account", description = "ESPI Customer Account resource endpoints") @SecurityRequirement(name = "oauth2") @RequiredArgsConstructor +// open-in-view is off: map and marshal inside one read-only session, or the lazy notifications fail (#253) +@Transactional(readOnly = true) public class CustomerAccountRESTController { private final CustomerAccountRepository customerAccountRepository; diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTController.java index 4b0b1d74..6c79c824 100755 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTController.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTController.java @@ -36,6 +36,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; @@ -55,6 +56,8 @@ @Tag(name = "Interval Block", description = "ESPI Interval Block resource endpoints") @SecurityRequirement(name = "oauth2") @RequiredArgsConstructor +// open-in-view is off: map and marshal inside one read-only session, or the lazy intervalReadings fail (#253) +@Transactional(readOnly = true) public class IntervalBlockRESTController { private final IntervalBlockRepository intervalBlockRepository; diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTController.java index 6b8068bc..9caa5af2 100755 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTController.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTController.java @@ -36,6 +36,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; @@ -58,6 +59,9 @@ @Tag(name = "Usage Summary", description = "Multi-Commodity Usage Summary Data Management API") @SecurityRequirement(name = "oauth2") @RequiredArgsConstructor +// open-in-view is off: map and marshal inside one read-only session, or the lazy +// costAdditionalDetailLastPeriod fails (#253) +@Transactional(readOnly = true) public class UsageSummaryRESTController { private final UsageSummaryRepository usageSummaryRepository; diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/sample/LocalSampleDataSeedRunnerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/sample/LocalSampleDataSeedRunnerTest.java new file mode 100644 index 00000000..469c5001 --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/sample/LocalSampleDataSeedRunnerTest.java @@ -0,0 +1,113 @@ +/* + * + * Copyright (c) 2018-2026 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.sample; + +import org.greenbuttonalliance.espi.common.domain.usage.RetailCustomerEntity; +import org.greenbuttonalliance.espi.common.repositories.usage.IntervalBlockRepository; +import org.greenbuttonalliance.espi.common.repositories.usage.RetailCustomerRepository; +import org.greenbuttonalliance.espi.common.repositories.usage.UsagePointRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +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.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.opaqueToken; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The {@code local} sample dataset (#253) against the real Flyway H2 schema ({@code test} wins over + * {@code local} for the datasource, so the schema is migrated and validated, not generated): every seeded + * ESPI feed marshals its entries, and the customer can log in. This is the real-data guard #247 asked for. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"local", "test"}) +@DisplayName("Local sample data seed (#253)") +class LocalSampleDataSeedRunnerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private LocalSampleDataSeedRunner runner; + + @Autowired + private UsagePointRepository usagePointRepository; + + @Autowired + private IntervalBlockRepository intervalBlockRepository; + + @Autowired + private RetailCustomerRepository retailCustomerRepository; + + @Autowired + private PasswordEncoder customerPasswordEncoder; + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "UsagePoint, 1", "MeterReading, 1", "ReadingType, 1", "IntervalBlock, 2", "UsageSummary, 1", + "ElectricPowerQualitySummary, 1", "ApplicationInformation, 1", "Customer, 1", "CustomerAccount, 1"}) + @DisplayName("each seeded ESPI feed returns its sample entries to the DataCustodian admin") + void feedsReturnSampleEntries(String resource, int entries) throws Exception { + String xml = mockMvc.perform(get("/espi/1_1/resource/" + resource) + .with(opaqueToken().authorities(new SimpleGrantedAuthority("SCOPE_DataCustodian_Admin_Access")))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(xml.split("", -1)).hasSize(entries + 1); + } + + @Test + @Transactional + @DisplayName("customer/customer owns the sample UsagePoint: 2 daily blocks of 24 hourly readings") + void customerOwnsUsagePointWithHourlyReadings() { + assertThat(retailCustomerRepository.findByUsername(LocalSampleDataSeedRunner.CUSTOMER_USERNAME)) + .hasValueSatisfying(customer -> assertThat(customer) + .extracting(RetailCustomerEntity::getRole, c -> customerPasswordEncoder.matches("customer", c.getPassword())) + .containsExactly(RetailCustomerEntity.ROLE_USER, true)); + assertThat(usagePointRepository.findById(runner.id("UsagePoint/1"))) + .hasValueSatisfying(usagePoint -> assertThat(usagePoint.getRetailCustomer().getUsername()) + .isEqualTo(LocalSampleDataSeedRunner.CUSTOMER_USERNAME)); + assertThat(intervalBlockRepository.findAll()) + .hasSize(LocalSampleDataSeedRunner.DAYS) + .allSatisfy(block -> assertThat(block.getIntervalReadings()).hasSize(24)); + } + + @Test + @DisplayName("is idempotent and uses stable ids") + void idempotentWithStableIds() throws Exception { + long before = usagePointRepository.count(); + + runner.run(); + + assertThat(usagePointRepository.count()).isEqualTo(before); + assertThat(runner.sampleResourceIds()).doesNotHaveDuplicates() + .contains(runner.id("UsagePoint/1")); + } +} diff --git a/scripts/local/smoke.ps1 b/scripts/local/smoke.ps1 index 67fe58f1..c679b8ee 100644 --- a/scripts/local/smoke.ps1 +++ b/scripts/local/smoke.ps1 @@ -64,6 +64,10 @@ foreach ($resource in 'ApplicationInformation', 'Authorization', 'UsagePoint', ' 'IntervalBlock', 'UsageSummary', 'ElectricPowerQualitySummary', 'Customer', 'CustomerAccount') { Check "GET $resource feed" 200 (Status "$DC/espi/1_1/resource/$resource" (Bearer $dcAdmin)) } +$intervalBlocks = try { + (Invoke-WebRequest -Uri "$DC/espi/1_1/resource/IntervalBlock" -Headers (Bearer $dcAdmin) -UseBasicParsing).Content +} catch { '' } +Check 'IntervalBlock feed contains the sample data (#253)' $true ($intervalBlocks -match '') Check 'GET Authorization feed with the Third Party client access token' 200 ` (Status "$DC/espi/1_1/resource/Authorization" (Bearer $tpClient)) Check 'GET UsagePoint feed without a token' 401 (Status "$DC/espi/1_1/resource/UsagePoint") diff --git a/scripts/local/smoke.sh b/scripts/local/smoke.sh index 0a833bc9..b000db0c 100755 --- a/scripts/local/smoke.sh +++ b/scripts/local/smoke.sh @@ -53,6 +53,8 @@ for resource in ApplicationInformation Authorization UsagePoint MeterReading Rea UsageSummary ElectricPowerQualitySummary Customer CustomerAccount; do check "GET $resource feed" 200 "$(status -H "Authorization: Bearer $DC_ADMIN" "$DC/espi/1_1/resource/$resource")" done +check "IntervalBlock feed contains the sample data (#253)" yes \ + "$(curl -s -H "Authorization: Bearer $DC_ADMIN" "$DC/espi/1_1/resource/IntervalBlock" | grep -q '' && echo yes || echo no)" check "GET Authorization feed with the Third Party client access token" 200 \ "$(status -H "Authorization: Bearer $TP_CLIENT" "$DC/espi/1_1/resource/Authorization")" check "GET UsagePoint feed without a token" 401 "$(status "$DC/espi/1_1/resource/UsagePoint")" From 46a4afb265ff78495d73c87ebfa7b1fbd3c60b3b Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Sun, 15 Nov 2026 01:28:24 -0500 Subject: [PATCH 2/2] test(#253): assert seeded entries by id, not by count (shared test H2 database) Every Spring test context in the DC module shares the in-memory testdb, so other tests can add UsagePoint or ApplicationInformation rows before this one runs (CI order). Check that each feed contains the seeded entries' stable ids instead of an exact entry total. Co-Authored-By: Claude Opus 5.5 --- .../sample/LocalSampleDataSeedRunnerTest.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/sample/LocalSampleDataSeedRunnerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/sample/LocalSampleDataSeedRunnerTest.java index 469c5001..37c562e5 100644 --- a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/sample/LocalSampleDataSeedRunnerTest.java +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/sample/LocalSampleDataSeedRunnerTest.java @@ -35,6 +35,9 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.stream.IntStream; + import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.opaqueToken; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -69,18 +72,25 @@ class LocalSampleDataSeedRunnerTest { @Autowired private PasswordEncoder customerPasswordEncoder; + /** + * Asserts on the seeded entries' ids, not on entry counts: every Spring test context in the module shares + * the in-memory {@code testdb}, so other tests may have added rows of the same type. + */ @ParameterizedTest(name = "{0}") @CsvSource({ "UsagePoint, 1", "MeterReading, 1", "ReadingType, 1", "IntervalBlock, 2", "UsageSummary, 1", "ElectricPowerQualitySummary, 1", "ApplicationInformation, 1", "Customer, 1", "CustomerAccount, 1"}) - @DisplayName("each seeded ESPI feed returns its sample entries to the DataCustodian admin") + @DisplayName("each seeded ESPI feed marshals its sample entries for the DataCustodian admin") void feedsReturnSampleEntries(String resource, int entries) throws Exception { String xml = mockMvc.perform(get("/espi/1_1/resource/" + resource) .with(opaqueToken().authorities(new SimpleGrantedAuthority("SCOPE_DataCustodian_Admin_Access")))) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); - assertThat(xml.split("", -1)).hasSize(entries + 1); + List seededEntryIds = IntStream.rangeClosed(1, entries) + .mapToObj(n -> "urn:uuid:" + runner.id(resource + "/" + n) + "") + .toList(); + assertThat(xml).contains(seededEntryIds); } @Test @@ -94,7 +104,8 @@ void customerOwnsUsagePointWithHourlyReadings() { assertThat(usagePointRepository.findById(runner.id("UsagePoint/1"))) .hasValueSatisfying(usagePoint -> assertThat(usagePoint.getRetailCustomer().getUsername()) .isEqualTo(LocalSampleDataSeedRunner.CUSTOMER_USERNAME)); - assertThat(intervalBlockRepository.findAll()) + assertThat(intervalBlockRepository.findAllById(IntStream.rangeClosed(1, LocalSampleDataSeedRunner.DAYS) + .mapToObj(n -> runner.id("IntervalBlock/" + n)).toList())) .hasSize(LocalSampleDataSeedRunner.DAYS) .allSatisfy(block -> assertThat(block.getIntervalReadings()).hasSize(24)); }