diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/usage/SubscriptionEntity.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/usage/SubscriptionEntity.java
index 6d9e2868..2862d119 100644
--- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/usage/SubscriptionEntity.java
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/usage/SubscriptionEntity.java
@@ -24,37 +24,55 @@
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
-import org.greenbuttonalliance.espi.common.domain.common.IdentifiedObject;
+import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.proxy.HibernateProxy;
+import org.hibernate.type.SqlTypes;
-import java.time.Instant;
-import java.time.LocalDateTime;
-import java.time.ZoneOffset;
+import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
+import java.util.UUID;
/**
* Pure JPA/Hibernate entity for Subscription without JAXB concerns.
- *
+ *
* Defines the parameters of a subscription between Third Party and Data
* Custodian. Represents a formal agreement allowing third-party applications
* to access specific usage points and energy data for a retail customer.
+ *
+ *
Key characteristics:
+ *
+ * - Application-specific entity (NOT an ESPI standard resource)
+ * - Uses UUID for primary key (indexed for API access)
+ * - Links OAuth2 Authorization to accessible UsagePoints
+ * - Does NOT extend IdentifiedObject (no selfLink/upLink in database)
+ * - Atom Feed output handled by DTO layer with dynamic links
+ *
*/
@Entity
@Table(name = "subscriptions", indexes = {
@Index(name = "idx_subscription_retail_customer", columnList = "retail_customer_id"),
@Index(name = "idx_subscription_application", columnList = "application_information_id"),
- @Index(name = "idx_subscription_authorization", columnList = "authorization_id"),
- @Index(name = "idx_subscription_last_update", columnList = "last_update")
+ @Index(name = "idx_subscription_authorization", columnList = "authorization_id")
})
@Getter
@Setter
@NoArgsConstructor
-public class SubscriptionEntity extends IdentifiedObject {
+public class SubscriptionEntity implements Serializable {
private static final long serialVersionUID = 1L;
+ /**
+ * UUID primary key for API access.
+ * Subscription is an application-specific entity, not an ESPI resource,
+ * but uses UUID for consistent API patterns.
+ */
+ @Id
+ @JdbcTypeCode(SqlTypes.CHAR)
+ @Column(length = 36, columnDefinition = "char(36)", updatable = false, nullable = false)
+ private UUID id;
+
/**
* Optional hashed identifier for external references.
* Used for privacy and security in external communications.
@@ -62,13 +80,6 @@ public class SubscriptionEntity extends IdentifiedObject {
@Column(name = "hashed_id", length = 64)
private String hashedId;
- /**
- * Last update timestamp for this subscription.
- * Tracks when the subscription configuration was last modified.
- */
- @Column(name = "last_update")
- private LocalDateTime lastUpdate;
-
/**
* Retail customer who owns this subscription.
* The customer whose data is being accessed through this subscription.
@@ -107,149 +118,42 @@ public class SubscriptionEntity extends IdentifiedObject {
)
private List usagePoints = new ArrayList<>();
+ /**
+ * Constructor with UUID.
+ * UUID5 should be generated by EspiIdGeneratorService.generateSubscriptionId().
+ *
+ * @param id the UUID5 identifier (must be provided, not generated here)
+ */
+ public SubscriptionEntity(UUID id) {
+ this.id = id;
+ }
+
/**
* Constructor with basic subscription information.
- *
+ * Note: ID must be set separately using UUID5 from EspiIdGeneratorService.
+ *
* @param retailCustomer the customer who owns the subscription
* @param applicationInformation the application accessing the data
*/
public SubscriptionEntity(RetailCustomerEntity retailCustomer, ApplicationInformationEntity applicationInformation) {
this.retailCustomer = retailCustomer;
this.applicationInformation = applicationInformation;
- this.lastUpdate = LocalDateTime.now();
- }
-
- // Note: Simple setter for authorization is generated by Lombok @Data
- // Complex bidirectional relationship management removed - handled by DataCustodian/ThirdParty applications
-
- // Note: Usage point collection accessors are generated by Lombok @Data
- // Bidirectional relationship management methods removed - handled by DataCustodian/ThirdParty applications
-
- /**
- * Updates the last update timestamp to current time.
- */
- public void updateLastUpdate() {
- this.lastUpdate = LocalDateTime.now();
- }
-
- /**
- * Gets the last update time as LocalDateTime.
- *
- * @return last update as LocalDateTime, or null if not set
- */
- public LocalDateTime getLastUpdateAsLocalDateTime() {
- if (lastUpdate == null) {
- return null;
- }
- return lastUpdate;
- }
-
- /**
- * Sets the last update time from LocalDateTime.
- *
- * @param dateTime the LocalDateTime to set
- */
- public void setLastUpdateFromLocalDateTime(LocalDateTime dateTime) {
- this.lastUpdate = dateTime;
- }
-
- /**
- * Gets the last update time as Instant.
- *
- * @return last update as Instant, or null if not set
- */
- public Instant getLastUpdateAsInstant() {
- return lastUpdate != null ? lastUpdate.toInstant(ZoneOffset.UTC): null;
- }
-
- /**
- * Generates the self href for this subscription.
- *
- * @return self href string
- */
- public String getSelfHref() {
- return "/espi/1_1/resource/Subscription/" + getHashedId();
}
/**
- * Generates the up href for this subscription.
- *
- * @return up href string
+ * Gets a string representation of the ID for href generation.
+ *
+ * @return string representation of the UUID
*/
- public String getUpHref() {
- return "/espi/1_1/resource/Subscription";
- }
-
- /**
- * Overrides the default self href generation to use subscription specific logic.
- *
- * @return self href for this subscription
- */
- @Override
- protected String generateDefaultSelfHref() {
- return getSelfHref();
- }
-
- /**
- * Overrides the default up href generation to use subscription specific logic.
- *
- * @return up href for this subscription
- */
- @Override
- protected String generateDefaultUpHref() {
- return getUpHref();
- }
-
- /**
- * Merges data from another SubscriptionEntity.
- * Updates subscription parameters while preserving critical relationships.
- *
- * @param other the other subscription entity to merge from
- */
- public void merge(SubscriptionEntity other) {
- if (other != null) {
- super.merge(other);
-
- // Update basic fields
- this.hashedId = other.hashedId;
- this.lastUpdate = other.lastUpdate;
-
- // Update relationships if provided
- if (other.applicationInformation != null) {
- this.applicationInformation = other.applicationInformation;
- }
- if (other.authorization != null) {
- this.authorization = other.authorization;
- }
- if (other.retailCustomer != null) {
- this.retailCustomer = other.retailCustomer;
- }
- if (other.usagePoints != null) {
- this.usagePoints = new ArrayList<>(other.usagePoints);
- }
- }
- }
-
- /**
- * Clears all relationships when unlinking the entity.
- * Simplified - applications handle relationship cleanup.
- */
- public void unlink() {
- clearRelatedLinks();
-
- // Simple collection clearing - applications handle bidirectional cleanup
- usagePoints.clear();
-
- // Clear authorization with simple field assignment
- this.authorization = null;
-
- // Note: We don't clear retailCustomer or applicationInformation as they might be referenced elsewhere
+ public String getHashedId() {
+ // Return the explicit hashedId if set, otherwise use UUID string
+ return hashedId != null ? hashedId : (id != null ? id.toString() : null);
}
/**
* Checks if this subscription is active.
* A subscription is active if it has an active authorization.
- *
+ *
* @return true if subscription is active, false otherwise
*/
public boolean isActive() {
@@ -259,7 +163,7 @@ public boolean isActive() {
/**
* Checks if this subscription has expired.
* A subscription is expired if its authorization has expired.
- *
+ *
* @return true if subscription is expired, false otherwise
*/
public boolean isExpired() {
@@ -269,7 +173,7 @@ public boolean isExpired() {
/**
* Checks if this subscription is revoked.
* A subscription is revoked if its authorization is revoked.
- *
+ *
* @return true if subscription is revoked, false otherwise
*/
public boolean isRevoked() {
@@ -278,7 +182,7 @@ public boolean isRevoked() {
/**
* Gets the number of usage points in this subscription.
- *
+ *
* @return count of usage points
*/
public int getUsagePointCount() {
@@ -287,7 +191,7 @@ public int getUsagePointCount() {
/**
* Checks if this subscription includes the specified usage point.
- *
+ *
* @param usagePoint the usage point to check
* @return true if included, false otherwise
*/
@@ -295,18 +199,10 @@ public boolean includesUsagePoint(UsagePointEntity usagePoint) {
return usagePoints != null && usagePoints.contains(usagePoint);
}
- /**
- * Checks if this subscription includes a usage point with the specified ID.
- *
- * @param usagePointId the usage point ID to check
- * @return true if included, false otherwise
- */
- // Note: includesUsagePointId() method removed - applications can implement custom lookup logic
-
/**
* Gets the subscription ID from a resource URI.
* Extracts the ID from URI patterns like "/espi/1_1/resource/Subscription/{id}".
- *
+ *
* @param resourceURI the resource URI
* @return subscription ID, or null if not found
*/
@@ -320,41 +216,29 @@ public static String getSubscriptionIdFromUri(String resourceURI) {
/**
* Checks if this subscription belongs to the specified customer.
- *
+ *
* @param customerId the customer ID to check
* @return true if belongs to customer, false otherwise
*/
public boolean belongsToCustomer(Long customerId) {
- return retailCustomer != null && customerId != null &&
+ return retailCustomer != null && customerId != null &&
customerId.equals(retailCustomer.getId());
}
/**
- * Checks if this subscription is for the specified application.
- *
- * @param applicationId the application ID to check
- * @return true if for the application, false otherwise
- */
- // Note: isForApplication() method removed - applications can implement custom lookup logic
-
- /**
- * Pre-persist callback to set default values.
+ * Pre-persist callback to validate required fields.
+ * UUID5 must be set by the service layer before persisting.
+ *
+ * @throws IllegalStateException if ID is not set
*/
@PrePersist
protected void onCreate() {
- if (lastUpdate == null) {
- lastUpdate = LocalDateTime.now();
+ if (id == null) {
+ throw new IllegalStateException(
+ "Subscription ID must be set using EspiIdGeneratorService.generateSubscriptionId() before persisting");
}
}
- /**
- * Pre-update callback to update the last update timestamp.
- */
- @PreUpdate
- protected void onUpdate() {
- updateLastUpdate();
- }
-
@Override
public final boolean equals(Object o) {
if (this == o) return true;
@@ -375,11 +259,6 @@ public final int hashCode() {
public String toString() {
return getClass().getSimpleName() + "(" +
"id = " + getId() + ", " +
- "hashedId = " + getHashedId() + ", " +
- "lastUpdate = " + getLastUpdate() + ", " +
- "description = " + getDescription() + ", " +
- "created = " + getCreated() + ", " +
- "updated = " + getUpdated() + ", " +
- "published = " + getPublished() + ")";
+ "hashedId = " + getHashedId() + ")";
}
-}
\ No newline at end of file
+}
diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/SubscriptionDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/SubscriptionDto.java
new file mode 100644
index 00000000..9b034435
--- /dev/null
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/SubscriptionDto.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.common.dto.usage;
+
+import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto;
+import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto;
+import org.greenbuttonalliance.espi.common.dto.atom.LinkDto;
+
+import java.time.OffsetDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Builder for creating Subscription Atom Feeds with complete energy data.
+ *
+ * Creates an AtomFeedDto configured for Green Button Subscription output,
+ * containing all related energy data entries. This is output-only and generates
+ * Atom feeds for third-party applications accessing subscribed data.
+ *
+ * Feed structure:
+ *
+ * - ID: UUID5 format (urn:uuid:xxxxxxxx-xxxx-5xxx-xxxx-xxxxxxxxxxxx)
+ * - Title: "Green Button Energy Feed" or "Green Button Customer Feed"
+ * - Self link: https://greenbuttondata.org/espi/1_1/resource/Subscription/{id}
+ * - Related link: Certification URL (only if application is GBA certified)
+ *
+ *
+ * Entries included (in order):
+ *
+ * - UsagePoint entries
+ * - MeterReading entries
+ * - ReadingType entries
+ * - IntervalBlock entries
+ * - TimeConfiguration entry (LocalTimeParameters)
+ * - UsageSummary entries (if data exists)
+ * - ElectricPowerQualitySummary entries (if data exists)
+ *
+ */
+public class SubscriptionDto {
+
+ private static final String SELF_LINK_BASE = "https://greenbuttondata.org/espi/1_1/resource/Subscription/";
+ private static final String SELF_LINK_TYPE = "espi-entry/Subscription";
+ private static final String ENERGY_FEED_TITLE = "Green Button Energy Feed";
+ private static final String CUSTOMER_FEED_TITLE = "Green Button Customer Feed";
+
+ /**
+ * Schema type for determining feed title and namespace.
+ */
+ public enum SchemaType {
+ ENERGY, // espi: namespace
+ CUSTOMER // cust: namespace
+ }
+
+ private final String subscriptionId;
+ private final SchemaType schemaType;
+ private final OffsetDateTime published;
+ private final OffsetDateTime updated;
+ private final List links;
+ private final List entries;
+
+ /**
+ * Creates a new SubscriptionDto builder.
+ *
+ * @param subscriptionId the UUID5 subscription ID
+ * @param schemaType the schema type (ENERGY or CUSTOMER)
+ * @param published the published timestamp
+ * @param updated the updated timestamp
+ */
+ public SubscriptionDto(String subscriptionId, SchemaType schemaType,
+ OffsetDateTime published, OffsetDateTime updated) {
+ this.subscriptionId = subscriptionId;
+ this.schemaType = schemaType;
+ this.published = published;
+ this.updated = updated;
+ this.links = new ArrayList<>();
+ this.entries = new ArrayList<>();
+
+ // Add self link
+ this.links.add(new LinkDto("self", SELF_LINK_BASE + subscriptionId, SELF_LINK_TYPE));
+ }
+
+ /**
+ * Adds the GBA certification related link if the application is certified.
+ *
+ * @param certificationUrl the full certification URL (base + certification ID)
+ * @return this builder for chaining
+ */
+ public SubscriptionDto withCertificationLink(String certificationUrl) {
+ if (certificationUrl != null && !certificationUrl.trim().isEmpty()) {
+ this.links.add(new LinkDto("related", certificationUrl, "text/html"));
+ }
+ return this;
+ }
+
+ /**
+ * Adds UsagePoint entries to the feed.
+ *
+ * @param usagePointEntries the UsagePoint entries
+ * @return this builder for chaining
+ */
+ public SubscriptionDto withUsagePoints(List usagePointEntries) {
+ if (usagePointEntries != null) {
+ this.entries.addAll(usagePointEntries);
+ }
+ return this;
+ }
+
+ /**
+ * Adds MeterReading entries to the feed.
+ *
+ * @param meterReadingEntries the MeterReading entries
+ * @return this builder for chaining
+ */
+ public SubscriptionDto withMeterReadings(List meterReadingEntries) {
+ if (meterReadingEntries != null) {
+ this.entries.addAll(meterReadingEntries);
+ }
+ return this;
+ }
+
+ /**
+ * Adds ReadingType entries to the feed.
+ *
+ * @param readingTypeEntries the ReadingType entries
+ * @return this builder for chaining
+ */
+ public SubscriptionDto withReadingTypes(List readingTypeEntries) {
+ if (readingTypeEntries != null) {
+ this.entries.addAll(readingTypeEntries);
+ }
+ return this;
+ }
+
+ /**
+ * Adds IntervalBlock entries to the feed.
+ *
+ * @param intervalBlockEntries the IntervalBlock entries
+ * @return this builder for chaining
+ */
+ public SubscriptionDto withIntervalBlocks(List intervalBlockEntries) {
+ if (intervalBlockEntries != null) {
+ this.entries.addAll(intervalBlockEntries);
+ }
+ return this;
+ }
+
+ /**
+ * Adds TimeConfiguration (LocalTimeParameters) entry to the feed.
+ *
+ * @param timeConfigurationEntry the TimeConfiguration entry
+ * @return this builder for chaining
+ */
+ public SubscriptionDto withTimeConfiguration(AtomEntryDto timeConfigurationEntry) {
+ if (timeConfigurationEntry != null) {
+ this.entries.add(timeConfigurationEntry);
+ }
+ return this;
+ }
+
+ /**
+ * Adds UsageSummary entries to the feed if data exists.
+ *
+ * @param usageSummaryEntries the UsageSummary entries (may be empty)
+ * @return this builder for chaining
+ */
+ public SubscriptionDto withUsageSummaries(List usageSummaryEntries) {
+ if (usageSummaryEntries != null && !usageSummaryEntries.isEmpty()) {
+ this.entries.addAll(usageSummaryEntries);
+ }
+ return this;
+ }
+
+ /**
+ * Adds ElectricPowerQualitySummary entries to the feed if data exists.
+ *
+ * @param electricPowerQualitySummaryEntries the ElectricPowerQualitySummary entries (may be empty)
+ * @return this builder for chaining
+ */
+ public SubscriptionDto withElectricPowerQualitySummaries(List electricPowerQualitySummaryEntries) {
+ if (electricPowerQualitySummaryEntries != null && !electricPowerQualitySummaryEntries.isEmpty()) {
+ this.entries.addAll(electricPowerQualitySummaryEntries);
+ }
+ return this;
+ }
+
+ /**
+ * Adds a generic entry to the feed.
+ *
+ * @param entry the Atom entry to add
+ * @return this builder for chaining
+ */
+ public SubscriptionDto withEntry(AtomEntryDto entry) {
+ if (entry != null) {
+ this.entries.add(entry);
+ }
+ return this;
+ }
+
+ /**
+ * Adds multiple generic entries to the feed.
+ *
+ * @param entries the list of entries to add
+ * @return this builder for chaining
+ */
+ public SubscriptionDto withEntries(List entries) {
+ if (entries != null) {
+ this.entries.addAll(entries);
+ }
+ return this;
+ }
+
+ /**
+ * Builds the AtomFeedDto for this subscription.
+ *
+ * @return configured AtomFeedDto with all entries
+ */
+ public AtomFeedDto toAtomFeed() {
+ String id = "urn:uuid:" + subscriptionId;
+ String title = schemaType == SchemaType.CUSTOMER ? CUSTOMER_FEED_TITLE : ENERGY_FEED_TITLE;
+
+ return new AtomFeedDto(id, title, published, updated, links, entries);
+ }
+
+ // Getters for testing
+
+ public String getSubscriptionId() {
+ return subscriptionId;
+ }
+
+ public SchemaType getSchemaType() {
+ return schemaType;
+ }
+
+ public List getLinks() {
+ return links;
+ }
+
+ public List getEntries() {
+ return entries;
+ }
+
+ public int getEntryCount() {
+ return entries.size();
+ }
+}
diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/SubscriptionMapper.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/SubscriptionMapper.java
new file mode 100644
index 00000000..527e5edf
--- /dev/null
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/SubscriptionMapper.java
@@ -0,0 +1,125 @@
+/*
+ *
+ * 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.mapper.usage;
+
+import org.greenbuttonalliance.espi.common.domain.usage.SubscriptionEntity;
+import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto;
+import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto;
+import org.greenbuttonalliance.espi.common.dto.usage.SubscriptionDto;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+
+/**
+ * Mapper for converting SubscriptionEntity to AtomFeedDto.
+ *
+ * This is an output-only mapper that creates Atom feeds for Subscription data.
+ * It handles certification link injection based on application configuration.
+ *
+ * Note: This mapper does not support DTO-to-Entity conversion as
+ * Subscription feeds are output-only.
+ */
+@Component
+public class SubscriptionMapper {
+
+ @Value("${greenbutton.certified:false}")
+ private boolean certified;
+
+ @Value("${greenbutton.certification-id:}")
+ private String certificationId;
+
+ @Value("${greenbutton.certification-base-url:https://cert.greenbuttonalliance.org/certificate/}")
+ private String certificationBaseUrl;
+
+ /**
+ * Creates a SubscriptionDto builder for the given entity.
+ *
+ * The returned builder can be used to add entries before calling toAtomFeed().
+ *
+ * @param entity the subscription entity
+ * @param schemaType the schema type (ENERGY or CUSTOMER)
+ * @return a SubscriptionDto builder with self link and certification link (if applicable)
+ */
+ public SubscriptionDto toDto(SubscriptionEntity entity, SubscriptionDto.SchemaType schemaType) {
+ if (entity == null) {
+ return null;
+ }
+
+ LocalDateTime localDateTime = LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS);
+ OffsetDateTime now = localDateTime.atOffset(ZoneOffset.UTC);
+
+ String subscriptionId = entity.getId() != null ? entity.getId().toString() : null;
+
+ SubscriptionDto dto = new SubscriptionDto(subscriptionId, schemaType, now, now);
+
+ // Add certification link if application is certified
+ if (certified && certificationId != null && !certificationId.trim().isEmpty()) {
+ String certificationUrl = certificationBaseUrl + certificationId;
+ dto.withCertificationLink(certificationUrl);
+ }
+
+ return dto;
+ }
+
+ /**
+ * Creates an AtomFeedDto directly from a SubscriptionEntity with provided entries.
+ *
+ * This is a convenience method that combines toDto() and toAtomFeed().
+ *
+ * @param entity the subscription entity
+ * @param schemaType the schema type (ENERGY or CUSTOMER)
+ * @param entries the list of Atom entries to include in the feed
+ * @return configured AtomFeedDto
+ */
+ public AtomFeedDto toAtomFeed(SubscriptionEntity entity, SubscriptionDto.SchemaType schemaType,
+ List entries) {
+ SubscriptionDto dto = toDto(entity, schemaType);
+ if (dto == null) {
+ return null;
+ }
+ return dto.withEntries(entries).toAtomFeed();
+ }
+
+ /**
+ * Checks if the application is GBA certified.
+ *
+ * @return true if certified, false otherwise
+ */
+ public boolean isCertified() {
+ return certified;
+ }
+
+ /**
+ * Gets the full certification URL if application is certified.
+ *
+ * @return certification URL or null if not certified
+ */
+ public String getCertificationUrl() {
+ if (certified && certificationId != null && !certificationId.trim().isEmpty()) {
+ return certificationBaseUrl + certificationId;
+ }
+ return null;
+ }
+}
diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/SubscriptionRepository.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/SubscriptionRepository.java
index e5d5448f..ae0bfe63 100755
--- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/SubscriptionRepository.java
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/SubscriptionRepository.java
@@ -21,48 +21,55 @@
import org.greenbuttonalliance.espi.common.domain.usage.SubscriptionEntity;
import org.springframework.data.jpa.repository.JpaRepository;
-import org.springframework.data.jpa.repository.Modifying;
-import org.springframework.data.jpa.repository.Query;
-import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
-import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
+/**
+ * Repository for SubscriptionEntity using Spring Data JPA query derivation.
+ *
+ * All queries are automatically generated from method names by Spring Data JPA.
+ * No explicit @Query annotations needed.
+ */
@Repository
public interface SubscriptionRepository extends JpaRepository {
// JpaRepository provides: save(), findAll(), findById(), deleteById(), etc.
- // Note: merge() functionality is handled by save() in Spring Data JPA
+ /**
+ * Finds a subscription by its hashed ID.
+ * Used for API access where the hashed ID is exposed externally.
+ *
+ * @param hashedId the hashed identifier
+ * @return the subscription if found
+ */
Optional findByHashedId(String hashedId);
- @Modifying
- @Transactional
- @Query("DELETE FROM SubscriptionEntity s WHERE s.id = :id")
- void deleteById(@Param("id") UUID id);
-
- // findById is already provided by JpaRepository
- // Optional findById(UUID id) is inherited
-
- @Query("SELECT s FROM SubscriptionEntity s WHERE s.authorization.id = :authorizationId")
- Optional findByAuthorizationId(@Param("authorizationId") UUID id);
-
- // Missing NamedQueries that need to be added:
-
- @Query("SELECT s.id FROM SubscriptionEntity s")
- List findAllIds();
-
- @Query("SELECT s FROM SubscriptionEntity s WHERE s.retailCustomer.id = :retailCustomerId")
- List findByRetailCustomerId(@Param("retailCustomerId") Long retailCustomerId);
-
- @Query("SELECT s FROM SubscriptionEntity s WHERE s.applicationInformation.id = :applicationInformationId")
- List findByApplicationInformationId(@Param("applicationInformationId") UUID applicationInformationId);
+ /**
+ * Finds a subscription by its associated authorization ID.
+ * Uses index: idx_subscription_authorization
+ *
+ * @param id the authorization UUID
+ * @return the subscription if found
+ */
+ Optional findByAuthorization_Id(UUID id);
- @Query("SELECT s FROM SubscriptionEntity s WHERE s.authorization IS NOT NULL AND s.authorization.status = 'ACTIVE'")
- List findActiveSubscriptions();
+ /**
+ * Finds all subscriptions for a retail customer.
+ * Uses index: idx_subscription_retail_customer
+ *
+ * @param id the retail customer ID
+ * @return list of subscriptions
+ */
+ List findByRetailCustomer_Id(Long id);
- @Query("SELECT DISTINCT s FROM SubscriptionEntity s JOIN s.usagePoints up WHERE up.id = :usagePointId")
- List findByUsagePointId(@Param("usagePointId") UUID usagePointId);
+ /**
+ * Finds all subscriptions for an application.
+ * Uses index: idx_subscription_application
+ *
+ * @param id the application information UUID
+ * @return list of subscriptions
+ */
+ List findByApplicationInformation_Id(UUID id);
}
diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/EspiIdGeneratorService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/EspiIdGeneratorService.java
index bbd6f8d3..e587cbfa 100644
--- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/EspiIdGeneratorService.java
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/EspiIdGeneratorService.java
@@ -134,12 +134,51 @@ private byte[] uuidToBytes(UUID uuid) {
private UUID bytesToUUID(byte[] bytes) {
long msb = 0;
long lsb = 0;
-
+
for (int i = 0; i < 8; i++) {
msb = (msb << 8) | (bytes[i] & 0xff);
lsb = (lsb << 8) | (bytes[8 + i] & 0xff);
}
-
+
return new UUID(msb, lsb);
}
+
+ /**
+ * Generates a NAESB ESPI compliant UUID5 for a Subscription entity.
+ *
+ * The UUID5 is generated from clientId + username + current timestamp to ensure
+ * uniqueness even when the same client/user creates multiple subscriptions.
+ *
+ * @param clientId the OAuth2 client ID
+ * @param username the retail customer username
+ * @return a unique UUID5 identifier for the subscription
+ * @throws IllegalArgumentException if both clientId and username are null or empty
+ */
+ public UUID generateSubscriptionId(String clientId, String username) {
+ if ((clientId == null || clientId.trim().isEmpty()) &&
+ (username == null || username.trim().isEmpty())) {
+ throw new IllegalArgumentException("At least one of clientId or username must be provided");
+ }
+
+ // Build the name string with timestamp for non-repeatability
+ StringBuilder nameBuilder = new StringBuilder();
+ nameBuilder.append("Subscription:");
+ if (clientId != null && !clientId.trim().isEmpty()) {
+ nameBuilder.append(clientId.trim());
+ }
+ nameBuilder.append(":");
+ if (username != null && !username.trim().isEmpty()) {
+ nameBuilder.append(username.trim());
+ }
+ nameBuilder.append(":");
+ nameBuilder.append(System.currentTimeMillis());
+ nameBuilder.append(":");
+ nameBuilder.append(System.nanoTime()); // Additional entropy
+
+ try {
+ return generateUUID5(ESPI_NAMESPACE, nameBuilder.toString());
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException("SHA-1 algorithm not available", e);
+ }
+ }
}
\ No newline at end of file
diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/SubscriptionServiceImpl.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/SubscriptionServiceImpl.java
index 88d57000..0c54a376 100755
--- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/SubscriptionServiceImpl.java
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/SubscriptionServiceImpl.java
@@ -28,13 +28,13 @@
import org.greenbuttonalliance.espi.common.repositories.usage.SubscriptionRepository;
import org.greenbuttonalliance.espi.common.repositories.usage.UsagePointRepository;
import org.greenbuttonalliance.espi.common.service.ApplicationInformationService;
+import org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService;
import org.greenbuttonalliance.espi.common.service.RetailCustomerService;
import org.greenbuttonalliance.espi.common.service.SubscriptionService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-import java.time.LocalDateTime;
import java.util.*;
@Service
@@ -54,6 +54,9 @@ public class SubscriptionServiceImpl implements SubscriptionService {
@Autowired
private ApplicationInformationService applicationInformationService;
+ @Autowired
+ private EspiIdGeneratorService espiIdGeneratorService;
+
//@Lazy // Added to break the circular dependency
@Autowired
private RetailCustomerService retailCustomerService;
@@ -61,7 +64,10 @@ public class SubscriptionServiceImpl implements SubscriptionService {
@Override
public SubscriptionEntity createSubscription(String username, Set roles, String clientId) {
SubscriptionEntity subscription = new SubscriptionEntity();
- subscription.setId(UUID.randomUUID());
+
+ // Generate UUID5 from clientId + username with timestamp for uniqueness
+ UUID subscriptionId = espiIdGeneratorService.generateSubscriptionId(clientId, username);
+ subscription.setId(subscriptionId);
if (roles.contains("ROLE_USER")) {
// For user-based subscriptions, find the retail customer by username
@@ -91,7 +97,6 @@ public SubscriptionEntity createSubscription(String username, Set roles,
}
subscription.setRetailCustomer(null); // No specific retail customer for client-based subscriptions
}
- subscription.setLastUpdate(LocalDateTime.now());
subscriptionRepository.save(subscription);
logger.info("Created subscription for username: " + username);
@@ -132,7 +137,7 @@ public List findUsagePointIds(UUID subscriptionId) {
@Override
public SubscriptionEntity findByAuthorizationId(UUID id) {
- return subscriptionRepository.findByAuthorizationId(id).orElse(null);
+ return subscriptionRepository.findByAuthorization_Id(id).orElse(null);
}
@Override
diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/UsagePointServiceImpl.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/UsagePointServiceImpl.java
index 231b31f9..ec9ac5c3 100755
--- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/UsagePointServiceImpl.java
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/UsagePointServiceImpl.java
@@ -33,6 +33,7 @@
import org.springframework.transaction.annotation.Transactional;
import java.io.InputStream;
+import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@@ -106,8 +107,12 @@ public UsagePointEntity findByHashedId(String usagePointHashedId) {
@Override
public List findAllUpdatedFor(SubscriptionEntity subscription) {
- // TODO: Implement query to find usage points updated after subscription timestamp
- return usagePointRepository.findAllUpdatedAfter(subscription.getLastUpdateAsLocalDateTime());
+ // Return all usage points associated with the subscription
+ // Subscription no longer tracks lastUpdate timestamp
+ if (subscription == null || subscription.getUsagePoints() == null) {
+ return new ArrayList<>();
+ }
+ return new ArrayList<>(subscription.getUsagePoints());
}
@Override
diff --git a/openespi-common/src/main/resources/application.properties b/openespi-common/src/main/resources/application.properties
index 80c2e59a..56d884b3 100644
--- a/openespi-common/src/main/resources/application.properties
+++ b/openespi-common/src/main/resources/application.properties
@@ -49,5 +49,11 @@ espi.version=1.0
espi.namespace.usage=urn:uuid:espi:usage
espi.namespace.customer=urn:uuid:espi:customer
+# Green Button Alliance Certification Configuration
+# Set certified=true and provide certification-id when application is GBA certified
+greenbutton.certified=false
+greenbutton.certification-id=
+greenbutton.certification-base-url=https://cert.greenbuttonalliance.org/certificate/
+
# Default Logging Configuration
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
\ No newline at end of file
diff --git a/openespi-common/src/main/resources/db/migration/V1__Create_Base_Tables.sql b/openespi-common/src/main/resources/db/migration/V1__Create_Base_Tables.sql
index 11873a1d..6906ef7b 100644
--- a/openespi-common/src/main/resources/db/migration/V1__Create_Base_Tables.sql
+++ b/openespi-common/src/main/resources/db/migration/V1__Create_Base_Tables.sql
@@ -265,40 +265,27 @@ CREATE TABLE reading_type_related_links
CREATE INDEX idx_reading_type_related_links ON reading_type_related_links (reading_type_id);
--- Subscription Table (depends only on application_information and retail_customers)
+-- Subscription Table (application-specific entity, NOT an ESPI resource)
+-- Does not extend IdentifiedObject - no self_link, up_link, or timestamps
CREATE TABLE subscriptions
(
- id CHAR(36) PRIMARY KEY ,
- description VARCHAR(255),
- created TIMESTAMP NOT NULL,
- updated TIMESTAMP NOT NULL,
- published TIMESTAMP,
- up_link_rel VARCHAR(255),
- up_link_href VARCHAR(1024),
- up_link_type VARCHAR(255),
- self_link_rel VARCHAR(255),
- self_link_href VARCHAR(1024),
- self_link_type VARCHAR(255),
+ id CHAR(36) PRIMARY KEY,
-- Subscription specific fields
hashed_id VARCHAR(64),
- has_customer_matching_criteria BOOLEAN DEFAULT FALSE,
- last_update TIMESTAMP,
-- Foreign key relationships
- application_information_id CHAR(36),
+ application_information_id CHAR(36) NOT NULL,
authorization_id CHAR(36),
- retail_customer_id BIGINT,
+ retail_customer_id BIGINT NOT NULL,
FOREIGN KEY (application_information_id) REFERENCES application_information (id) ON DELETE CASCADE
-- FK constraint for retail_customer_id added in V2 after retail_customers table is created
);
-CREATE INDEX idx_subscription_app_id ON subscriptions (application_information_id);
-CREATE INDEX idx_subscription_customer_id ON subscriptions (retail_customer_id);
-CREATE INDEX idx_subscription_last_update ON subscriptions (last_update);
-CREATE INDEX idx_subscription_created ON subscriptions (created);
-CREATE INDEX idx_subscription_updated ON subscriptions (updated);
+CREATE INDEX idx_subscription_retail_customer ON subscriptions (retail_customer_id);
+CREATE INDEX idx_subscription_application ON subscriptions (application_information_id);
+CREATE INDEX idx_subscription_authorization ON subscriptions (authorization_id);
-- Batch List Table (Independent - no foreign key dependencies)
CREATE TABLE batch_lists
diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/usage/SubscriptionDtoTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/usage/SubscriptionDtoTest.java
new file mode 100644
index 00000000..d2aa8306
--- /dev/null
+++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/usage/SubscriptionDtoTest.java
@@ -0,0 +1,269 @@
+/*
+ *
+ * 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 org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto;
+import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto;
+import org.greenbuttonalliance.espi.common.dto.atom.LinkDto;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for SubscriptionDto builder.
+ * Verifies Atom Feed construction for Subscription output.
+ */
+@DisplayName("SubscriptionDto Builder Tests")
+class SubscriptionDtoTest {
+
+ private static final String TEST_SUBSCRIPTION_ID = "550e8400-e29b-51d4-a716-446655440000";
+ private static final String EXPECTED_SELF_HREF = "https://greenbuttondata.org/espi/1_1/resource/Subscription/" + TEST_SUBSCRIPTION_ID;
+ private static final String CERTIFICATION_URL = "https://cert.greenbuttonalliance.org/certificate/test-cert-id";
+
+ @Test
+ @DisplayName("Should create SubscriptionDto with Energy schema type")
+ void shouldCreateSubscriptionDtoWithEnergySchemaType() {
+ OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
+
+ SubscriptionDto dto = new SubscriptionDto(
+ TEST_SUBSCRIPTION_ID,
+ SubscriptionDto.SchemaType.ENERGY,
+ now,
+ now
+ );
+
+ assertThat(dto.getSubscriptionId()).isEqualTo(TEST_SUBSCRIPTION_ID);
+ assertThat(dto.getSchemaType()).isEqualTo(SubscriptionDto.SchemaType.ENERGY);
+ assertThat(dto.getLinks()).hasSize(1);
+ assertThat(dto.getLinks().get(0).rel()).isEqualTo("self");
+ assertThat(dto.getLinks().get(0).href()).isEqualTo(EXPECTED_SELF_HREF);
+ assertThat(dto.getLinks().get(0).type()).isEqualTo("espi-entry/Subscription");
+ }
+
+ @Test
+ @DisplayName("Should create SubscriptionDto with Customer schema type")
+ void shouldCreateSubscriptionDtoWithCustomerSchemaType() {
+ OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
+
+ SubscriptionDto dto = new SubscriptionDto(
+ TEST_SUBSCRIPTION_ID,
+ SubscriptionDto.SchemaType.CUSTOMER,
+ now,
+ now
+ );
+
+ assertThat(dto.getSchemaType()).isEqualTo(SubscriptionDto.SchemaType.CUSTOMER);
+ }
+
+ @Test
+ @DisplayName("Should add certification link when provided")
+ void shouldAddCertificationLinkWhenProvided() {
+ OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
+
+ SubscriptionDto dto = new SubscriptionDto(
+ TEST_SUBSCRIPTION_ID,
+ SubscriptionDto.SchemaType.ENERGY,
+ now,
+ now
+ ).withCertificationLink(CERTIFICATION_URL);
+
+ assertThat(dto.getLinks()).hasSize(2);
+
+ LinkDto relatedLink = dto.getLinks().stream()
+ .filter(link -> "related".equals(link.rel()))
+ .findFirst()
+ .orElse(null);
+
+ assertThat(relatedLink).isNotNull();
+ assertThat(relatedLink.href()).isEqualTo(CERTIFICATION_URL);
+ assertThat(relatedLink.type()).isEqualTo("text/html");
+ }
+
+ @Test
+ @DisplayName("Should not add certification link when null or empty")
+ void shouldNotAddCertificationLinkWhenNullOrEmpty() {
+ OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
+
+ SubscriptionDto dtoWithNull = new SubscriptionDto(
+ TEST_SUBSCRIPTION_ID,
+ SubscriptionDto.SchemaType.ENERGY,
+ now,
+ now
+ ).withCertificationLink(null);
+
+ assertThat(dtoWithNull.getLinks()).hasSize(1);
+
+ SubscriptionDto dtoWithEmpty = new SubscriptionDto(
+ TEST_SUBSCRIPTION_ID,
+ SubscriptionDto.SchemaType.ENERGY,
+ now,
+ now
+ ).withCertificationLink(" ");
+
+ assertThat(dtoWithEmpty.getLinks()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("Should add entries using fluent API")
+ void shouldAddEntriesUsingFluentApi() {
+ OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
+
+ AtomEntryDto usagePointEntry = new AtomEntryDto(
+ "urn:uuid:test-usage-point",
+ "Test Usage Point",
+ new UsagePointDto()
+ );
+
+ AtomEntryDto meterReadingEntry = new AtomEntryDto(
+ "urn:uuid:test-meter-reading",
+ "Test Meter Reading",
+ new MeterReadingDto()
+ );
+
+ SubscriptionDto dto = new SubscriptionDto(
+ TEST_SUBSCRIPTION_ID,
+ SubscriptionDto.SchemaType.ENERGY,
+ now,
+ now
+ )
+ .withEntry(usagePointEntry)
+ .withEntry(meterReadingEntry);
+
+ assertThat(dto.getEntries()).hasSize(2);
+ assertThat(dto.getEntryCount()).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("Should add multiple entries at once")
+ void shouldAddMultipleEntriesAtOnce() {
+ OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
+
+ List entries = List.of(
+ new AtomEntryDto("urn:uuid:entry1", "Entry 1", new UsagePointDto()),
+ new AtomEntryDto("urn:uuid:entry2", "Entry 2", new MeterReadingDto())
+ );
+
+ SubscriptionDto dto = new SubscriptionDto(
+ TEST_SUBSCRIPTION_ID,
+ SubscriptionDto.SchemaType.ENERGY,
+ now,
+ now
+ ).withEntries(entries);
+
+ assertThat(dto.getEntries()).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("Should convert to AtomFeedDto with Energy title")
+ void shouldConvertToAtomFeedDtoWithEnergyTitle() {
+ OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
+
+ SubscriptionDto dto = new SubscriptionDto(
+ TEST_SUBSCRIPTION_ID,
+ SubscriptionDto.SchemaType.ENERGY,
+ now,
+ now
+ );
+
+ AtomFeedDto feed = dto.toAtomFeed();
+
+ assertThat(feed.id()).isEqualTo("urn:uuid:" + TEST_SUBSCRIPTION_ID);
+ assertThat(feed.title()).isEqualTo("Green Button Energy Feed");
+ assertThat(feed.published()).isEqualTo(now);
+ assertThat(feed.updated()).isEqualTo(now);
+ assertThat(feed.links()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("Should convert to AtomFeedDto with Customer title")
+ void shouldConvertToAtomFeedDtoWithCustomerTitle() {
+ OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
+
+ SubscriptionDto dto = new SubscriptionDto(
+ TEST_SUBSCRIPTION_ID,
+ SubscriptionDto.SchemaType.CUSTOMER,
+ now,
+ now
+ );
+
+ AtomFeedDto feed = dto.toAtomFeed();
+
+ assertThat(feed.title()).isEqualTo("Green Button Customer Feed");
+ }
+
+ @Test
+ @DisplayName("Should include all entries in AtomFeedDto")
+ void shouldIncludeAllEntriesInAtomFeedDto() {
+ OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
+
+ AtomEntryDto entry1 = new AtomEntryDto("urn:uuid:entry1", "Entry 1", new UsagePointDto());
+ AtomEntryDto entry2 = new AtomEntryDto("urn:uuid:entry2", "Entry 2", new MeterReadingDto());
+
+ SubscriptionDto dto = new SubscriptionDto(
+ TEST_SUBSCRIPTION_ID,
+ SubscriptionDto.SchemaType.ENERGY,
+ now,
+ now
+ )
+ .withEntry(entry1)
+ .withEntry(entry2)
+ .withCertificationLink(CERTIFICATION_URL);
+
+ AtomFeedDto feed = dto.toAtomFeed();
+
+ assertThat(feed.entries()).hasSize(2);
+ assertThat(feed.links()).hasSize(2); // self + related
+ }
+
+ @Test
+ @DisplayName("Should handle null entry gracefully")
+ void shouldHandleNullEntryGracefully() {
+ OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
+
+ SubscriptionDto dto = new SubscriptionDto(
+ TEST_SUBSCRIPTION_ID,
+ SubscriptionDto.SchemaType.ENERGY,
+ now,
+ now
+ ).withEntry(null);
+
+ assertThat(dto.getEntries()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("Should handle null entries list gracefully")
+ void shouldHandleNullEntriesListGracefully() {
+ OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
+
+ SubscriptionDto dto = new SubscriptionDto(
+ TEST_SUBSCRIPTION_ID,
+ SubscriptionDto.SchemaType.ENERGY,
+ now,
+ now
+ ).withEntries(null);
+
+ assertThat(dto.getEntries()).isEmpty();
+ }
+}
diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/mapper/usage/SubscriptionMapperTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/mapper/usage/SubscriptionMapperTest.java
new file mode 100644
index 00000000..a2548578
--- /dev/null
+++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/mapper/usage/SubscriptionMapperTest.java
@@ -0,0 +1,180 @@
+/*
+ *
+ * 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.mapper.usage;
+
+import org.greenbuttonalliance.espi.common.domain.usage.SubscriptionEntity;
+import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto;
+import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto;
+import org.greenbuttonalliance.espi.common.dto.usage.MeterReadingDto;
+import org.greenbuttonalliance.espi.common.dto.usage.SubscriptionDto;
+import org.greenbuttonalliance.espi.common.dto.usage.UsagePointDto;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for SubscriptionMapper.
+ * Verifies Entity-to-DTO conversion with certification link handling.
+ */
+@DisplayName("SubscriptionMapper Tests")
+class SubscriptionMapperTest {
+
+ private SubscriptionMapper subscriptionMapper;
+
+ private static final UUID TEST_SUBSCRIPTION_ID = UUID.fromString("550e8400-e29b-51d4-a716-446655440000");
+ private static final String TEST_CERTIFICATION_ID = "cert-uuid-12345";
+ private static final String CERTIFICATION_BASE_URL = "https://cert.greenbuttonalliance.org/certificate/";
+
+ @BeforeEach
+ void setUp() {
+ subscriptionMapper = new SubscriptionMapper();
+ // Set default values (not certified)
+ ReflectionTestUtils.setField(subscriptionMapper, "certified", false);
+ ReflectionTestUtils.setField(subscriptionMapper, "certificationId", "");
+ ReflectionTestUtils.setField(subscriptionMapper, "certificationBaseUrl", CERTIFICATION_BASE_URL);
+ }
+
+ @Test
+ @DisplayName("Should return null for null entity")
+ void shouldReturnNullForNullEntity() {
+ SubscriptionDto dto = subscriptionMapper.toDto(null, SubscriptionDto.SchemaType.ENERGY);
+
+ assertThat(dto).isNull();
+ }
+
+ @Test
+ @DisplayName("Should create SubscriptionDto from entity")
+ void shouldCreateSubscriptionDtoFromEntity() {
+ SubscriptionEntity entity = new SubscriptionEntity(TEST_SUBSCRIPTION_ID);
+
+ SubscriptionDto dto = subscriptionMapper.toDto(entity, SubscriptionDto.SchemaType.ENERGY);
+
+ assertThat(dto).isNotNull();
+ assertThat(dto.getSubscriptionId()).isEqualTo(TEST_SUBSCRIPTION_ID.toString());
+ assertThat(dto.getSchemaType()).isEqualTo(SubscriptionDto.SchemaType.ENERGY);
+ assertThat(dto.getLinks()).hasSize(1); // Only self link
+ assertThat(dto.getLinks().get(0).rel()).isEqualTo("self");
+ }
+
+ @Test
+ @DisplayName("Should include certification link when certified")
+ void shouldIncludeCertificationLinkWhenCertified() {
+ // Configure as certified
+ ReflectionTestUtils.setField(subscriptionMapper, "certified", true);
+ ReflectionTestUtils.setField(subscriptionMapper, "certificationId", TEST_CERTIFICATION_ID);
+
+ SubscriptionEntity entity = new SubscriptionEntity(TEST_SUBSCRIPTION_ID);
+
+ SubscriptionDto dto = subscriptionMapper.toDto(entity, SubscriptionDto.SchemaType.ENERGY);
+
+ assertThat(dto.getLinks()).hasSize(2); // self + related
+ assertThat(dto.getLinks().stream()
+ .filter(link -> "related".equals(link.rel()))
+ .findFirst())
+ .isPresent();
+ }
+
+ @Test
+ @DisplayName("Should not include certification link when not certified")
+ void shouldNotIncludeCertificationLinkWhenNotCertified() {
+ // Keep default (not certified)
+ SubscriptionEntity entity = new SubscriptionEntity(TEST_SUBSCRIPTION_ID);
+
+ SubscriptionDto dto = subscriptionMapper.toDto(entity, SubscriptionDto.SchemaType.ENERGY);
+
+ assertThat(dto.getLinks()).hasSize(1); // Only self link
+ assertThat(dto.getLinks().stream()
+ .filter(link -> "related".equals(link.rel()))
+ .findFirst())
+ .isEmpty();
+ }
+
+ @Test
+ @DisplayName("Should create AtomFeedDto directly with entries")
+ void shouldCreateAtomFeedDtoDirectlyWithEntries() {
+ SubscriptionEntity entity = new SubscriptionEntity(TEST_SUBSCRIPTION_ID);
+
+ List entries = List.of(
+ new AtomEntryDto("urn:uuid:entry1", "Usage Point", new UsagePointDto()),
+ new AtomEntryDto("urn:uuid:entry2", "Meter Reading", new MeterReadingDto())
+ );
+
+ AtomFeedDto feed = subscriptionMapper.toAtomFeed(entity, SubscriptionDto.SchemaType.ENERGY, entries);
+
+ assertThat(feed).isNotNull();
+ assertThat(feed.id()).isEqualTo("urn:uuid:" + TEST_SUBSCRIPTION_ID);
+ assertThat(feed.title()).isEqualTo("Green Button Energy Feed");
+ assertThat(feed.entries()).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("Should return null AtomFeedDto for null entity")
+ void shouldReturnNullAtomFeedDtoForNullEntity() {
+ AtomFeedDto feed = subscriptionMapper.toAtomFeed(null, SubscriptionDto.SchemaType.ENERGY, List.of());
+
+ assertThat(feed).isNull();
+ }
+
+ @Test
+ @DisplayName("Should return correct certification URL when certified")
+ void shouldReturnCorrectCertificationUrlWhenCertified() {
+ ReflectionTestUtils.setField(subscriptionMapper, "certified", true);
+ ReflectionTestUtils.setField(subscriptionMapper, "certificationId", TEST_CERTIFICATION_ID);
+
+ String certUrl = subscriptionMapper.getCertificationUrl();
+
+ assertThat(certUrl).isEqualTo(CERTIFICATION_BASE_URL + TEST_CERTIFICATION_ID);
+ }
+
+ @Test
+ @DisplayName("Should return null certification URL when not certified")
+ void shouldReturnNullCertificationUrlWhenNotCertified() {
+ String certUrl = subscriptionMapper.getCertificationUrl();
+
+ assertThat(certUrl).isNull();
+ }
+
+ @Test
+ @DisplayName("Should report certified status correctly")
+ void shouldReportCertifiedStatusCorrectly() {
+ assertThat(subscriptionMapper.isCertified()).isFalse();
+
+ ReflectionTestUtils.setField(subscriptionMapper, "certified", true);
+ ReflectionTestUtils.setField(subscriptionMapper, "certificationId", TEST_CERTIFICATION_ID);
+
+ assertThat(subscriptionMapper.isCertified()).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should use Customer title for Customer schema type")
+ void shouldUseCustomerTitleForCustomerSchemaType() {
+ SubscriptionEntity entity = new SubscriptionEntity(TEST_SUBSCRIPTION_ID);
+
+ AtomFeedDto feed = subscriptionMapper.toAtomFeed(entity, SubscriptionDto.SchemaType.CUSTOMER, List.of());
+
+ assertThat(feed.title()).isEqualTo("Green Button Customer Feed");
+ }
+}
diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/repositories/usage/SubscriptionRepositoryTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/repositories/usage/SubscriptionRepositoryTest.java
index f4122e1b..52f74a6c 100644
--- a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/repositories/usage/SubscriptionRepositoryTest.java
+++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/repositories/usage/SubscriptionRepositoryTest.java
@@ -28,17 +28,19 @@
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
-import java.time.LocalDateTime;
-import java.time.temporal.ChronoUnit;
import java.util.*;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Comprehensive test suite for SubscriptionRepository.
- *
+ *
* Tests subscription lifecycle management, all custom query methods,
* relationship testing, and validation constraints.
+ *
+ * Note: Subscription is an application-specific entity (NOT an ESPI resource),
+ * so it does not extend IdentifiedObject and has no description, created,
+ * updated, or lastUpdate fields. The UUID must be set before persisting.
*/
@DisplayName("Subscription Repository Tests")
class SubscriptionRepositoryTest extends BaseRepositoryTest {
@@ -60,12 +62,11 @@ class SubscriptionRepositoryTest extends BaseRepositoryTest {
/**
* Creates a valid SubscriptionEntity for testing.
+ * Sets UUID since it's required before persisting.
*/
private SubscriptionEntity createValidSubscription() {
- SubscriptionEntity subscription = new SubscriptionEntity();
- subscription.setDescription("Test Subscription");
+ SubscriptionEntity subscription = new SubscriptionEntity(UUID.randomUUID());
subscription.setHashedId("hashed-" + faker.internet().uuid());
- subscription.setLastUpdate(LocalDateTime.now());
return subscription;
}
@@ -75,7 +76,7 @@ private SubscriptionEntity createValidSubscription() {
private ApplicationInformationEntity createValidApplicationInformation() {
ApplicationInformationEntity app = new ApplicationInformationEntity();
app.setDescription("Test Application Information");
-
+
// Ensure clientId meets validation constraints (2-64 chars, @NotEmpty) and is unique
String uniqueId = UUID.randomUUID().toString().substring(0, 8);
String clientId = "test-client-" + uniqueId;
@@ -83,7 +84,7 @@ private ApplicationInformationEntity createValidApplicationInformation() {
clientId = clientId.substring(0, 64);
}
app.setClientId(clientId);
-
+
app.setClientSecret(faker.internet().password());
// Ensure dataCustodianId meets validation constraints (2-64 chars if present)
@@ -92,11 +93,11 @@ private ApplicationInformationEntity createValidApplicationInformation() {
dataCustodianId = dataCustodianId.substring(0, 64);
}
app.setDataCustodianId(dataCustodianId);
-
+
Set grantTypes = new HashSet<>();
grantTypes.add(GrantType.AUTHORIZATION_CODE);
app.setGrantTypes(grantTypes);
-
+
return app;
}
@@ -128,14 +129,13 @@ void shouldSaveAndRetrieveSubscriptionSuccessfully() {
assertThat(saved).isNotNull();
assertThat(saved.getId()).isNotNull();
assertThat(retrieved).isPresent();
- assertThat(retrieved.get().getDescription()).isEqualTo("Test Subscription");
assertThat(retrieved.get().getRetailCustomer().getId()).isEqualTo(savedCustomer.getId());
assertThat(retrieved.get().getApplicationInformation().getId()).isEqualTo(savedApp.getId());
}
@Test
- @DisplayName("Should save subscription with lifecycle fields")
- void shouldSaveSubscriptionWithLifecycleFields() {
+ @DisplayName("Should save subscription with hashed ID")
+ void shouldSaveSubscriptionWithHashedId() {
// Arrange
RetailCustomerEntity customer = TestDataBuilders.createValidRetailCustomer();
customer.setUsername("life" + UUID.randomUUID().toString().substring(0, 8));
@@ -147,10 +147,8 @@ void shouldSaveSubscriptionWithLifecycleFields() {
SubscriptionEntity subscription = createValidSubscription();
subscription.setRetailCustomer(savedCustomer);
subscription.setApplicationInformation(savedApp);
- subscription.setDescription("Subscription with Lifecycle Fields");
-
- // 1 hour ago
- subscription.setLastUpdate(LocalDateTime.now().minus(1, ChronoUnit.HOURS));
+ String expectedHashedId = "test-hashed-id-" + faker.number().digits(8);
+ subscription.setHashedId(expectedHashedId);
// Act
SubscriptionEntity saved = subscriptionRepository.save(subscription);
@@ -160,9 +158,7 @@ void shouldSaveSubscriptionWithLifecycleFields() {
// Assert
assertThat(retrieved).isPresent();
SubscriptionEntity entity = retrieved.get();
- assertThat(entity.getHashedId()).isNotNull();
- assertThat(entity.getLastUpdate()).isNotNull();
- assertThat(entity.getLastUpdate().getHour()).isLessThan(LocalDateTime.now().getHour());
+ assertThat(entity.getHashedId()).isEqualTo(expectedHashedId);
}
@Test
@@ -181,13 +177,12 @@ void shouldFindAllSubscriptions() {
createValidSubscription(),
createValidSubscription()
);
-
- for (int i = 0; i < subscriptions.size(); i++) {
- subscriptions.get(i).setDescription("Subscription " + (i + 1));
- subscriptions.get(i).setRetailCustomer(savedCustomer);
- subscriptions.get(i).setApplicationInformation(savedApp);
+
+ for (SubscriptionEntity sub : subscriptions) {
+ sub.setRetailCustomer(savedCustomer);
+ sub.setApplicationInformation(savedApp);
}
-
+
subscriptionRepository.saveAll(subscriptions);
flushAndClear();
@@ -196,8 +191,6 @@ void shouldFindAllSubscriptions() {
// Assert
assertThat(allSubscriptions).hasSizeGreaterThanOrEqualTo(3);
- assertThat(allSubscriptions).extracting(SubscriptionEntity::getDescription)
- .contains("Subscription 1", "Subscription 2", "Subscription 3");
}
@Test
@@ -212,10 +205,9 @@ void shouldDeleteSubscriptionSuccessfully() {
ApplicationInformationEntity savedApp = applicationInformationRepository.save(app);
SubscriptionEntity subscription = createValidSubscription();
- subscription.setDescription("Subscription to Delete");
subscription.setRetailCustomer(savedCustomer);
subscription.setApplicationInformation(savedApp);
-
+
SubscriptionEntity saved = subscriptionRepository.save(subscription);
UUID subscriptionId = saved.getId();
flushAndClear();
@@ -243,7 +235,7 @@ void shouldCheckIfSubscriptionExists() {
SubscriptionEntity subscription = createValidSubscription();
subscription.setRetailCustomer(savedCustomer);
subscription.setApplicationInformation(savedApp);
-
+
SubscriptionEntity saved = subscriptionRepository.save(subscription);
flushAndClear();
@@ -257,7 +249,7 @@ void shouldCheckIfSubscriptionExists() {
void shouldCountSubscriptions() {
// Arrange
long initialCount = subscriptionRepository.count();
-
+
RetailCustomerEntity customer = TestDataBuilders.createValidRetailCustomer();
customer.setUsername("count" + UUID.randomUUID().toString().substring(0, 8));
RetailCustomerEntity savedCustomer = retailCustomerRepository.save(customer);
@@ -269,12 +261,12 @@ void shouldCountSubscriptions() {
createValidSubscription(),
createValidSubscription()
);
-
+
subscriptions.forEach(sub -> {
sub.setRetailCustomer(savedCustomer);
sub.setApplicationInformation(savedApp);
});
-
+
subscriptionRepository.saveAll(subscriptions);
flushAndClear();
@@ -304,10 +296,9 @@ void shouldFindSubscriptionByHashedId() {
SubscriptionEntity subscription = createValidSubscription();
String hashedId = "unique-hashed-" + faker.internet().uuid();
subscription.setHashedId(hashedId);
- subscription.setDescription("Subscription with Specific Hashed ID");
subscription.setRetailCustomer(savedCustomer);
subscription.setApplicationInformation(savedApp);
-
+
subscriptionRepository.save(subscription);
flushAndClear();
@@ -317,7 +308,6 @@ void shouldFindSubscriptionByHashedId() {
// Assert
assertThat(result).isPresent();
assertThat(result.get().getHashedId()).isEqualTo(hashedId);
- assertThat(result.get().getDescription()).isEqualTo("Subscription with Specific Hashed ID");
}
@Test
@@ -338,60 +328,19 @@ void shouldFindSubscriptionByAuthorizationId() {
AuthorizationEntity savedAuth = authorizationRepository.save(auth);
SubscriptionEntity subscription = createValidSubscription();
- subscription.setDescription("Subscription with Authorization");
subscription.setRetailCustomer(savedCustomer);
subscription.setApplicationInformation(savedApp);
subscription.setAuthorization(savedAuth);
-
+
subscriptionRepository.save(subscription);
flushAndClear();
// Act
- Optional result = subscriptionRepository.findByAuthorizationId(savedAuth.getId());
+ Optional result = subscriptionRepository.findByAuthorization_Id(savedAuth.getId());
// Assert
assertThat(result).isPresent();
assertThat(result.get().getAuthorization().getId()).isEqualTo(savedAuth.getId());
- assertThat(result.get().getDescription()).isEqualTo("Subscription with Authorization");
- }
-
- @Test
- @DisplayName("Should find all subscription IDs")
- void shouldFindAllSubscriptionIds() {
- // Arrange
- long initialCount = subscriptionRepository.count();
-
- RetailCustomerEntity customer = TestDataBuilders.createValidRetailCustomer();
- customer.setUsername("ids" + faker.number().digits(7));
- RetailCustomerEntity savedCustomer = retailCustomerRepository.save(customer);
-
- ApplicationInformationEntity app = createValidApplicationInformation();
- ApplicationInformationEntity savedApp = applicationInformationRepository.save(app);
-
- List subscriptions = List.of(
- createValidSubscription(),
- createValidSubscription(),
- createValidSubscription()
- );
-
- subscriptions.forEach(sub -> {
- sub.setRetailCustomer(savedCustomer);
- sub.setApplicationInformation(savedApp);
- });
-
- List savedSubscriptions = subscriptionRepository.saveAll(subscriptions);
- flushAndClear();
-
- // Act
- List allIds = subscriptionRepository.findAllIds();
-
- // Assert
- assertThat(allIds).hasSizeGreaterThanOrEqualTo(3);
- assertThat(allIds).contains(
- savedSubscriptions.get(0).getId(),
- savedSubscriptions.get(1).getId(),
- savedSubscriptions.get(2).getId()
- );
}
@Test
@@ -406,12 +355,10 @@ void shouldFindSubscriptionsByRetailCustomerId() {
ApplicationInformationEntity savedApp = applicationInformationRepository.save(app);
SubscriptionEntity sub1 = createValidSubscription();
- sub1.setDescription("Customer Subscription 1");
sub1.setRetailCustomer(savedCustomer);
sub1.setApplicationInformation(savedApp);
-
+
SubscriptionEntity sub2 = createValidSubscription();
- sub2.setDescription("Customer Subscription 2");
sub2.setRetailCustomer(savedCustomer);
sub2.setApplicationInformation(savedApp);
@@ -419,12 +366,10 @@ void shouldFindSubscriptionsByRetailCustomerId() {
flushAndClear();
// Act
- List results = subscriptionRepository.findByRetailCustomerId(savedCustomer.getId());
+ List results = subscriptionRepository.findByRetailCustomer_Id(savedCustomer.getId());
// Assert
assertThat(results).hasSize(2);
- assertThat(results).extracting(SubscriptionEntity::getDescription)
- .contains("Customer Subscription 1", "Customer Subscription 2");
}
@Test
@@ -440,12 +385,10 @@ void shouldFindSubscriptionsByApplicationInformationId() {
ApplicationInformationEntity savedApp = applicationInformationRepository.save(app);
SubscriptionEntity sub1 = createValidSubscription();
- sub1.setDescription("App Subscription 1");
sub1.setRetailCustomer(savedCustomer);
sub1.setApplicationInformation(savedApp);
-
+
SubscriptionEntity sub2 = createValidSubscription();
- sub2.setDescription("App Subscription 2");
sub2.setRetailCustomer(savedCustomer);
sub2.setApplicationInformation(savedApp);
@@ -453,96 +396,10 @@ void shouldFindSubscriptionsByApplicationInformationId() {
flushAndClear();
// Act
- List results = subscriptionRepository.findByApplicationInformationId(savedApp.getId());
+ List results = subscriptionRepository.findByApplicationInformation_Id(savedApp.getId());
// Assert
assertThat(results).hasSize(2);
- assertThat(results).extracting(SubscriptionEntity::getDescription)
- .contains("App Subscription 1", "App Subscription 2");
- }
-
- @Test
- @DisplayName("Should find active subscriptions")
- void shouldFindActiveSubscriptions() {
- // Arrange
- RetailCustomerEntity customer = TestDataBuilders.createValidRetailCustomer();
- customer.setUsername("active" + faker.number().digits(5));
- RetailCustomerEntity savedCustomer = retailCustomerRepository.save(customer);
-
- ApplicationInformationEntity app = createValidApplicationInformation();
- ApplicationInformationEntity savedApp = applicationInformationRepository.save(app);
-
- // Create active authorization
- AuthorizationEntity activeAuth = new AuthorizationEntity();
- activeAuth.setDescription("Active Authorization");
- activeAuth.setAccessToken("active-token-" + faker.internet().uuid());
- activeAuth.setStatus(AuthorizationEntity.STATUS_ACTIVE);
- AuthorizationEntity savedActiveAuth = authorizationRepository.save(activeAuth);
-
- // Create inactive authorization
- AuthorizationEntity inactiveAuth = new AuthorizationEntity();
- inactiveAuth.setDescription("Inactive Authorization");
- inactiveAuth.setAccessToken("inactive-token-" + faker.internet().uuid());
- inactiveAuth.setStatus(AuthorizationEntity.STATUS_REVOKED);
- AuthorizationEntity savedInactiveAuth = authorizationRepository.save(inactiveAuth);
-
- // Create subscriptions
- SubscriptionEntity activeSub = createValidSubscription();
- activeSub.setDescription("Active Subscription");
- activeSub.setRetailCustomer(savedCustomer);
- activeSub.setApplicationInformation(savedApp);
- activeSub.setAuthorization(savedActiveAuth);
-
- SubscriptionEntity inactiveSub = createValidSubscription();
- inactiveSub.setDescription("Inactive Subscription");
- inactiveSub.setRetailCustomer(savedCustomer);
- inactiveSub.setApplicationInformation(savedApp);
- inactiveSub.setAuthorization(savedInactiveAuth);
-
- subscriptionRepository.saveAll(List.of(activeSub, inactiveSub));
- flushAndClear();
-
- // Act
- List results = subscriptionRepository.findActiveSubscriptions();
-
- // Assert
- assertThat(results).hasSize(1);
- assertThat(results.get(0).getDescription()).isEqualTo("Active Subscription");
- assertThat(results.get(0).getAuthorization().getStatus()).isEqualTo(AuthorizationEntity.STATUS_ACTIVE);
- }
-
- @Test
- @DisplayName("Should find subscriptions by usage point ID")
- void shouldFindSubscriptionsByUsagePointId() {
- // Arrange
- RetailCustomerEntity customer = TestDataBuilders.createValidRetailCustomer();
- customer.setUsername("usage" + faker.number().digits(6));
- RetailCustomerEntity savedCustomer = retailCustomerRepository.save(customer);
-
- ApplicationInformationEntity app = createValidApplicationInformation();
- ApplicationInformationEntity savedApp = applicationInformationRepository.save(app);
-
- UsagePointEntity usagePoint = TestDataBuilders.createValidUsagePoint();
- usagePoint.setDescription("Test Usage Point for Subscription");
- UsagePointEntity savedUsagePoint = usagePointRepository.save(usagePoint);
-
- SubscriptionEntity subscription = createValidSubscription();
- subscription.setDescription("Subscription with Usage Point");
- subscription.setRetailCustomer(savedCustomer);
- subscription.setApplicationInformation(savedApp);
- subscription.getUsagePoints().add(savedUsagePoint);
-
- subscriptionRepository.save(subscription);
- flushAndClear();
-
- // Act
- List results = subscriptionRepository.findByUsagePointId(savedUsagePoint.getId());
-
- // Assert
- assertThat(results).hasSize(1);
- assertThat(results.get(0).getDescription()).isEqualTo("Subscription with Usage Point");
- assertThat(results.get(0).getUsagePoints()).hasSize(1);
- assertThat(results.get(0).getUsagePoints().get(0).getId()).isEqualTo(savedUsagePoint.getId());
}
@Test
@@ -550,10 +407,9 @@ void shouldFindSubscriptionsByUsagePointId() {
void shouldHandleEmptyResultsGracefully() {
// Act & Assert
assertThat(subscriptionRepository.findByHashedId("nonexistent-hash")).isEmpty();
- assertThat(subscriptionRepository.findByAuthorizationId(UUID.randomUUID())).isEmpty();
- assertThat(subscriptionRepository.findByRetailCustomerId(999999L)).isEmpty();
- assertThat(subscriptionRepository.findByApplicationInformationId(UUID.randomUUID())).isEmpty();
- assertThat(subscriptionRepository.findByUsagePointId(UUID.randomUUID())).isEmpty();
+ assertThat(subscriptionRepository.findByAuthorization_Id(UUID.randomUUID())).isEmpty();
+ assertThat(subscriptionRepository.findByRetailCustomer_Id(999999L)).isEmpty();
+ assertThat(subscriptionRepository.findByApplicationInformation_Id(UUID.randomUUID())).isEmpty();
}
}
@@ -587,7 +443,6 @@ void shouldCreateSubscriptionWithAllRelationships() {
UsagePointEntity savedUsagePoint2 = usagePointRepository.save(usagePoint2);
SubscriptionEntity subscription = createValidSubscription();
- subscription.setDescription("Subscription with All Relationships");
subscription.setRetailCustomer(savedCustomer);
subscription.setApplicationInformation(savedApp);
subscription.setAuthorization(savedAuth);
@@ -621,7 +476,6 @@ void shouldHandleSubscriptionWithoutOptionalRelationships() {
ApplicationInformationEntity savedApp = applicationInformationRepository.save(app);
SubscriptionEntity subscription = createValidSubscription();
- subscription.setDescription("Subscription with Minimal Relationships");
subscription.setRetailCustomer(savedCustomer);
subscription.setApplicationInformation(savedApp);
// Leave authorization and usagePoints as null/empty
@@ -651,7 +505,7 @@ void shouldValidateSubscriptionWithValidData() {
// Arrange
RetailCustomerEntity customer = TestDataBuilders.createValidRetailCustomer();
ApplicationInformationEntity app = createValidApplicationInformation();
-
+
SubscriptionEntity subscription = createValidSubscription();
subscription.setRetailCustomer(customer);
subscription.setApplicationInformation(app);
@@ -668,7 +522,7 @@ void shouldValidateSubscriptionWithValidData() {
void shouldValidateRequiredRetailCustomer() {
// Arrange
ApplicationInformationEntity app = createValidApplicationInformation();
-
+
SubscriptionEntity subscription = createValidSubscription();
subscription.setRetailCustomer(null); // Missing required field
subscription.setApplicationInformation(app);
@@ -688,7 +542,7 @@ void shouldValidateRequiredRetailCustomer() {
void shouldValidateRequiredApplicationInformation() {
// Arrange
RetailCustomerEntity customer = TestDataBuilders.createValidRetailCustomer();
-
+
SubscriptionEntity subscription = createValidSubscription();
subscription.setRetailCustomer(customer);
subscription.setApplicationInformation(null); // Missing required field
@@ -705,12 +559,12 @@ void shouldValidateRequiredApplicationInformation() {
}
@Nested
- @DisplayName("Base Class Functionality")
- class BaseClassTest {
+ @DisplayName("Entity Functionality")
+ class EntityFunctionalityTest {
@Test
- @DisplayName("Should inherit IdentifiedObject functionality")
- void shouldInheritIdentifiedObjectFunctionality() {
+ @DisplayName("Should persist with pre-set UUID")
+ void shouldPersistWithPreSetUuid() {
// Arrange
RetailCustomerEntity customer = TestDataBuilders.createValidRetailCustomer();
customer.setUsername("base" + faker.number().digits(7));
@@ -719,8 +573,8 @@ void shouldInheritIdentifiedObjectFunctionality() {
ApplicationInformationEntity app = createValidApplicationInformation();
ApplicationInformationEntity savedApp = applicationInformationRepository.save(app);
- SubscriptionEntity subscription = createValidSubscription();
- subscription.setDescription("Subscription for Base Class Test");
+ UUID presetId = UUID.randomUUID();
+ SubscriptionEntity subscription = new SubscriptionEntity(presetId);
subscription.setRetailCustomer(savedCustomer);
subscription.setApplicationInformation(savedApp);
@@ -729,8 +583,7 @@ void shouldInheritIdentifiedObjectFunctionality() {
// Assert
assertThat(saved.getId()).isNotNull();
- assertThat(saved.getCreated()).isNotNull();
- assertThat(saved.getUpdated()).isNotNull();
+ assertThat(saved.getId()).isEqualTo(presetId);
}
@Test
@@ -745,5 +598,84 @@ void shouldHandleEqualsAndHashCodeCorrectly() {
assertThat(subscription).isNotEqualTo(null); // Should not equal null
assertThat(subscription).isNotEqualTo("not a SubscriptionEntity"); // Should not equal different type
}
+
+ @Test
+ @DisplayName("Should check active status based on authorization")
+ void shouldCheckActiveStatusBasedOnAuthorization() {
+ // Arrange
+ SubscriptionEntity subscription = createValidSubscription();
+
+ // Without authorization - should not be active
+ assertThat(subscription.isActive()).isFalse();
+
+ // Add active authorization
+ AuthorizationEntity auth = new AuthorizationEntity();
+ auth.setStatus(AuthorizationEntity.STATUS_ACTIVE);
+ subscription.setAuthorization(auth);
+
+ // With active authorization - should be active
+ assertThat(subscription.isActive()).isTrue();
+ }
+
+ @Test
+ @DisplayName("Should track usage point count")
+ void shouldTrackUsagePointCount() {
+ // Arrange
+ SubscriptionEntity subscription = createValidSubscription();
+
+ // Initially empty
+ assertThat(subscription.getUsagePointCount()).isZero();
+
+ // Add usage points
+ UsagePointEntity up1 = TestDataBuilders.createValidUsagePoint();
+ UsagePointEntity up2 = TestDataBuilders.createValidUsagePoint();
+ subscription.getUsagePoints().addAll(List.of(up1, up2));
+
+ // Should reflect count
+ assertThat(subscription.getUsagePointCount()).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("Should check if includes usage point")
+ void shouldCheckIfIncludesUsagePoint() {
+ // Arrange
+ SubscriptionEntity subscription = createValidSubscription();
+ UsagePointEntity up1 = TestDataBuilders.createValidUsagePoint();
+ UsagePointEntity up2 = TestDataBuilders.createValidUsagePoint();
+
+ subscription.getUsagePoints().add(up1);
+
+ // Act & Assert
+ assertThat(subscription.includesUsagePoint(up1)).isTrue();
+ assertThat(subscription.includesUsagePoint(up2)).isFalse();
+ }
+
+ @Test
+ @DisplayName("Should check customer ownership")
+ void shouldCheckCustomerOwnership() {
+ // Arrange
+ RetailCustomerEntity customer = TestDataBuilders.createValidRetailCustomer();
+ customer.setId(123L);
+
+ SubscriptionEntity subscription = createValidSubscription();
+ subscription.setRetailCustomer(customer);
+
+ // Act & Assert
+ assertThat(subscription.belongsToCustomer(123L)).isTrue();
+ assertThat(subscription.belongsToCustomer(456L)).isFalse();
+ assertThat(subscription.belongsToCustomer(null)).isFalse();
+ }
+
+ @Test
+ @DisplayName("Should extract subscription ID from URI")
+ void shouldExtractSubscriptionIdFromUri() {
+ // Act & Assert
+ assertThat(SubscriptionEntity.getSubscriptionIdFromUri("/espi/1_1/resource/Subscription/12345"))
+ .isEqualTo("12345");
+ assertThat(SubscriptionEntity.getSubscriptionIdFromUri("/espi/1_1/resource/Subscription/uuid-value"))
+ .isEqualTo("uuid-value");
+ assertThat(SubscriptionEntity.getSubscriptionIdFromUri(null)).isNull();
+ assertThat(SubscriptionEntity.getSubscriptionIdFromUri("")).isNull();
+ }
}
-}
\ No newline at end of file
+}
diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/test/TestDataBuilders.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/test/TestDataBuilders.java
index c499ec23..a2e1c3df 100644
--- a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/test/TestDataBuilders.java
+++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/test/TestDataBuilders.java
@@ -31,6 +31,7 @@
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
+import java.util.UUID;
/**
* Minimal utility class for creating test data entities.
@@ -246,12 +247,12 @@ public static TimeConfigurationEntity createValidTimeConfiguration() {
/**
* Creates a valid SubscriptionEntity for testing.
+ * Note: Subscription is an application-specific entity (NOT an ESPI resource),
+ * so it does not extend IdentifiedObject and requires UUID to be set before persisting.
*/
public static SubscriptionEntity createValidSubscription() {
- SubscriptionEntity subscription = new SubscriptionEntity();
- subscription.setDescription(faker.lorem().sentence(3, 6));
+ SubscriptionEntity subscription = new SubscriptionEntity(UUID.randomUUID());
subscription.setHashedId("hashed-" + faker.internet().uuid());
- subscription.setLastUpdate(LocalDateTime.now());
return subscription;
}