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:

+ * */ @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:

+ * + * + *

Entries included (in order):

+ *
    + *
  1. UsagePoint entries
  2. + *
  3. MeterReading entries
  4. + *
  5. ReadingType entries
  6. + *
  7. IntervalBlock entries
  8. + *
  9. TimeConfiguration entry (LocalTimeParameters)
  10. + *
  11. UsageSummary entries (if data exists)
  12. + *
  13. ElectricPowerQualitySummary entries (if data exists)
  14. + *
+ */ +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; }