From 43855b5dc0da3fe655f022864011b2a917f9e6e7 Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Wed, 27 May 2026 23:48:51 -0400 Subject: [PATCH] feat(common): ESPI FB-scope parser + Function Block catalog (#122 PR A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for grant→subscription resolution and resource-server enforcement. New package org.greenbuttonalliance.espi.common.scope: - FunctionBlock: authoritative catalog of NAESB ESPI 4.0 §REQ.21.4.2.1.3.1 ScopeFBTerms (id, category, optional ServiceKind, spec title) with tolerant byId/categoryOf/serviceKindOf lookups. Only active FBs are enumerated; DEPRECATED_IDS records spec-deprecated ids so they resolve to DEPRECATED rather than UNKNOWN. - FunctionBlockCategory: BASE / COMMODITY / ENERGY_DATA_SHAPE / CUSTOMER_PII (load-bearing) + INTERACTION / BULK_TRANSFER / AUTHORIZATION / ADMINISTRATION / PLATFORM / DEPRECATED / UNKNOWN. - EspiScope: FB-agnostic record parser for the FB=…;IntervalDuration=…; BlockDuration=…;HistoryLength=… grammar, deriving commodityServiceKinds(), functionBlocksIn(category), includesCustomerPii(), includesEnergyData(). Spec accuracy (per GBA-confirmed table): FB_71 does not exist (customer/PII = 54–62); FB_56/57 retitled; SFTP bulk FB_34/FB_66 deprecated (REST 35/67 remain); FB_29 Temperature is COMMODITY with no ServiceKind (XSD gap). The parser carries any FB id (future/deprecated/unknown) without error; the catalog is consulted only for semantics. openespi-authserver stays scope-opaque and keeps no dependency on this package — FB semantics live solely in the DC. 50 unit tests (EspiScopeTest, FunctionBlockTest), all green. Co-Authored-By: Claude Opus 4.7 --- .../espi/common/scope/EspiScope.java | 181 ++++++++++++++++ .../espi/common/scope/FunctionBlock.java | 199 ++++++++++++++++++ .../common/scope/FunctionBlockCategory.java | 70 ++++++ .../espi/common/scope/EspiScopeTest.java | 194 +++++++++++++++++ .../espi/common/scope/FunctionBlockTest.java | 132 ++++++++++++ 5 files changed, 776 insertions(+) create mode 100644 openespi-common/src/main/java/org/greenbuttonalliance/espi/common/scope/EspiScope.java create mode 100644 openespi-common/src/main/java/org/greenbuttonalliance/espi/common/scope/FunctionBlock.java create mode 100644 openespi-common/src/main/java/org/greenbuttonalliance/espi/common/scope/FunctionBlockCategory.java create mode 100644 openespi-common/src/test/java/org/greenbuttonalliance/espi/common/scope/EspiScopeTest.java create mode 100644 openespi-common/src/test/java/org/greenbuttonalliance/espi/common/scope/FunctionBlockTest.java diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/scope/EspiScope.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/scope/EspiScope.java new file mode 100644 index 00000000..2909df26 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/scope/EspiScope.java @@ -0,0 +1,181 @@ +/* + * Copyright 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.scope; + +import org.greenbuttonalliance.espi.common.domain.usage.enums.ServiceKind; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** + * Immutable value object for one parsed NAESB ESPI 4.0 OAuth scope string. + * + *

ESPI scope grammar is a {@code ;}-delimited list of {@code key=value} terms, e.g. + * {@code FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13}. The {@code FB} + * term carries a {@code _}-delimited set of Function Block ids.

+ * + *

The parser is intentionally FB-agnostic: it carries whatever FB ids appear + * in the string (including spec-deprecated or future ids) without consulting the catalog. Semantic + * questions — category, commodity {@link ServiceKind} — are answered on demand via + * {@link FunctionBlock}. Unrecognized non-{@code FB} terms are preserved in + * {@link #additionalParameters()} so no information is lost.

+ * + *

This object holds no OAuth, HTTP, persistence, or resource knowledge. It is the shared + * foundation for grant→subscription candidate resolution and resource-server enforcement (#122).

+ * + * @param raw the original scope string as parsed + * @param functionBlocks FB ids from the {@code FB=} term, sorted ascending and immutable + * @param intervalDuration {@code IntervalDuration} term in seconds, or {@code null} if absent + * @param blockDuration {@code BlockDuration} term (e.g. {@code monthly}), or {@code null} if absent + * @param historyLength {@code HistoryLength} term, or {@code null} if absent + * @param additionalParameters any other {@code key=value} terms (and bare tokens, with empty value), + * insertion-ordered and immutable + * @see FunctionBlock + * @see FunctionBlockCategory + */ +public record EspiScope( + String raw, + Set functionBlocks, + Integer intervalDuration, + String blockDuration, + Integer historyLength, + Map additionalParameters +) { + + /** Canonical constructor; defensively copies collections into sorted/insertion-ordered immutable views. */ + public EspiScope { + functionBlocks = functionBlocks == null + ? Collections.emptySortedSet() + : Collections.unmodifiableSortedSet(new TreeSet<>(functionBlocks)); + additionalParameters = additionalParameters == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap<>(additionalParameters)); + } + + /** + * Parse an ESPI scope string into an {@code EspiScope}. + * + *

Tolerant of surrounding whitespace, stray/empty {@code ;} segments, and mixed-case term + * keys. The recognized keys ({@code FB}, {@code IntervalDuration}, {@code BlockDuration}, + * {@code HistoryLength}) are matched case-insensitively; every other {@code key=value} term is + * preserved in {@link #additionalParameters()}, and a bare token with no {@code =} is preserved + * with an empty value.

+ * + * @param scope the scope string (e.g. {@code FB=4_5_15;IntervalDuration=3600}) + * @return the parsed value object + * @throws IllegalArgumentException if {@code scope} is null/blank, or an {@code FB}, + * {@code IntervalDuration}, or {@code HistoryLength} value is + * not an integer + */ + public static EspiScope parse(String scope) { + if (scope == null || scope.isBlank()) { + throw new IllegalArgumentException("ESPI scope must not be null or blank"); + } + + Set fbs = new TreeSet<>(); + Integer intervalDuration = null; + String blockDuration = null; + Integer historyLength = null; + Map extras = new LinkedHashMap<>(); + + for (String segment : scope.split(";")) { + String term = segment.trim(); + if (term.isEmpty()) { + continue; + } + int eq = term.indexOf('='); + if (eq < 0) { + extras.put(term, ""); + continue; + } + String key = term.substring(0, eq).trim(); + String value = term.substring(eq + 1).trim(); + switch (key.toLowerCase(Locale.ROOT)) { + case "fb" -> { + for (String token : value.split("_")) { + String fb = token.trim(); + if (!fb.isEmpty()) { + fbs.add(parseIntTerm(key, fb)); + } + } + } + case "intervalduration" -> intervalDuration = parseIntTerm(key, value); + case "historylength" -> historyLength = parseIntTerm(key, value); + case "blockduration" -> blockDuration = value; + default -> extras.put(key, value); + } + } + + return new EspiScope(scope, fbs, intervalDuration, blockDuration, historyLength, extras); + } + + private static int parseIntTerm(String key, String value) { + try { + return Integer.parseInt(value); + } + catch (NumberFormatException e) { + throw new IllegalArgumentException( + "ESPI scope term '" + key + "' expected an integer but was '" + value + "'", e); + } + } + + /** @return whether the given FB id is present in this scope. */ + public boolean containsFunctionBlock(int functionBlock) { + return functionBlocks.contains(functionBlock); + } + + /** + * @param category the category to filter by + * @return the FB ids in this scope that belong to {@code category}, sorted and immutable + */ + public Set functionBlocksIn(FunctionBlockCategory category) { + TreeSet result = new TreeSet<>(); + for (int fb : functionBlocks) { + if (FunctionBlock.categoryOf(fb) == category) { + result.add(fb); + } + } + return Collections.unmodifiableSortedSet(result); + } + + /** + * @return the distinct {@link ServiceKind}s selected by the commodity FBs in this scope. + * Commodity FBs with no XSD-defined kind (FB 29 / Temperature) contribute none. + */ + public Set commodityServiceKinds() { + Set kinds = EnumSet.noneOf(ServiceKind.class); + for (int fb : functionBlocks) { + FunctionBlock.serviceKindOf(fb).ifPresent(kinds::add); + } + return Collections.unmodifiableSet(kinds); + } + + /** @return whether this scope grants any customer/PII FB (54-62). */ + public boolean includesCustomerPii() { + return !functionBlocksIn(FunctionBlockCategory.CUSTOMER_PII).isEmpty(); + } + + /** @return whether this scope grants any energy data-shape FB (12, 15, 16, 17, 27, 28). */ + public boolean includesEnergyData() { + return !functionBlocksIn(FunctionBlockCategory.ENERGY_DATA_SHAPE).isEmpty(); + } +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/scope/FunctionBlock.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/scope/FunctionBlock.java new file mode 100644 index 00000000..28d30220 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/scope/FunctionBlock.java @@ -0,0 +1,199 @@ +/* + * Copyright 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.scope; + +import org.greenbuttonalliance.espi.common.domain.usage.enums.ServiceKind; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Authoritative catalog of NAESB ESPI 4.0 OAuth scope Function Block (FB) terms, + * per §REQ.21.4.2.1.3.1 (ScopeFBTerms). + * + *

This enum is the single in-code home for ESPI FB semantics. Each constant carries the + * FB's numeric id (the wire value used in the {@code FB=} scope term), its + * {@link FunctionBlockCategory}, the spec title, and — for most {@code COMMODITY} FBs — + * the {@link ServiceKind} it selects.

+ * + *

Only active (non-deprecated) FBs are enumerated. FB ids the spec marks + * {@code [DEPRECATED]} are recorded in {@link #DEPRECATED_IDS} so that {@link #categoryOf(int)} + * reports {@link FunctionBlockCategory#DEPRECATED} rather than {@link FunctionBlockCategory#UNKNOWN}. + * Any id absent from both is genuinely outside the spec table and resolves to {@code UNKNOWN}.

+ * + *

The {@link EspiScope} parser is intentionally FB-agnostic: it carries whatever FB ids appear + * in a scope string, so future or unrecognized FBs still parse cleanly. This catalog is consulted + * only when a category or service kind is needed. The {@code title} is diagnostic only (logging / + * documentation) — it is never serialized and never drives an authorization decision.

+ * + * @see FunctionBlockCategory + * @see EspiScope + */ +public enum FunctionBlock { + + // --- Energy Usage domain ------------------------------------------------- + FB_01(1, FunctionBlockCategory.PLATFORM, "Common (Energy Usage)"), + FB_02(2, FunctionBlockCategory.INTERACTION, "Download My Data (Energy Usage)"), + FB_03(3, FunctionBlockCategory.INTERACTION, "Connect My Data (Energy Usage)"), + FB_04(4, FunctionBlockCategory.BASE, "Interval Metering"), + FB_05(5, FunctionBlockCategory.COMMODITY, ServiceKind.ELECTRICITY, "Interval Electricity Metering"), + FB_06(6, FunctionBlockCategory.COMMODITY, ServiceKind.ELECTRICITY, "Demand Electricity Metering"), + FB_07(7, FunctionBlockCategory.COMMODITY, ServiceKind.ELECTRICITY, "Net Metering"), + FB_08(8, FunctionBlockCategory.COMMODITY, ServiceKind.ELECTRICITY, "Forward and Reverse Metering"), + FB_09(9, FunctionBlockCategory.COMMODITY, ServiceKind.ELECTRICITY, "Register Values"), + FB_10(10, FunctionBlockCategory.COMMODITY, ServiceKind.GAS, "Gas Interval Metering"), + FB_11(11, FunctionBlockCategory.COMMODITY, ServiceKind.WATER, "Water Interval Metering"), + FB_12(12, FunctionBlockCategory.ENERGY_DATA_SHAPE, "Cost of Interval Data"), + FB_13(13, FunctionBlockCategory.PLATFORM, "Security and Privacy Classes (Energy Usage)"), + FB_15(15, FunctionBlockCategory.ENERGY_DATA_SHAPE, "Usage Summary"), + FB_16(16, FunctionBlockCategory.ENERGY_DATA_SHAPE, "Usage Summary with Cost"), + FB_17(17, FunctionBlockCategory.ENERGY_DATA_SHAPE, "Power Quality Summary"), + FB_27(27, FunctionBlockCategory.ENERGY_DATA_SHAPE, "Usage Summary with Demands and Previous Day Attributes"), + FB_28(28, FunctionBlockCategory.ENERGY_DATA_SHAPE, "Usage Summary Costs for Current Billing Period"), + + /** + * Temperature Interval Metering — commodity FB with no {@link ServiceKind} mapping. + * + *

The ESPI 4.0 {@code ServiceKind} XSD enumeration (electricity, gas, water, time, heat, + * refuse, sewerage, rates, TV licence, internet) has no value for temperature, so a UsagePoint's + * {@code ServiceCategory.kind} cannot encode it — a known gap in the standard. FB 29 is + * therefore {@link FunctionBlockCategory#COMMODITY} with no service kind, and + * {@link EspiScope#commodityServiceKinds()} will not include any kind solely on its account.

+ */ + FB_29(29, FunctionBlockCategory.COMMODITY, "Temperature Interval Metering"), + + FB_30(30, FunctionBlockCategory.PLATFORM, "Common User Experience"), + FB_31(31, FunctionBlockCategory.AUTHORIZATION, "Authorization and Authentication w/o Pre-Negotiated Scope"), + FB_35(35, FunctionBlockCategory.BULK_TRANSFER, "REST for Energy Usage Bulk"), + FB_37(37, FunctionBlockCategory.INTERACTION, "Query Parameters (Energy Usage)"), + FB_39(39, FunctionBlockCategory.INTERACTION, "PUSH Model (Energy Usage)"), + FB_40(40, FunctionBlockCategory.AUTHORIZATION, "Offline Authorization (Energy Usage)"), + FB_41(41, FunctionBlockCategory.ADMINISTRATION, "Manage ApplicationInformation Resource"), + FB_44(44, FunctionBlockCategory.ADMINISTRATION, "Manage Authorization Resource"), + + // --- Retail Customer domain ---------------------------------------------- + FB_51(51, FunctionBlockCategory.PLATFORM, "Common (Retail Customer)"), + FB_52(52, FunctionBlockCategory.INTERACTION, "Download My Data (Retail Customer)"), + FB_53(53, FunctionBlockCategory.BASE, "Connect My Data (Retail Customer)"), + FB_54(54, FunctionBlockCategory.CUSTOMER_PII, "Basic Retail Customer Information"), + FB_55(55, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer Address Information"), + FB_56(56, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer Billing Information"), + FB_57(57, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer AccountAgreement Information"), + FB_58(58, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer ServiceLocation Information"), + FB_59(59, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer ServiceSupplier Information"), + FB_60(60, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer Meter Information"), + FB_61(61, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer EndDevice Information"), + FB_62(62, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer ProgramDateIdMapping Information"), + FB_63(63, FunctionBlockCategory.PLATFORM, "Common User Experience (Retail Customer)"), + FB_64(64, FunctionBlockCategory.PLATFORM, "Security and Privacy Classes (Retail Customer)"), + FB_65(65, FunctionBlockCategory.AUTHORIZATION, "Authorization and Authentication w/o Pre-Negotiated Scope (Retail Customer)"), + FB_67(67, FunctionBlockCategory.BULK_TRANSFER, "REST for Retail Customer Bulk"), + FB_68(68, FunctionBlockCategory.INTERACTION, "Query Parameters (Retail Customer)"), + FB_69(69, FunctionBlockCategory.INTERACTION, "PUSH Model (Retail Customer)"), + FB_70(70, FunctionBlockCategory.AUTHORIZATION, "Offline Authorization (Retail Customer)"); + + /** + * FB ids that NAESB ESPI 4.0 marks {@code [DEPRECATED]} (no longer assignable but reserved so + * the numbers are not reused). Recorded so {@link #categoryOf(int)} can distinguish a deprecated + * FB from one that was never defined. Includes the SFTP bulk FBs (34, 66), which the standard + * retired in favor of the REST bulk FBs (35, 67). + */ + public static final Set DEPRECATED_IDS = + Set.of(14, 18, 19, 32, 33, 34, 36, 38, 46, 47, 48, 49, 50, 66); + + private static final Map BY_ID = + Stream.of(values()).collect(Collectors.toUnmodifiableMap(FunctionBlock::getId, Function.identity())); + + private final int id; + private final FunctionBlockCategory category; + private final ServiceKind serviceKind; + private final String title; + + FunctionBlock(int id, FunctionBlockCategory category, String title) { + this(id, category, null, title); + } + + FunctionBlock(int id, FunctionBlockCategory category, ServiceKind serviceKind, String title) { + this.id = id; + this.category = category; + this.serviceKind = serviceKind; + this.title = title; + } + + /** @return the FB's numeric id (the value used in the {@code FB=} scope term). */ + public int getId() { + return id; + } + + /** @return the functional category this FB belongs to. */ + public FunctionBlockCategory getCategory() { + return category; + } + + /** + * @return the {@link ServiceKind} selected by this commodity FB, or empty for non-commodity FBs + * and for commodity FBs with no XSD-defined kind (FB 29 / Temperature) + */ + public Optional getServiceKind() { + return Optional.ofNullable(serviceKind); + } + + /** @return the NAESB spec title for this FB (diagnostic only). */ + public String getTitle() { + return title; + } + + /** + * Look up an active FB by its numeric id. + * + * @param id the FB number + * @return the catalogued FB, or empty if the id is deprecated or undefined + */ + public static Optional byId(int id) { + return Optional.ofNullable(BY_ID.get(id)); + } + + /** + * Categorize an FB id, tolerating ids that are not catalogued. + * + * @param id the FB number + * @return the active FB's category; {@link FunctionBlockCategory#DEPRECATED} for a + * spec-deprecated id; {@link FunctionBlockCategory#UNKNOWN} otherwise + */ + public static FunctionBlockCategory categoryOf(int id) { + FunctionBlock fb = BY_ID.get(id); + if (fb != null) { + return fb.category; + } + return DEPRECATED_IDS.contains(id) ? FunctionBlockCategory.DEPRECATED : FunctionBlockCategory.UNKNOWN; + } + + /** + * Resolve the {@link ServiceKind} a commodity FB id selects. + * + * @param id the FB number + * @return the service kind for a commodity FB, or empty for any other (or undefined) id + */ + public static Optional serviceKindOf(int id) { + FunctionBlock fb = BY_ID.get(id); + return fb == null ? Optional.empty() : fb.getServiceKind(); + } +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/scope/FunctionBlockCategory.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/scope/FunctionBlockCategory.java new file mode 100644 index 00000000..5d302be1 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/scope/FunctionBlockCategory.java @@ -0,0 +1,70 @@ +/* + * Copyright 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.scope; + +/** + * Functional categorization of ESPI 4.0 OAuth scope Function Block (FB) terms, + * per NAESB ESPI 4.0 §REQ.21.4.2.1.3.1 (ScopeFBTerms). + * + *

A granted ESPI scope is a set of Function Block IDs (e.g. {@code FB=4_5_15}). + * Every FB defined by the standard falls into exactly one of these categories. + * The first four are load-bearing — they drive grant→subscription + * resolution and resource-server enforcement (Phase 2, #122). The remainder exist so + * that a real spec FB is never mislabeled {@link #UNKNOWN}.

+ * + * @see FunctionBlock + * @see EspiScope + */ +public enum FunctionBlockCategory { + + /** Root resource an FB unlocks: FB 04 (Interval Metering / UsagePoint), FB 53 (Connect My Data, Retail Customer). */ + BASE, + + /** + * Commodity selector — maps to {@code ServiceCategory.kind} (FB 05-11, 29). + * Note FB 29 (Temperature Interval Metering) is a commodity FB with no {@code ServiceKind} + * equivalent in the ESPI XSD enumeration. + */ + COMMODITY, + + /** Shape of energy data exposed: summaries, cost, power quality (FB 12, 15, 16, 17, 27, 28). */ + ENERGY_DATA_SHAPE, + + /** Customer / PII resources from customer.xsd (FB 54-62). */ + CUSTOMER_PII, + + /** Delivery and query models: Download/Connect My Data, Query Parameters, PUSH (FB 02, 03, 37, 39, 52, 68, 69). */ + INTERACTION, + + /** Bulk transfer transport: REST bulk (FB 35, 67). The SFTP bulk FBs (34, 66) are deprecated. */ + BULK_TRANSFER, + + /** Authorization / authentication function blocks, incl. offline authorization (FB 31, 40, 65, 70). */ + AUTHORIZATION, + + /** Resource-management function blocks (FB 41, 44). */ + ADMINISTRATION, + + /** Cross-cutting platform FBs: Common, Common User Experience, Security and Privacy Classes (FB 01, 13, 30, 51, 63, 64). */ + PLATFORM, + + /** An FB ID that NAESB ESPI 4.0 marks DEPRECATED (FB 14, 18, 19, 32, 33, 34, 36, 38, 46-50, 66). */ + DEPRECATED, + + /** An FB ID not defined by the NAESB ESPI 4.0 ScopeFBTerms table. */ + UNKNOWN +} diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/scope/EspiScopeTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/scope/EspiScopeTest.java new file mode 100644 index 00000000..a01d93d8 --- /dev/null +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/scope/EspiScopeTest.java @@ -0,0 +1,194 @@ +/* + * Copyright 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.scope; + +import org.greenbuttonalliance.espi.common.domain.usage.enums.ServiceKind; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; + +/** + * Unit tests for {@link EspiScope} parsing and the category-aware views it derives from + * {@link FunctionBlock}. + */ +class EspiScopeTest { + + @Test + @DisplayName("parses the full ESPI scope grammar") + void parsesFullGrammar() { + EspiScope scope = EspiScope.parse("FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13"); + + assertThat(scope) + .satisfies(s -> assertThat(s.raw()).isEqualTo("FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13")) + .extracting( + EspiScope::functionBlocks, + EspiScope::intervalDuration, + EspiScope::blockDuration, + EspiScope::historyLength, + EspiScope::additionalParameters) + .containsExactly( + java.util.Set.of(4, 5, 15), + 3600, + "monthly", + 13, + java.util.Map.of()); + } + + @Test + @DisplayName("FB ids are sorted ascending regardless of input order") + void functionBlocksAreSorted() { + EspiScope scope = EspiScope.parse("FB=15_4_29_5"); + + assertThat(scope.functionBlocks()).containsExactly(4, 5, 15, 29); + } + + @Test + @DisplayName("unrecognized terms and bare tokens are preserved in additionalParameters") + void preservesUnknownTerms() { + EspiScope scope = EspiScope.parse("FB=4_5;ServiceKindFilter=ELECTRIC;DataCustodian_Admin_Access"); + + assertThat(scope.additionalParameters()) + .containsEntry("ServiceKindFilter", "ELECTRIC") + .containsEntry("DataCustodian_Admin_Access", ""); + } + + @Test + @DisplayName("recognized term keys are matched case-insensitively") + void keysAreCaseInsensitive() { + EspiScope scope = EspiScope.parse("fb=4_5;intervalduration=900;BLOCKDURATION=daily;HistoryLength=7"); + + assertThat(scope) + .extracting(EspiScope::functionBlocks, EspiScope::intervalDuration, EspiScope::blockDuration, EspiScope::historyLength) + .containsExactly(java.util.Set.of(4, 5), 900, "daily", 7); + assertThat(scope.additionalParameters()).isEmpty(); + } + + @Test + @DisplayName("tolerant of surrounding whitespace and stray/empty segments") + void tolerantOfWhitespaceAndEmptySegments() { + EspiScope scope = EspiScope.parse(" ;; FB = 4_5 ; ; IntervalDuration = 3600 ;"); + + assertThat(scope) + .extracting(EspiScope::functionBlocks, EspiScope::intervalDuration) + .containsExactly(java.util.Set.of(4, 5), 3600); + } + + @Test + @DisplayName("a scope with no FB term yields an empty functionBlocks set") + void scopeWithoutFbTerm() { + EspiScope scope = EspiScope.parse("IntervalDuration=3600"); + + assertThat(scope.functionBlocks()).isEmpty(); + assertThat(scope.intervalDuration()).isEqualTo(3600); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t"}) + @DisplayName("null or blank scope is rejected") + void rejectsNullOrBlank(String scope) { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> EspiScope.parse(scope)) + .withMessageContaining("must not be null or blank"); + } + + @Test + @DisplayName("a non-integer FB token is rejected") + void rejectsNonIntegerFb() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> EspiScope.parse("FB=4_x_15")) + .withMessageContaining("FB"); + } + + @Test + @DisplayName("a non-integer IntervalDuration is rejected") + void rejectsNonIntegerIntervalDuration() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> EspiScope.parse("FB=4;IntervalDuration=hourly")) + .withMessageContaining("IntervalDuration"); + } + + @Test + @DisplayName("containsFunctionBlock reflects membership") + void containsFunctionBlock() { + EspiScope scope = EspiScope.parse("FB=4_5_15"); + + assertThat(scope.containsFunctionBlock(5)).isTrue(); + assertThat(scope.containsFunctionBlock(16)).isFalse(); + } + + @Test + @DisplayName("functionBlocksIn filters by category") + void functionBlocksInByCategory() { + EspiScope scope = EspiScope.parse("FB=4_5_10_15_54_36_99"); + + assertThat(scope.functionBlocksIn(FunctionBlockCategory.BASE)).containsExactly(4); + assertThat(scope.functionBlocksIn(FunctionBlockCategory.COMMODITY)).containsExactly(5, 10); + assertThat(scope.functionBlocksIn(FunctionBlockCategory.ENERGY_DATA_SHAPE)).containsExactly(15); + assertThat(scope.functionBlocksIn(FunctionBlockCategory.CUSTOMER_PII)).containsExactly(54); + assertThat(scope.functionBlocksIn(FunctionBlockCategory.DEPRECATED)).containsExactly(36); + assertThat(scope.functionBlocksIn(FunctionBlockCategory.UNKNOWN)).containsExactly(99); + } + + @Test + @DisplayName("commodityServiceKinds aggregates kinds; temperature (FB 29) contributes none") + void commodityServiceKinds() { + EspiScope scope = EspiScope.parse("FB=4_5_10_11_29_15"); + + assertThat(scope.commodityServiceKinds()) + .containsExactlyInAnyOrder(ServiceKind.ELECTRICITY, ServiceKind.GAS, ServiceKind.WATER); + } + + @Test + @DisplayName("FB 29 alone yields no commodity ServiceKind") + void temperatureAloneYieldsNoServiceKind() { + assertThat(EspiScope.parse("FB=4_29").commodityServiceKinds()).isEmpty(); + } + + @Test + @DisplayName("includesCustomerPii and includesEnergyData reflect the granted FBs") + void domainPredicates() { + assertThat(EspiScope.parse("FB=53_54").includesCustomerPii()).isTrue(); + assertThat(EspiScope.parse("FB=4_5_15").includesCustomerPii()).isFalse(); + assertThat(EspiScope.parse("FB=4_5_15").includesEnergyData()).isTrue(); + assertThat(EspiScope.parse("FB=53_54").includesEnergyData()).isFalse(); + } + + @Test + @DisplayName("unrecognized and deprecated FB ids parse without error and are carried") + void carriesUnknownAndDeprecatedFbs() { + assertThatNoException().isThrownBy(() -> EspiScope.parse("FB=99_36")); + + EspiScope scope = EspiScope.parse("FB=99_36"); + assertThat(scope.functionBlocks()).containsExactly(36, 99); + } + + @Test + @DisplayName("the functionBlocks set is immutable") + void functionBlocksImmutable() { + EspiScope scope = EspiScope.parse("FB=4_5"); + + assertThatExceptionOfType(UnsupportedOperationException.class) + .isThrownBy(() -> scope.functionBlocks().add(99)); + } +} diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/scope/FunctionBlockTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/scope/FunctionBlockTest.java new file mode 100644 index 00000000..fa7fbf1c --- /dev/null +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/scope/FunctionBlockTest.java @@ -0,0 +1,132 @@ +/* + * Copyright 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.scope; + +import org.greenbuttonalliance.espi.common.domain.usage.enums.ServiceKind; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Guard tests that lock the {@link FunctionBlock} catalog to NAESB ESPI 4.0 §REQ.21.4.2.1.3.1. + */ +class FunctionBlockTest { + + @Test + @DisplayName("every FB id is unique and within the spec range 1..70") + void idsAreUniqueAndInRange() { + long distinctIds = Arrays.stream(FunctionBlock.values()).map(FunctionBlock::getId).distinct().count(); + + assertThat(FunctionBlock.values()) + .allSatisfy(fb -> assertThat(fb.getId()).isBetween(1, 70)) + .hasSize((int) distinctIds); + } + + @Test + @DisplayName("deprecated ids never overlap an active FB and never escape the 1..70 range") + void deprecatedIdsAreDisjointFromActive() { + var activeIds = Arrays.stream(FunctionBlock.values()).map(FunctionBlock::getId).toList(); + + assertThat(FunctionBlock.DEPRECATED_IDS) + .doesNotContainAnyElementsOf(activeIds) + .allSatisfy(id -> assertThat(id).isBetween(1, 70)); + } + + @Test + @DisplayName("every commodity FB except temperature (FB 29) maps to a ServiceKind; FB 29 maps to none") + void commodityServiceKindMapping() { + assertThat(FunctionBlock.values()) + .filteredOn(fb -> fb.getCategory() == FunctionBlockCategory.COMMODITY) + .allSatisfy(fb -> { + if (fb == FunctionBlock.FB_29) { + assertThat(fb.getServiceKind()).isEmpty(); + } + else { + assertThat(fb.getServiceKind()).isPresent(); + } + }); + } + + @Test + @DisplayName("no non-commodity FB carries a ServiceKind") + void onlyCommodityFbsCarryServiceKind() { + assertThat(FunctionBlock.values()) + .filteredOn(fb -> fb.getCategory() != FunctionBlockCategory.COMMODITY) + .allSatisfy(fb -> assertThat(fb.getServiceKind()).isEmpty()); + } + + @Test + @DisplayName("commodity FBs map to the expected ServiceKind per the #122 mapping") + void serviceKindOfKnownCommodities() { + assertThat(FunctionBlock.serviceKindOf(5)).contains(ServiceKind.ELECTRICITY); + assertThat(FunctionBlock.serviceKindOf(9)).contains(ServiceKind.ELECTRICITY); + assertThat(FunctionBlock.serviceKindOf(10)).contains(ServiceKind.GAS); + assertThat(FunctionBlock.serviceKindOf(11)).contains(ServiceKind.WATER); + assertThat(FunctionBlock.serviceKindOf(29)).isEmpty(); + assertThat(FunctionBlock.serviceKindOf(15)).isEmpty(); + assertThat(FunctionBlock.serviceKindOf(99)).isEmpty(); + } + + @Test + @DisplayName("categoryOf resolves the load-bearing categories") + void categoryOfLoadBearing() { + assertThat(FunctionBlock.categoryOf(4)).isEqualTo(FunctionBlockCategory.BASE); + assertThat(FunctionBlock.categoryOf(53)).isEqualTo(FunctionBlockCategory.BASE); + assertThat(FunctionBlock.categoryOf(5)).isEqualTo(FunctionBlockCategory.COMMODITY); + assertThat(FunctionBlock.categoryOf(29)).isEqualTo(FunctionBlockCategory.COMMODITY); + assertThat(FunctionBlock.categoryOf(15)).isEqualTo(FunctionBlockCategory.ENERGY_DATA_SHAPE); + assertThat(FunctionBlock.categoryOf(54)).isEqualTo(FunctionBlockCategory.CUSTOMER_PII); + assertThat(FunctionBlock.categoryOf(62)).isEqualTo(FunctionBlockCategory.CUSTOMER_PII); + } + + @ParameterizedTest + @ValueSource(ints = {14, 18, 19, 32, 33, 34, 36, 38, 46, 47, 48, 49, 50, 66}) + @DisplayName("deprecated ids resolve to DEPRECATED, not UNKNOWN") + void deprecatedIdsCategorizeAsDeprecated(int id) { + assertThat(FunctionBlock.categoryOf(id)).isEqualTo(FunctionBlockCategory.DEPRECATED); + assertThat(FunctionBlock.byId(id)).isEmpty(); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1, 20, 26, 42, 43, 45, 71, 99}) + @DisplayName("ids absent from the spec table resolve to UNKNOWN") + void undefinedIdsCategorizeAsUnknown(int id) { + assertThat(FunctionBlock.categoryOf(id)).isEqualTo(FunctionBlockCategory.UNKNOWN); + assertThat(FunctionBlock.byId(id)).isEmpty(); + } + + @Test + @DisplayName("byId returns the catalogued FB for an active id") + void byIdResolvesActive() { + assertThat(FunctionBlock.byId(5)).contains(FunctionBlock.FB_05); + assertThat(FunctionBlock.byId(53)).contains(FunctionBlock.FB_53); + } + + @Test + @DisplayName("SFTP bulk FBs are deprecated; REST bulk FBs are active") + void bulkTransferReflectsSftpDeprecation() { + assertThat(FunctionBlock.categoryOf(34)).isEqualTo(FunctionBlockCategory.DEPRECATED); + assertThat(FunctionBlock.categoryOf(66)).isEqualTo(FunctionBlockCategory.DEPRECATED); + assertThat(FunctionBlock.categoryOf(35)).isEqualTo(FunctionBlockCategory.BULK_TRANSFER); + assertThat(FunctionBlock.categoryOf(67)).isEqualTo(FunctionBlockCategory.BULK_TRANSFER); + } +}