diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 440c889..efad6cf 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -39,6 +39,9 @@ erDiagram CONSUMER ||--o{ PRODUCT_CONSUMER : consumes PRODUCT_CONSUMER ||--o{ PRODUCT_CONSUMER_ATTRIBUTE : has PRODUCT_TYPE ||--o{ PRODUCT : categorizes + ATTRIBUTE_DEFINITION ||--o{ ATTRIBUTE_DEFINITION_SCOPE : "bound via" + ATTRIBUTE_SCOPE ||--o{ ATTRIBUTE_DEFINITION_SCOPE : "bound via" + ATTRIBUTE_DEFINITION_SCOPE ||--o{ ATTRIBUTE_VALUE : has ORGANISATION { BIGSERIAL id PK @@ -116,6 +119,53 @@ erDiagram TIMESTAMP event_time VARCHAR performed_by } + ATTRIBUTE_SCOPE { + BIGSERIAL id PK + VARCHAR code + VARCHAR table_name + VARCHAR description + } + ATTRIBUTE_DEFINITION { + BIGSERIAL id PK + VARCHAR namespace + VARCHAR name + VARCHAR display_name + TEXT description + VARCHAR data_type + BOOLEAN multi_valued + JSONB allowed_values + VARCHAR validation_pattern + JSONB classification + BOOLEAN sensitive + BOOLEAN is_deleted + TIMESTAMP created_at + VARCHAR created_by + TIMESTAMP updated_at + VARCHAR updated_by + } + ATTRIBUTE_DEFINITION_SCOPE { + BIGSERIAL id PK + BIGINT attribute_definition_id FK + BIGINT attribute_scope_id FK + BOOLEAN required + JSONB default_value + BOOLEAN is_deleted + TIMESTAMP created_at + VARCHAR created_by + TIMESTAMP updated_at + VARCHAR updated_by + } + ATTRIBUTE_VALUE { + BIGSERIAL id PK + BIGINT attribute_definition_scope_id FK + BIGINT entity_id + JSONB value + BOOLEAN is_deleted + TIMESTAMP created_at + VARCHAR created_by + TIMESTAMP updated_at + VARCHAR updated_by + } ``` --- @@ -284,6 +334,104 @@ Usage: --- +### attribute_scope +Which core entity types may carry dynamic policy attributes, and the table `attribute_value.entity_id` resolves against for that scope. + +Columns: +- `id` BIGSERIAL, primary key +- `code` VARCHAR(50), not null — unique scope identifier (e.g. `PRODUCT`) +- `table_name` VARCHAR(150), not null — the table `attribute_value.entity_id` is a row id in, for this scope +- `description` VARCHAR(500), nullable + +Constraints: +- UNIQUE on `code` (`uq_attribute_scope__code`) + +Usage: +- Seeded by migration with one row per core entity type: `ORGANISATION` (`organisation`), `CONSUMER` (`consumer`), `PRODUCER` (`producer`), `PRODUCT` (`product`), `SUBSCRIPTION` (`product_consumer`). +- Referenced by `attribute_definition_scope` to say which scopes an attribute definition applies to. + +--- + +### attribute_definition +Vocabulary of policy attributes: name, type, and validation metadata, independent of which scope(s) it applies to. + +Columns: +- `id` BIGSERIAL, primary key +- `namespace` VARCHAR(150), not null +- `name` VARCHAR(150), not null +- `display_name` VARCHAR(255), nullable +- `description` TEXT, not null +- `data_type` VARCHAR(50), not null +- `multi_valued` BOOLEAN, not null, default FALSE +- `allowed_values` JSONB, nullable +- `validation_pattern` VARCHAR(500), nullable +- `classification` JSONB, nullable +- `sensitive` BOOLEAN, not null, default FALSE +- `is_deleted` BOOLEAN, not null, default FALSE +- `created_at` TIMESTAMP, not null, default `now()` +- `created_by` VARCHAR(255), not null +- `updated_at` TIMESTAMP, nullable +- `updated_by` VARCHAR(255), nullable + +Constraints: +- UNIQUE on (`namespace`, `name`) (`uq_attribute_definition__namespace_name`) + +Usage: +- Defines the shape of a policy attribute (e.g. data type, whether it can hold multiple values, allowed values, sensitivity) independently of where it can be attached. + +--- + +### attribute_definition_scope +Which scopes an `attribute_definition` is valid on, whether required there, and its default value. + +Columns: +- `id` BIGSERIAL, primary key +- `attribute_definition_id` BIGINT, not null, foreign key → `attribute_definition(id)` +- `attribute_scope_id` BIGINT, not null, foreign key → `attribute_scope(id)` +- `required` BOOLEAN, not null, default FALSE +- `default_value` JSONB, nullable +- `is_deleted` BOOLEAN, not null, default FALSE +- `created_at` TIMESTAMP, not null, default `now()` +- `created_by` VARCHAR(255), not null +- `updated_at` TIMESTAMP, nullable +- `updated_by` VARCHAR(255), nullable + +Constraints: +- UNIQUE on (`attribute_definition_id`, `attribute_scope_id`) (`uq_attribute_definition_scope__definition_scope`) +- Index on `attribute_definition_id` (`idx_attribute_definition_scope__attribute_definition_id`) +- Index on `attribute_scope_id` (`idx_attribute_definition_scope__attribute_scope_id`) + +Usage: +- Binds a definition to one or more scopes, controlling per-scope requiredness and default. + +--- + +### attribute_value +Actual policy attribute values recorded against a specific entity. + +Columns: +- `id` BIGSERIAL, primary key +- `attribute_definition_scope_id` BIGINT, not null, foreign key → `attribute_definition_scope(id)` +- `entity_id` BIGINT, not null — polymorphic reference: the primary key of the row in the table named by the value's `attribute_scope.table_name`. Not a declared foreign key, since the target table varies by scope. +- `value` JSONB, not null +- `is_deleted` BOOLEAN, not null, default FALSE +- `created_at` TIMESTAMP, not null, default `now()` +- `created_by` VARCHAR(255), not null +- `updated_at` TIMESTAMP, nullable +- `updated_by` VARCHAR(255), nullable + +Constraints: +- Index on `entity_id` (`idx_attribute_value__entity_id`) +- Partial UNIQUE index on (`attribute_definition_scope_id`, `entity_id`, `value`) WHERE `is_deleted = FALSE` (`uq_attr_value_live`) — an idempotency guard against persisting an exact-duplicate live value; it does not by itself enforce "one live value per entity" for single-valued attributes (that check spans `attribute_definition.multi_valued` and is left to the service layer that writes these rows) + +Soft-delete triggers: +- `trg_organisation_attribute_value_soft_delete`, `trg_consumer_attribute_value_soft_delete`, `trg_producer_attribute_value_soft_delete`, `trg_product_attribute_value_soft_delete`, `trg_product_consumer_attribute_value_soft_delete` — one `AFTER DELETE` trigger per owning table (`organisation`, `consumer`, `producer`, `product`, `product_consumer`), all calling the shared function `fn_attribute_value_soft_delete_on_entity_delete()`. When a row in one of those tables is deleted, every live (`is_deleted = FALSE`) `attribute_value` row scoped to that table and entity id is set `is_deleted = TRUE` rather than deleted or left orphaned. + +Usage: +- Stores the actual attribute values used to build the OPA data bundle for policy decisions, keyed by which entity (organisation, consumer, producer, product, or subscription) they describe. + +--- + ## Migration Notes - Schema is versioned and applied with Flyway on application startup. - Foreign keys enforce referential integrity among core entities. diff --git a/pom.xml b/pom.xml index e28a186..9560a2c 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,7 @@ 1.84 6.5.9 5.4.3 + 10.1.59 **/config/**, **/dto/**, **/entity/**, @@ -197,6 +198,16 @@ commons-lang3 3.18.0 + + org.testcontainers + postgresql + test + + + org.testcontainers + junit-jupiter + test + diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeAuditFields.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeAuditFields.java new file mode 100644 index 0000000..29c6936 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeAuditFields.java @@ -0,0 +1,45 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.sql.Timestamp; +import lombok.Getter; +import lombok.Setter; + +/** + * Shared soft-delete and audit columns for the policy attribute schema entities + * ({@link AttributeDefinition}, {@link AttributeDefinitionScope}, {@link AttributeValue}). + */ +@Getter +@Setter +@MappedSuperclass +public abstract class AttributeAuditFields { + + @NotNull + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = false; + + @NotNull + @Column(name = "created_at", nullable = false) + private Timestamp createdAt; + + @Size(max = 255) + @NotNull + @Column(name = "created_by", nullable = false, length = 255) + private String createdBy; + + @Column(name = "updated_at") + private Timestamp updatedAt; + + @Size(max = 255) + @Column(name = "updated_by", length = 255) + private String updatedBy; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeDefinition.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeDefinition.java new file mode 100644 index 0000000..6338baa --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeDefinition.java @@ -0,0 +1,69 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Getter +@Setter +@Entity +@Table(name = "attribute_definition") +public class AttributeDefinition extends AttributeAuditFields { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Size(max = 150) + @NotNull + @Column(name = "namespace", nullable = false, length = 150) + private String namespace; + + @Size(max = 150) + @NotNull + @Column(name = "name", nullable = false, length = 150) + private String name; + + @Size(max = 255) + @Column(name = "display_name", length = 255) + private String displayName; + + @NotNull + @Column(name = "description", nullable = false) + private String description; + + @Size(max = 50) + @NotNull + @Column(name = "data_type", nullable = false, length = 50) + private String dataType; + + @NotNull + @Column(name = "multi_valued", nullable = false) + private Boolean multiValued = false; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "allowed_values") + private String allowedValues; + + @Size(max = 500) + @Column(name = "validation_pattern", length = 500) + private String validationPattern; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "classification") + private String classification; + + @NotNull + @Column(name = "sensitive", nullable = false) + private Boolean sensitive = false; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeDefinitionScope.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeDefinitionScope.java new file mode 100644 index 0000000..364ab43 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeDefinitionScope.java @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Getter +@Setter +@Entity +@Table(name = "attribute_definition_scope") +public class AttributeDefinitionScope extends AttributeAuditFields { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "attribute_definition_id", nullable = false) + private AttributeDefinition attributeDefinition; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "attribute_scope_id", nullable = false) + private AttributeScope attributeScope; + + @NotNull + @Column(name = "required", nullable = false) + private Boolean required = false; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "default_value") + private String defaultValue; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeScope.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeScope.java new file mode 100644 index 0000000..eb8f553 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeScope.java @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "attribute_scope") +public class AttributeScope { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Size(max = 50) + @NotNull + @Column(name = "code", nullable = false, length = 50) + private String code; + + @Size(max = 150) + @NotNull + @Column(name = "table_name", nullable = false, length = 150) + private String tableName; + + @Size(max = 500) + @Column(name = "description", length = 500) + private String description; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeValue.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeValue.java new file mode 100644 index 0000000..64ccd63 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/AttributeValue.java @@ -0,0 +1,45 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Getter +@Setter +@Entity +@Table(name = "attribute_value") +public class AttributeValue extends AttributeAuditFields { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "attribute_definition_scope_id", nullable = false) + private AttributeDefinitionScope attributeDefinitionScope; + + /** + * Polymorphic reference: the primary key of the row in the table named by + * {@code attributeDefinitionScope.attributeScope.tableName}. Not a JPA relationship + * because the target entity type varies by scope; see the migration's soft-delete + * trigger for how this is enforced at the database level. + */ + @NotNull + @Column(name = "entity_id", nullable = false) + private Long entityId; + + @NotNull + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "value", nullable = false) + private String value; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionRepository.java new file mode 100644 index 0000000..a740c6b --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionRepository.java @@ -0,0 +1,26 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition; + +/** + * Repository interface for managing {@link AttributeDefinition} entities. + * Provides persistence operations and query methods for interacting with the + * underlying database. + * + * Extends {@link JpaRepository} to inherit standard CRUD operations and adds + * query methods specific to {@link AttributeDefinition}. + */ +@Repository +public interface AttributeDefinitionRepository extends JpaRepository { + + Optional findByNamespaceAndName(String namespace, String name); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionScopeRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionScopeRepository.java new file mode 100644 index 0000000..27104c5 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionScopeRepository.java @@ -0,0 +1,26 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinitionScope; + +/** + * Repository interface for managing {@link AttributeDefinitionScope} entities. + * Provides persistence operations and query methods for interacting with the + * underlying database. + * + * Extends {@link JpaRepository} to inherit standard CRUD operations and adds + * query methods specific to {@link AttributeDefinitionScope}. + */ +@Repository +public interface AttributeDefinitionScopeRepository extends JpaRepository { + + List findByAttributeDefinitionId(Long attributeDefinitionId); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeScopeRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeScopeRepository.java new file mode 100644 index 0000000..21ac1a9 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeScopeRepository.java @@ -0,0 +1,26 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope; + +/** + * Repository interface for managing {@link AttributeScope} entities. + * Provides persistence operations and query methods for interacting with the + * underlying database. + * + * Extends {@link JpaRepository} to inherit standard CRUD operations and adds + * query methods specific to {@link AttributeScope}. + */ +@Repository +public interface AttributeScopeRepository extends JpaRepository { + + Optional findByCode(String code); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueRepository.java new file mode 100644 index 0000000..d6365b9 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueRepository.java @@ -0,0 +1,27 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeValue; + +/** + * Repository interface for managing {@link AttributeValue} entities. + * Provides persistence operations and query methods for interacting with the + * underlying database. + * + * Extends {@link JpaRepository} to inherit standard CRUD operations and adds + * query methods specific to {@link AttributeValue}. + */ +@Repository +public interface AttributeValueRepository extends JpaRepository { + + List findByAttributeDefinitionScopeIdAndEntityIdAndIsDeletedFalse( + Long attributeDefinitionScopeId, Long entityId); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java index 68d790c..45cb378 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java @@ -76,7 +76,12 @@ private static boolean isValidGrantedTs(Timestamp grantedTs, BigDecimal validity @Override public ConsumerConfigDTO getConsumerConfigByClientId(String clientId, Optional consumerId) { - List consumers = getFilteredConsumers(clientId, consumerId); + List consumers = consumerService.findByIdpClientId(clientId); + if (consumerId.isPresent()) { + consumers = consumers.stream() + .filter(consumer -> consumer.getId().equals(consumerId.get())) + .toList(); + } List consumerIds = consumers.stream().map(ConsumerDTO::getId).toList(); List validProductConsumers = getValidProductConsumers(consumers); @@ -144,7 +149,14 @@ private List getValidProductConsumers(List cons @Override public ProducerConfigDTO getProducerConfigByClientId(String clientId, Optional producerId) { - List producers = getFilteredActiveProducers(clientId, producerId); + List producers = producerService.getProducersByClientId(clientId).stream() + .filter(ProducerDTO::getActive) + .toList(); + if (producerId.isPresent()) { + producers = producers.stream() + .filter(producer -> producerId.get().equals(producer.getId())) + .toList(); + } List dataProviderIds = collectDataProviderIds(producers); // Get allowed consumers (not directly used but might be needed for side effects) @@ -158,46 +170,6 @@ public ProducerConfigDTO getProducerConfigByClientId(String clientId, Optional getFilteredConsumers(String clientId, Optional consumerId) { - List consumers = consumerService.findByIdpClientId(clientId); - - if (consumerId.isPresent()) { - consumers = consumers.stream() - .filter(consumer -> consumer.getId().equals(consumerId.get())) - .toList(); - } - - return consumers; - } - - /** - * Filters active producers by client ID and optional producer ID. - * - * @param clientId the client ID - * @param producerId the optional producer ID - * @return a list of filtered active producers - */ - private List getFilteredActiveProducers(String clientId, Optional producerId) { - List producers = producerService.getProducersByClientId(clientId).stream() - .filter(ProducerDTO::getActive) - .toList(); - - if (producerId.isPresent()) { - producers = producers.stream() - .filter(producer -> producerId.get().equals(producer.getId())) - .toList(); - } - - return producers; - } - /** * Collects data provider IDs from a list of producers. * @@ -265,7 +237,7 @@ private void populateConsumersForProducers(List producers) { */ private boolean isValidProvider(ProductConsumerDTO provider) { - if (provider.getValidity() == null || provider.getValidity().equals(BigDecimal.ZERO)) return true; + if (provider.getValidity() == null || provider.getValidity().compareTo(BigDecimal.ZERO) == 0) return true; return isValidGrantedTs(provider.getGrantedTs(), provider.getValidity()); } diff --git a/src/main/resources/db/migration/V20260902120000__add_attribute_schema_tables.sql b/src/main/resources/db/migration/V20260902120000__add_attribute_schema_tables.sql new file mode 100644 index 0000000..2034188 --- /dev/null +++ b/src/main/resources/db/migration/V20260902120000__add_attribute_schema_tables.sql @@ -0,0 +1,124 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +-- Which core entity types may carry dynamic attributes, and the table entity_id resolves against. +CREATE TABLE attribute_scope ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL, + table_name VARCHAR(150) NOT NULL, + description VARCHAR(500), + CONSTRAINT uq_attribute_scope__code UNIQUE (code) +); + +-- Attribute vocabulary: name/type/validation metadata, independent of which scope(s) it applies to. +CREATE TABLE attribute_definition ( + id BIGSERIAL PRIMARY KEY, + namespace VARCHAR(150) NOT NULL, + name VARCHAR(150) NOT NULL, + display_name VARCHAR(255), + description TEXT NOT NULL, + data_type VARCHAR(50) NOT NULL, + multi_valued BOOLEAN NOT NULL DEFAULT FALSE, + allowed_values JSONB, + validation_pattern VARCHAR(500), + classification JSONB, + sensitive BOOLEAN NOT NULL DEFAULT FALSE, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + created_by VARCHAR(255) NOT NULL, + updated_at TIMESTAMP, + updated_by VARCHAR(255), + CONSTRAINT uq_attribute_definition__namespace_name UNIQUE (namespace, name) +); + +-- Which scopes a definition is valid on, whether required there, and its default. +CREATE TABLE attribute_definition_scope ( + id BIGSERIAL PRIMARY KEY, + attribute_definition_id BIGINT NOT NULL, + attribute_scope_id BIGINT NOT NULL, + required BOOLEAN NOT NULL DEFAULT FALSE, + default_value JSONB, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + created_by VARCHAR(255) NOT NULL, + updated_at TIMESTAMP, + updated_by VARCHAR(255), + CONSTRAINT fk_attribute_definition_scope__attribute_definition_id + FOREIGN KEY (attribute_definition_id) REFERENCES attribute_definition (id), + CONSTRAINT fk_attribute_definition_scope__attribute_scope_id + FOREIGN KEY (attribute_scope_id) REFERENCES attribute_scope (id), + CONSTRAINT uq_attribute_definition_scope__definition_scope + UNIQUE (attribute_definition_id, attribute_scope_id) +); +CREATE INDEX idx_attribute_definition_scope__attribute_definition_id + ON attribute_definition_scope (attribute_definition_id); +CREATE INDEX idx_attribute_definition_scope__attribute_scope_id + ON attribute_definition_scope (attribute_scope_id); + +-- Actual values. entity_id is polymorphic: PK of the row in attribute_scope.table_name for that pairing's +-- scope, not a declared FK — enforced by the soft-delete trigger below, not by the database schema. +CREATE TABLE attribute_value ( + id BIGSERIAL PRIMARY KEY, + attribute_definition_scope_id BIGINT NOT NULL, + entity_id BIGINT NOT NULL, + value JSONB NOT NULL, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + created_by VARCHAR(255) NOT NULL, + updated_at TIMESTAMP, + updated_by VARCHAR(255), + CONSTRAINT fk_attribute_value__attribute_definition_scope_id + FOREIGN KEY (attribute_definition_scope_id) REFERENCES attribute_definition_scope (id) +); +CREATE INDEX idx_attribute_value__entity_id ON attribute_value (entity_id); +-- Backs the PEP's EXISTS sub-queries per constraint (attribute_definition_scope_id, entity_id, value). +CREATE UNIQUE INDEX uq_attr_value_live + ON attribute_value (attribute_definition_scope_id, entity_id, value) + WHERE is_deleted = FALSE; + +INSERT INTO attribute_scope (code, table_name, description) VALUES + ('ORGANISATION', 'organisation', 'Attributes carried by an organisation'), + ('CONSUMER', 'consumer', 'Attributes carried by a consumer'), + ('PRODUCER', 'producer', 'Attributes carried by a producer'), + ('PRODUCT', 'product', 'Attributes carried by a product'), + ('SUBSCRIPTION', 'product_consumer', 'Attributes carried by a product/consumer subscription'); + +-- Soft-delete any live attribute_value rows for the entity being removed, scoped to the deleted table. +CREATE FUNCTION fn_attribute_value_soft_delete_on_entity_delete() RETURNS TRIGGER AS $$ +BEGIN + UPDATE attribute_value av + SET is_deleted = TRUE, + updated_at = now(), + updated_by = 'trigger:' || TG_TABLE_NAME + FROM attribute_definition_scope ads + JOIN attribute_scope asc_ ON asc_.id = ads.attribute_scope_id + WHERE av.attribute_definition_scope_id = ads.id + AND asc_.table_name = TG_TABLE_NAME + AND av.entity_id = OLD.id + AND av.is_deleted = FALSE; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_organisation_attribute_value_soft_delete + AFTER DELETE ON organisation + FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete(); + +CREATE TRIGGER trg_consumer_attribute_value_soft_delete + AFTER DELETE ON consumer + FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete(); + +CREATE TRIGGER trg_producer_attribute_value_soft_delete + AFTER DELETE ON producer + FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete(); + +CREATE TRIGGER trg_product_attribute_value_soft_delete + AFTER DELETE ON product + FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete(); + +CREATE TRIGGER trg_product_consumer_attribute_value_soft_delete + AFTER DELETE ON product_consumer + FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete(); diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AbstractPostgresRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AbstractPostgresRepositoryTest.java new file mode 100644 index 0000000..df49d13 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AbstractPostgresRepositoryTest.java @@ -0,0 +1,53 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Base class for repository tests that need the real Flyway migrations and real + * Postgres behaviour (partial unique indexes, {@code plpgsql} triggers) that the + * project's shared H2 test profile ({@code src/test/resources/application.yml}) cannot + * provide. Points the Spring context at a shared Postgres container and re-enables + * Flyway (disabled in the shared profile) so migrations apply for real. + * + *

The container is started eagerly in a static initializer rather than left to the + * {@code @Testcontainers}/{@code @Container} JUnit extension. With multiple concrete + * subclasses - each getting its own Spring context - relying on the extension's + * per-class {@code beforeAll} to start (or no-op past) the container raced against + * context refresh on CI and on some local Docker setups: the first class or two would + * see the container "started" but not yet accepting TCP connections, and every test in + * that class would time out. Starting synchronously here, before any JUnit lifecycle + * callback runs for any subclass, removes that race. Testcontainers' Ryuk reaper still + * cleans the container up at JVM exit; it is not tied to the JUnit5 extension. + */ +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public abstract class AbstractPostgresRepositoryTest { + + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine")); + + static { + POSTGRES.start(); + } + + @DynamicPropertySource + static void datasourceProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", POSTGRES::getDriverClassName); + registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.flyway.enabled", () -> "true"); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionRepositoryTest.java new file mode 100644 index 0000000..1ebdfb4 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionRepositoryTest.java @@ -0,0 +1,64 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition; + +class AttributeDefinitionRepositoryTest extends AbstractPostgresRepositoryTest { + + @Autowired + private AttributeDefinitionRepository attributeDefinitionRepository; + + private static AttributeDefinition newDefinition(String namespace, String name) { + AttributeDefinition definition = new AttributeDefinition(); + definition.setNamespace(namespace); + definition.setName(name); + definition.setDescription("Test attribute definition"); + definition.setDataType("STRING"); + definition.setCreatedAt(Timestamp.from(Instant.now())); + definition.setCreatedBy("test"); + return definition; + } + + @Test + void findByNamespaceAndName_returnsPersistedDefinition() { + attributeDefinitionRepository.saveAndFlush(newDefinition("policy", "risk-tier")); + + Optional found = + attributeDefinitionRepository.findByNamespaceAndName("policy", "risk-tier"); + + assertThat(found).isPresent(); + assertThat(found.get().getDataType()).isEqualTo("STRING"); + assertThat(found.get().getMultiValued()).isFalse(); + assertThat(found.get().getSensitive()).isFalse(); + } + + @Test + void findByNamespaceAndName_returnsEmptyForUnknownPair() { + Optional found = attributeDefinitionRepository.findByNamespaceAndName("nope", "nope"); + + assertThat(found).isEmpty(); + } + + @Test + void save_rejectsDuplicateNamespaceAndName() { + attributeDefinitionRepository.saveAndFlush(newDefinition("policy", "duplicate-check")); + AttributeDefinition duplicate = newDefinition("policy", "duplicate-check"); + + assertThrows( + DataIntegrityViolationException.class, () -> attributeDefinitionRepository.saveAndFlush(duplicate)); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionScopeRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionScopeRepositoryTest.java new file mode 100644 index 0000000..605dcb4 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionScopeRepositoryTest.java @@ -0,0 +1,88 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinitionScope; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope; + +class AttributeDefinitionScopeRepositoryTest extends AbstractPostgresRepositoryTest { + + @Autowired + private AttributeDefinitionRepository attributeDefinitionRepository; + + @Autowired + private AttributeScopeRepository attributeScopeRepository; + + @Autowired + private AttributeDefinitionScopeRepository attributeDefinitionScopeRepository; + + private AttributeDefinition persistDefinition(String name) { + AttributeDefinition definition = new AttributeDefinition(); + definition.setNamespace("policy"); + definition.setName(name); + definition.setDescription("Test attribute definition"); + definition.setDataType("STRING"); + definition.setCreatedAt(Timestamp.from(Instant.now())); + definition.setCreatedBy("test"); + return attributeDefinitionRepository.saveAndFlush(definition); + } + + private static AttributeDefinitionScope newBinding( + AttributeDefinition definition, AttributeScope scope, boolean required) { + AttributeDefinitionScope binding = new AttributeDefinitionScope(); + binding.setAttributeDefinition(definition); + binding.setAttributeScope(scope); + binding.setRequired(required); + binding.setCreatedAt(Timestamp.from(Instant.now())); + binding.setCreatedBy("test"); + return binding; + } + + @Test + void findByAttributeDefinitionId_returnsAllBoundScopes() { + AttributeDefinition definition = persistDefinition("multi-scope-attr"); + AttributeScope productScope = + attributeScopeRepository.findByCode("PRODUCT").orElseThrow(); + AttributeScope consumerScope = + attributeScopeRepository.findByCode("CONSUMER").orElseThrow(); + + attributeDefinitionScopeRepository.saveAndFlush(newBinding(definition, productScope, true)); + attributeDefinitionScopeRepository.saveAndFlush(newBinding(definition, consumerScope, false)); + + List bindings = + attributeDefinitionScopeRepository.findByAttributeDefinitionId(definition.getId()); + + assertThat(bindings).hasSize(2); + assertThat(bindings) + .extracting(b -> b.getAttributeScope().getId()) + .containsExactlyInAnyOrder(productScope.getId(), consumerScope.getId()); + } + + @Test + void save_rejectsDuplicateDefinitionScopePair() { + AttributeDefinition definition = persistDefinition("duplicate-binding-attr"); + AttributeScope productScope = + attributeScopeRepository.findByCode("PRODUCT").orElseThrow(); + attributeDefinitionScopeRepository.saveAndFlush(newBinding(definition, productScope, false)); + + AttributeDefinitionScope duplicate = newBinding(definition, productScope, true); + + assertThrows( + DataIntegrityViolationException.class, + () -> attributeDefinitionScopeRepository.saveAndFlush(duplicate)); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeScopeRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeScopeRepositoryTest.java new file mode 100644 index 0000000..a76c944 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeScopeRepositoryTest.java @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope; + +class AttributeScopeRepositoryTest extends AbstractPostgresRepositoryTest { + + @Autowired + private AttributeScopeRepository attributeScopeRepository; + + @Test + void findByCode_returnsSeededScope() { + Optional found = attributeScopeRepository.findByCode("PRODUCT"); + + assertThat(found).isPresent(); + assertThat(found.get().getTableName()).isEqualTo("product"); + } + + @Test + void findByCode_returnsEmptyForUnknownCode() { + Optional found = attributeScopeRepository.findByCode("DOES_NOT_EXIST"); + + assertThat(found).isEmpty(); + } + + @Test + void save_rejectsDuplicateCode() { + AttributeScope duplicate = new AttributeScope(); + duplicate.setCode("PRODUCT"); + duplicate.setTableName("product"); + duplicate.setDescription("Duplicate of the seeded PRODUCT scope"); + + assertThrows(DataIntegrityViolationException.class, () -> attributeScopeRepository.saveAndFlush(duplicate)); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueRepositoryTest.java new file mode 100644 index 0000000..2a24d25 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueRepositoryTest.java @@ -0,0 +1,141 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinitionScope; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeValue; + +class AttributeValueRepositoryTest extends AbstractPostgresRepositoryTest { + + @Autowired + private AttributeDefinitionRepository attributeDefinitionRepository; + + @Autowired + private AttributeScopeRepository attributeScopeRepository; + + @Autowired + private AttributeDefinitionScopeRepository attributeDefinitionScopeRepository; + + @Autowired + private AttributeValueRepository attributeValueRepository; + + private AttributeDefinitionScope persistProductScopedBinding(String attributeName) { + AttributeDefinition definition = new AttributeDefinition(); + definition.setNamespace("policy"); + definition.setName(attributeName); + definition.setDescription("Test attribute definition"); + definition.setDataType("STRING"); + definition.setCreatedAt(Timestamp.from(Instant.now())); + definition.setCreatedBy("test"); + definition = attributeDefinitionRepository.saveAndFlush(definition); + + AttributeScope productScope = + attributeScopeRepository.findByCode("PRODUCT").orElseThrow(); + + AttributeDefinitionScope binding = new AttributeDefinitionScope(); + binding.setAttributeDefinition(definition); + binding.setAttributeScope(productScope); + binding.setRequired(false); + binding.setCreatedAt(Timestamp.from(Instant.now())); + binding.setCreatedBy("test"); + return attributeDefinitionScopeRepository.saveAndFlush(binding); + } + + private static AttributeValue newValue(AttributeDefinitionScope binding, Long entityId, String json) { + AttributeValue value = new AttributeValue(); + value.setAttributeDefinitionScope(binding); + value.setEntityId(entityId); + value.setValue(json); + value.setCreatedAt(Timestamp.from(Instant.now())); + value.setCreatedBy("test"); + return value; + } + + @Test + void findLiveValue_returnsNonDeletedValue() { + AttributeDefinitionScope binding = persistProductScopedBinding("live-value-attr"); + attributeValueRepository.saveAndFlush(newValue(binding, 1001L, "\"gold\"")); + + List live = + attributeValueRepository.findByAttributeDefinitionScopeIdAndEntityIdAndIsDeletedFalse( + binding.getId(), 1001L); + + assertThat(live).hasSize(1); + assertThat(live.get(0).getValue()).isEqualTo("\"gold\""); + } + + @Test + void findLiveValue_excludesSoftDeletedValue() { + AttributeDefinitionScope binding = persistProductScopedBinding("soft-deleted-attr"); + AttributeValue value = newValue(binding, 1002L, "\"silver\""); + value.setIsDeleted(true); + attributeValueRepository.saveAndFlush(value); + + List live = + attributeValueRepository.findByAttributeDefinitionScopeIdAndEntityIdAndIsDeletedFalse( + binding.getId(), 1002L); + + assertThat(live).isEmpty(); + } + + @Test + void save_rejectsExactDuplicateLiveValue() { + AttributeDefinitionScope binding = persistProductScopedBinding("duplicate-value-attr"); + attributeValueRepository.saveAndFlush(newValue(binding, 1003L, "\"gold\"")); + + AttributeValue duplicate = newValue(binding, 1003L, "\"gold\""); + + assertThrows(DataIntegrityViolationException.class, () -> attributeValueRepository.saveAndFlush(duplicate)); + } + + @Test + void save_acceptsDistinctValueForSameBindingAndEntity() { + // uq_attr_value_live keys on (scope, entity, value) - it is an idempotency guard against exact + // duplicates, not a single-valuedness constraint, so a different value is allowed. See design.md. + AttributeDefinitionScope binding = persistProductScopedBinding("multi-valued-attr"); + attributeValueRepository.saveAndFlush(newValue(binding, 1004L, "\"gold\"")); + + attributeValueRepository.saveAndFlush(newValue(binding, 1004L, "\"silver\"")); + + List live = + attributeValueRepository.findByAttributeDefinitionScopeIdAndEntityIdAndIsDeletedFalse( + binding.getId(), 1004L); + assertThat(live) + .hasSize(2) + .extracting(AttributeValue::getValue) + .containsExactlyInAnyOrder("\"gold\"", "\"silver\""); + } + + @Test + void save_allowsSameValueAgainAfterPriorDuplicateIsSoftDeleted() { + AttributeDefinitionScope binding = persistProductScopedBinding("resurrected-attr"); + AttributeValue first = newValue(binding, 1005L, "\"gold\""); + first = attributeValueRepository.saveAndFlush(first); + first.setIsDeleted(true); + attributeValueRepository.saveAndFlush(first); + + AttributeValue resurrected = newValue(binding, 1005L, "\"gold\""); + attributeValueRepository.saveAndFlush(resurrected); + + List live = + attributeValueRepository.findByAttributeDefinitionScopeIdAndEntityIdAndIsDeletedFalse( + binding.getId(), 1005L); + assertThat(live).hasSize(1); + assertThat(live.get(0).getValue()).isEqualTo("\"gold\""); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueSoftDeleteTriggerTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueSoftDeleteTriggerTest.java new file mode 100644 index 0000000..c8ffe58 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueSoftDeleteTriggerTest.java @@ -0,0 +1,235 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinitionScope; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeValue; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Consumer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Organisation; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Producer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumer; + +/** + * Verifies the migration's five {@code AFTER DELETE} triggers, which soft-delete + * {@code attribute_value} rows scoped to the deleted owning entity rather than + * leaving them orphaned. + */ +class AttributeValueSoftDeleteTriggerTest extends AbstractPostgresRepositoryTest { + + @Autowired + private OrganisationRepository organisationRepository; + + @Autowired + private ProducerRepository producerRepository; + + @Autowired + private ConsumerRepository consumerRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private ProductConsumerRepository productConsumerRepository; + + @Autowired + private AttributeScopeRepository attributeScopeRepository; + + @Autowired + private AttributeDefinitionRepository attributeDefinitionRepository; + + @Autowired + private AttributeDefinitionScopeRepository attributeDefinitionScopeRepository; + + @Autowired + private AttributeValueRepository attributeValueRepository; + + @Autowired + private TestEntityManager testEntityManager; + + private AttributeDefinitionScope bindingFor(String scopeCode, String attributeName) { + AttributeDefinition definition = new AttributeDefinition(); + definition.setNamespace("policy"); + definition.setName(attributeName); + definition.setDescription("Trigger test attribute definition"); + definition.setDataType("STRING"); + definition.setCreatedAt(Timestamp.from(Instant.now())); + definition.setCreatedBy("test"); + definition = attributeDefinitionRepository.saveAndFlush(definition); + + AttributeScope scope = attributeScopeRepository.findByCode(scopeCode).orElseThrow(); + + AttributeDefinitionScope binding = new AttributeDefinitionScope(); + binding.setAttributeDefinition(definition); + binding.setAttributeScope(scope); + binding.setRequired(false); + binding.setCreatedAt(Timestamp.from(Instant.now())); + binding.setCreatedBy("test"); + return attributeDefinitionScopeRepository.saveAndFlush(binding); + } + + private Long persistLiveValue(AttributeDefinitionScope binding, Long entityId) { + AttributeValue value = new AttributeValue(); + value.setAttributeDefinitionScope(binding); + value.setEntityId(entityId); + value.setValue("\"trigger-test-value\""); + value.setCreatedAt(Timestamp.from(Instant.now())); + value.setCreatedBy("test"); + return attributeValueRepository.saveAndFlush(value).getId(); + } + + private Organisation persistOrganisation() { + Organisation organisation = new Organisation(); + organisation.setName("Trigger Test Org"); + return organisationRepository.saveAndFlush(organisation); + } + + private Consumer persistConsumer(Organisation organisation) { + Consumer consumer = new Consumer(); + consumer.setName("Trigger Test Consumer"); + consumer.setOrg(organisation); + consumer.setIdpClientId("trigger-test-consumer"); + consumer.setScheduleType("cron"); + return consumerRepository.saveAndFlush(consumer); + } + + private Producer persistProducer(Organisation organisation) { + Producer producer = new Producer(); + producer.setName("Trigger Test Producer"); + producer.setDescription("Trigger test producer"); + producer.setOrg(organisation); + producer.setActive(true); + producer.setHost("localhost"); + producer.setPort(BigDecimal.valueOf(8080)); + producer.setTls(true); + producer.setIdpClientId("trigger-test-producer"); + return producerRepository.saveAndFlush(producer); + } + + private Product persistProduct(Producer producer) { + Product product = new Product(); + product.setName("Trigger Test Product"); + product.setTopic("topic.trigger-test"); + product.setProducer(producer); + return productRepository.saveAndFlush(product); + } + + private ProductConsumer persistProductConsumer(Product product, Consumer consumer) { + ProductConsumer productConsumer = new ProductConsumer(); + productConsumer.setProduct(product); + productConsumer.setConsumer(consumer); + productConsumer.setGrantedTs(Timestamp.from(Instant.now())); + productConsumer.setValidity(BigDecimal.valueOf(30)); + productConsumer.setScheduleType("cron"); + return productConsumerRepository.saveAndFlush(productConsumer); + } + + @Test + void deletingOrganisation_softDeletesItsAttributeValues() { + Organisation organisation = persistOrganisation(); + AttributeDefinitionScope binding = bindingFor("ORGANISATION", "org-trigger-attr"); + Long valueId = persistLiveValue(binding, organisation.getId()); + + organisationRepository.delete(organisation); + organisationRepository.flush(); + testEntityManager.clear(); + + assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat( + v.getIsDeleted()) + .isTrue()); + } + + @Test + void deletingConsumer_softDeletesItsAttributeValues() { + Organisation organisation = persistOrganisation(); + Consumer consumer = persistConsumer(organisation); + AttributeDefinitionScope binding = bindingFor("CONSUMER", "consumer-trigger-attr"); + Long valueId = persistLiveValue(binding, consumer.getId()); + + consumerRepository.delete(consumer); + consumerRepository.flush(); + testEntityManager.clear(); + + assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat( + v.getIsDeleted()) + .isTrue()); + } + + @Test + void deletingProducer_softDeletesItsAttributeValues() { + Organisation organisation = persistOrganisation(); + Producer producer = persistProducer(organisation); + AttributeDefinitionScope binding = bindingFor("PRODUCER", "producer-trigger-attr"); + Long valueId = persistLiveValue(binding, producer.getId()); + + producerRepository.delete(producer); + producerRepository.flush(); + testEntityManager.clear(); + + assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat( + v.getIsDeleted()) + .isTrue()); + } + + @Test + void deletingProduct_softDeletesItsAttributeValues() { + Organisation organisation = persistOrganisation(); + Producer producer = persistProducer(organisation); + Product product = persistProduct(producer); + AttributeDefinitionScope binding = bindingFor("PRODUCT", "product-trigger-attr"); + Long valueId = persistLiveValue(binding, product.getId()); + + productRepository.delete(product); + productRepository.flush(); + testEntityManager.clear(); + + assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat( + v.getIsDeleted()) + .isTrue()); + } + + @Test + void deletingProductConsumer_softDeletesItsAttributeValues() { + Organisation organisation = persistOrganisation(); + Producer producer = persistProducer(organisation); + Product product = persistProduct(producer); + Consumer consumer = persistConsumer(organisation); + ProductConsumer productConsumer = persistProductConsumer(product, consumer); + AttributeDefinitionScope binding = bindingFor("SUBSCRIPTION", "subscription-trigger-attr"); + Long valueId = persistLiveValue(binding, productConsumer.getId()); + + productConsumerRepository.delete(productConsumer); + productConsumerRepository.flush(); + testEntityManager.clear(); + + assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat( + v.getIsDeleted()) + .isTrue()); + } + + @Test + void deletingEntityWithNoAttributeValues_succeedsAndLeavesAttributeValueTableUntouched() { + Organisation organisation = persistOrganisation(); + long countBefore = attributeValueRepository.count(); + + organisationRepository.delete(organisation); + organisationRepository.flush(); + + assertThat(organisationRepository.existsById(organisation.getId())).isFalse(); + assertThat(attributeValueRepository.count()).isEqualTo(countBefore); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java index 27a429e..aead7da 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java @@ -159,8 +159,7 @@ void getConsumerConfigByClientId_whenNoValidProducts_clearsAllProducerProducts() void getConsumerConfigByClientId_withConsumerIdFilter_appliesFilter_andRemovesNullProductIds() { String clientId = "clientC"; ConsumerDTO c1 = consumer(3L, clientId, "c3", "CRON", "@daily"); - ConsumerDTO cOther = consumer(99L, clientId, "other", "CRON", "@minutely"); - when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1, cOther)); + when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1)); ProductConsumerDTO pc = productConsumer(300L, 3L, null, null); when(productConsumerService.findByConsumerId(3L)).thenReturn(List.of(pc)); @@ -272,8 +271,8 @@ void getProducerConfigByClientId_withValidValidity_includesConsumer() { void getConsumerConfigByClientId_withConsumerId_filtersByConsumerId() { String clientId = "clientA"; ConsumerDTO c1 = consumer(1L, clientId, "c1", "CRON", "@hourly"); - ConsumerDTO c2 = consumer(2L, clientId, "c2", "CRON", "@daily"); - when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1, c2)); + + when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1)); ConsumerConfigDTO cfg = configurationProvider.getConsumerConfigByClientId(clientId, Optional.of(1L)); @@ -284,11 +283,9 @@ void getConsumerConfigByClientId_withConsumerId_filtersByConsumerId() { void getProducerConfigByClientId_withProducerId_filtersByProducerId() { String clientId = "producerClient"; ProductDTO p1 = product(100L, "p1"); - ProductDTO p2 = product(101L, "p2"); ProducerDTO pr1 = producer(1L, true, p1); - ProducerDTO pr2 = producer(2L, true, p2); - when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(pr1, pr2)); + when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(pr1)); ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.of(1L));