Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions docs/LOCAL_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Logs go to `.local-run/<app>.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)
Expand Down Expand Up @@ -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-<timestamp>/` 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.
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
*
* 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 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;
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;

/**
* 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 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();

List<String> seededEntryIds = IntStream.rangeClosed(1, entries)
.mapToObj(n -> "<atom:id>urn:uuid:" + runner.id(resource + "/" + n) + "</atom:id>")
.toList();
assertThat(xml).contains(seededEntryIds);
}

@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.findAllById(IntStream.rangeClosed(1, LocalSampleDataSeedRunner.DAYS)
.mapToObj(n -> runner.id("IntervalBlock/" + n)).toList()))
.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"));
}
}
4 changes: 4 additions & 0 deletions scripts/local/smoke.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<atom:entry>')
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")
Expand Down
2 changes: 2 additions & 0 deletions scripts/local/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<atom:entry>' && 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")"
Expand Down
Loading