diff --git a/BUILD.md b/BUILD.md index 6eeaaf1d..a93ad4c2 100644 --- a/BUILD.md +++ b/BUILD.md @@ -174,14 +174,16 @@ The goal is to provide infrastructure configurations which... Each habitcentric service runs with the same ports across all deployment options. -| Service | Port | -|-------------------|-------| -| gateway | 9000 | -| habit | 9001 | -| habit-postgres | 10001 | -| track | 9002 | -| track-postgres | 10002 | -| report | 9003 | -| ui | 9004 | -| keycloak | 8080 | -| keycloak-postgres | 10003 | +| Service | Port | +|-------------------|---------------| +| gateway | 9000 | +| habit | 9001 | +| habit-postgres | 10001 | +| track | 9002 | +| track-postgres | 10002 | +| report | 9003 | +| ui | 9004 | +| keycloak | 8080 | +| keycloak-postgres | 10003 | +| kafka | 11001 - 11003 | +| kafka-ui | 11001 - 11003 | diff --git a/infrastructure/docker/docker-compose.yml b/infrastructure/docker/docker-compose.yml index 3e320141..42bc1fea 100644 --- a/infrastructure/docker/docker-compose.yml +++ b/infrastructure/docker/docker-compose.yml @@ -87,6 +87,7 @@ services: - track-db environment: DB_HOST: track-db + KAFKA_BOOTSTRAP_SERVERS: kafka:11001 networks: - habitcentric-net track-db: @@ -111,6 +112,37 @@ services: TRACKING_SERVICE_HOST: http://localhost:9000/ networks: - habitcentric-net + kafka: + image: 'bitnami/kafka:latest' + ports: + - "11003:11003" + environment: + - KAFKA_CFG_NODE_ID=0 + - KAFKA_CFG_PROCESS_ROLES=controller,broker + - KAFKA_CFG_LISTENERS=PLAINTEXT://:11001,CONTROLLER://:11002,EXTERNAL://:11003 + - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:SASL_PLAINTEXT,EXTERNAL:SASL_PLAINTEXT + - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://kafka:11001,EXTERNAL://localhost:11003 + - KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=0@kafka:11002 + - KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER + - KAFKA_CLIENT_USERS=track,kafka-ui + - KAFKA_CLIENT_PASSWORDS=track,kafka-ui + networks: + - habitcentric-net + kafka-ui: + container_name: kafka-ui + image: provectuslabs/kafka-ui:latest + ports: + - "11004:11004" + environment: + SERVER_PORT: 11004 + DYNAMIC_CONFIG_ENABLED: 'true' + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:11001 + KAFKA_CLUSTERS_0_PROPERTIES_SECURITY_PROTOCOL: SASL_PLAINTEXT + KAFKA_CLUSTERS_0_PROPERTIES_SASL_MECHANISM: PLAIN + KAFKA_CLUSTERS_0_PROPERTIES_SASL_JAAS_CONFIG: 'org.apache.kafka.common.security.plain.PlainLoginModule required username="kafka-ui" password="kafka-ui";' + networks: + - habitcentric-net + volumes: auth-keycloak-db-data: networks: diff --git a/infrastructure/istio/config/23-mtls-authz-policies.yaml b/infrastructure/istio/config/23-mtls-authz-policies.yaml index fd7b41d7..9c85bb02 100644 --- a/infrastructure/istio/config/23-mtls-authz-policies.yaml +++ b/infrastructure/istio/config/23-mtls-authz-policies.yaml @@ -51,6 +51,23 @@ spec: --- apiVersion: security.istio.io/v1beta1 kind: AuthorizationPolicy +metadata: + name: hc-kafka-authz + namespace: hc-kafka +spec: + action: ALLOW + selector: + matchLabels: + app.kubernetes.io/name: kafka + app.kubernetes.io/instance: hc-kafka + rules: + - from: + - source: + principals: + - cluster.local/ns/hc-track/sa/track +--- +apiVersion: security.istio.io/v1beta1 +kind: AuthorizationPolicy metadata: name: keycloak-authz namespace: hc-keycloak diff --git a/infrastructure/kubernetes/helmfile.d/00-namespaces.yaml b/infrastructure/kubernetes/helmfile.d/00-namespaces.yaml index 4b1625b1..3213fbcf 100644 --- a/infrastructure/kubernetes/helmfile.d/00-namespaces.yaml +++ b/infrastructure/kubernetes/helmfile.d/00-namespaces.yaml @@ -1,14 +1,10 @@ +bases: + - environments.yaml + repositories: - name: incubator url: https://charts.helm.sh/incubator -environments: - default: - istio: - linkerd: - kuma: - traefik-mesh: - releases: - name: namespaces chart: incubator/raw @@ -94,6 +90,26 @@ releases: "kuma.io/mesh": "default" {{- end }} spec: + - apiVersion: v1 + kind: Namespace + metadata: + name: hc-kafka + {{- if eq .Environment.Name "istio" }} + labels: + "istio-injection": "enabled" + {{- end }} + {{- if eq .Environment.Name "linkerd" }} + annotations: + "linkerd.io/inject": "enabled" + "config.linkerd.io/trace-collector": "linkerd-collector.linkerd:55678" + "config.alpha.linkerd.io/trace-collector-service-account": "linkerd-collector" + {{- end }} + {{- if eq .Environment.Name "kuma" }} + annotations: + "kuma.io/sidecar-injection": "enabled" + "kuma.io/mesh": "default" + {{- end }} + spec: - apiVersion: v1 kind: Namespace metadata: diff --git a/infrastructure/kubernetes/helmfile.d/10-keycloak.yaml b/infrastructure/kubernetes/helmfile.d/10-keycloak.yaml index 0a3e73cc..379589d5 100644 --- a/infrastructure/kubernetes/helmfile.d/10-keycloak.yaml +++ b/infrastructure/kubernetes/helmfile.d/10-keycloak.yaml @@ -1,3 +1,6 @@ +bases: + - environments.yaml + repositories: - name: codecentric url: https://codecentric.github.io/helm-charts @@ -6,13 +9,6 @@ repositories: - name: bitnami-archive-full-index url: https://raw.githubusercontent.com/bitnami/charts/archive-full-index/bitnami -environments: - default: - istio: - linkerd: - kuma: - traefik-mesh: - releases: - name: keycloak-postgresql namespace: hc-keycloak diff --git a/infrastructure/kubernetes/helmfile.d/15-kafka.yaml b/infrastructure/kubernetes/helmfile.d/15-kafka.yaml new file mode 100644 index 00000000..e5e81ec9 --- /dev/null +++ b/infrastructure/kubernetes/helmfile.d/15-kafka.yaml @@ -0,0 +1,16 @@ +bases: + - environments.yaml + +repositories: + - name: bitnami-archive-full-index + url: https://raw.githubusercontent.com/bitnami/charts/archive-full-index/bitnami + +releases: + - name: kafka + namespace: hc-kafka + labels: + service: kafka + chart: bitnami-archive-full-index/kafka + version: 25.3.1 + values: + - ./values/kafka-values.yaml.gotmpl diff --git a/infrastructure/kubernetes/helmfile.d/20-habitcentric.yaml b/infrastructure/kubernetes/helmfile.d/20-habitcentric.yaml index 5c00b959..192326b2 100644 --- a/infrastructure/kubernetes/helmfile.d/20-habitcentric.yaml +++ b/infrastructure/kubernetes/helmfile.d/20-habitcentric.yaml @@ -1,9 +1,5 @@ -environments: - default: - istio: - linkerd: - kuma: - traefik-mesh: +bases: + - environments.yaml repositories: - name: bitnami-archive-full-index diff --git a/infrastructure/kubernetes/helmfile.d/environments.yaml b/infrastructure/kubernetes/helmfile.d/environments.yaml new file mode 100644 index 00000000..2d1dec5e --- /dev/null +++ b/infrastructure/kubernetes/helmfile.d/environments.yaml @@ -0,0 +1,6 @@ +environments: + default: + istio: + linkerd: + kuma: + traefik-mesh: diff --git a/infrastructure/kubernetes/helmfile.d/values/kafka-values.yaml.gotmpl b/infrastructure/kubernetes/helmfile.d/values/kafka-values.yaml.gotmpl new file mode 100644 index 00000000..3834ccdb --- /dev/null +++ b/infrastructure/kubernetes/helmfile.d/values/kafka-values.yaml.gotmpl @@ -0,0 +1,35 @@ +controller: + replicaCount: 1 +kraft: + # generated with: uuidgen | tr -d '-' | base64 | cut -b 1-22 + clusterId: "ZDQ2MzI3MWEwNmIwNGMyMT" +sasl: + interbroker: + password: "kafka" + controller: + password: "kafka" + client: + users: + - "track" + passwords: + - "track" + +externalAccess: + enabled: true + service: + broker: + type: LoadBalancer + ports: + external: 9094 + controller: + type: LoadBalancer + containerPorts: + external: 9094 + autoDiscovery: + enabled: true + +serviceAccount: + create: true + +rbac: + create: true diff --git a/infrastructure/kubernetes/helmfile.d/values/track-values.yaml.gotmpl b/infrastructure/kubernetes/helmfile.d/values/track-values.yaml.gotmpl index 45a2ca2e..ea90ed69 100644 --- a/infrastructure/kubernetes/helmfile.d/values/track-values.yaml.gotmpl +++ b/infrastructure/kubernetes/helmfile.d/values/track-values.yaml.gotmpl @@ -17,9 +17,10 @@ image: pullPolicy: Always ## habitcentric track service configuration - -{{- if (eq .Environment.Name "traefik-mesh") }} extraEnv: + - name: KAFKA_BOOTSTRAP_SERVERS + value: kafka.hc-kafka.svc.cluster.local:9092 +{{- if (eq .Environment.Name "traefik-mesh") }} - name: MANAGEMENT_ZIPKIN_TRACING_ENDPOINT value: http://jaeger-collector.traefik-mesh.svc.cluster.local:9411 {{- end }} diff --git a/services/track/build.gradle b/services/track/build.gradle index 839641e6..40775afc 100644 --- a/services/track/build.gradle +++ b/services/track/build.gradle @@ -52,6 +52,9 @@ spotless { repositories { mavenCentral() + maven { + url "https://repo.spring.io/milestone" + } } test { @@ -79,12 +82,25 @@ ext { ext['junit-jupiter.version'] = versions.junitJupiter ext['jna.version'] = versions.jna // Required for Docker on ARM +dependencyManagement { + imports { + mavenBom 'org.springframework.modulith:spring-modulith-bom:1.1.0-M1' + } +} + dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.security:spring-security-oauth2-jose' + + // Spring Modulith + implementation "org.springframework.modulith:spring-modulith-starter-core" + implementation "org.springframework.modulith:spring-modulith-starter-jpa" + implementation "org.springframework.modulith:spring-modulith-events-kafka" + intTestImplementation "org.springframework.modulith:spring-modulith-starter-test" + implementation 'io.micrometer:micrometer-tracing-bridge-otel' implementation 'io.opentelemetry:opentelemetry-exporter-zipkin' runtimeOnly "org.postgresql:postgresql:$versions.postgresql" diff --git a/services/track/docker-compose.yml b/services/track/docker-compose.yml index bce20e4f..71178e6b 100644 --- a/services/track/docker-compose.yml +++ b/services/track/docker-compose.yml @@ -4,7 +4,35 @@ services: image: bitnami/postgresql:15 restart: always ports: - - 10002:10002 + - "10002:10002" environment: POSTGRESQL_PASSWORD: postgres POSTGRESQL_PORT_NUMBER: 10002 + + kafka: + image: 'bitnami/kafka:latest' + ports: + - "11003:11003" + environment: + - KAFKA_CFG_NODE_ID=0 + - KAFKA_CFG_PROCESS_ROLES=controller,broker + - KAFKA_CFG_LISTENERS=PLAINTEXT://:11001,CONTROLLER://:11002,EXTERNAL://:11003 + - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:SASL_PLAINTEXT,EXTERNAL:SASL_PLAINTEXT + - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://kafka:11001,EXTERNAL://localhost:11003 + - KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=0@kafka:11002 + - KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER + - KAFKA_CLIENT_USERS=track,kafka-ui + - KAFKA_CLIENT_PASSWORDS=track,kafka-ui + + kafka-ui: + container_name: kafka-ui + image: provectuslabs/kafka-ui:latest + ports: + - "11004:11004" + environment: + SERVER_PORT: 11004 + DYNAMIC_CONFIG_ENABLED: 'true' + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:11001 + KAFKA_CLUSTERS_0_PROPERTIES_SECURITY_PROTOCOL: SASL_PLAINTEXT + KAFKA_CLUSTERS_0_PROPERTIES_SASL_MECHANISM: PLAIN + KAFKA_CLUSTERS_0_PROPERTIES_SASL_JAAS_CONFIG: 'org.apache.kafka.common.security.plain.PlainLoginModule required username="kafka-ui" password="kafka-ui";' diff --git a/services/track/src/intTest/java/de/codecentric/habitcentric/track/habit/HabitModuleIntegrationTest.java b/services/track/src/intTest/java/de/codecentric/habitcentric/track/habit/HabitModuleIntegrationTest.java new file mode 100644 index 00000000..7a7d2a35 --- /dev/null +++ b/services/track/src/intTest/java/de/codecentric/habitcentric/track/habit/HabitModuleIntegrationTest.java @@ -0,0 +1,69 @@ +package de.codecentric.habitcentric.track.habit; + +import static org.assertj.core.api.Assertions.assertThat; + +import de.codecentric.habitcentric.track.auth.UserIdArgumentResolver; +import java.time.LocalDate; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.modulith.test.ApplicationModuleTest; +import org.springframework.modulith.test.Scenario; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("intTest") +@ApplicationModuleTest +@MockBean(UserIdArgumentResolver.class) +public class HabitModuleIntegrationTest { + + @Autowired private HabitTrackingController habitTrackingController; + @Autowired private HabitTrackingRepository habitTrackingRepository; + + @AfterEach + void tearDown() { + habitTrackingRepository.deleteAll(); + } + + @Test + void shouldPublishDateTrackedEventWhenHabitTrackingIsSaved(Scenario scenario) { + habitTrackingRepository.save( + HabitTracking.from("userId", 1L, Set.of(LocalDate.parse("2023-09-29")))); + + scenario + .stimulate( + () -> + habitTrackingController.putHabitTrackingRecords( + "userId", + 1L, + Set.of(LocalDate.parse("2023-09-29"), LocalDate.parse("2023-09-30")))) + .andWaitForEventOfType(HabitTracking.DateTracked.class) + .toArriveAndVerify( + event -> { + assertThat(event.habitId()).isEqualTo(1L); + assertThat(event.userId()).isEqualTo("userId"); + assertThat(event.trackDate()).isEqualTo(LocalDate.parse("2023-09-30")); + }); + } + + @Test + void shouldPublishDateUntrackedEventWhenExistingHabitTrackingIsRemoved(Scenario scenario) { + habitTrackingRepository.save( + HabitTracking.from( + "userId", 1L, Set.of(LocalDate.parse("2023-09-29"), LocalDate.parse("2023-09-30")))); + + scenario + .stimulate( + () -> + habitTrackingController.putHabitTrackingRecords( + "userId", 1L, Set.of(LocalDate.parse("2023-09-29")))) + .andWaitForEventOfType(HabitTracking.DateUntracked.class) + .toArriveAndVerify( + event -> { + assertThat(event.habitId()).isEqualTo(1L); + assertThat(event.userId()).isEqualTo("userId"); + assertThat(event.trackDate()).isEqualTo(LocalDate.parse("2023-09-30")); + }); + } +} diff --git a/services/track/src/intTest/java/de/codecentric/habitcentric/track/habit/HabitTrackingControllerRestAssuredTest.java b/services/track/src/intTest/java/de/codecentric/habitcentric/track/habit/HabitTrackingControllerRestAssuredTest.java index 8be220a1..e2f78a83 100644 --- a/services/track/src/intTest/java/de/codecentric/habitcentric/track/habit/HabitTrackingControllerRestAssuredTest.java +++ b/services/track/src/intTest/java/de/codecentric/habitcentric/track/habit/HabitTrackingControllerRestAssuredTest.java @@ -1,7 +1,7 @@ package de.codecentric.habitcentric.track.habit; -import static de.codecentric.habitcentric.track.error.matcher.ApiErrorMatcher.hasHabitIdViolationError; -import static de.codecentric.habitcentric.track.error.matcher.ApiErrorMatcher.hasUserIdViolationError; +import static de.codecentric.habitcentric.track.habit.matcher.HabitApiMatcher.hasHabitIdViolationError; +import static de.codecentric.habitcentric.track.habit.matcher.HabitApiMatcher.hasUserIdViolationError; import static io.restassured.RestAssured.given; import static io.restassured.http.ContentType.JSON; import static org.assertj.core.api.Assertions.assertThat; @@ -30,7 +30,7 @@ public class HabitTrackingControllerRestAssuredTest extends RestAssuredTest { @AfterEach public void cleanUp() { - String[] tableNames = {"HC_TRACK.HABIT_TRACKING"}; + String[] tableNames = {"HC_TRACK.TRACKED_DATES", "HC_TRACK.HABIT_TRACKING"}; JdbcTestUtils.deleteFromTables(jdbcTemplate, tableNames); } diff --git a/services/track/src/intTest/java/de/codecentric/habitcentric/track/habit/jwt/HabitTrackingControllerJwtRestAssuredTest.java b/services/track/src/intTest/java/de/codecentric/habitcentric/track/habit/jwt/HabitTrackingControllerJwtRestAssuredTest.java index 5c6ef719..05f36504 100644 --- a/services/track/src/intTest/java/de/codecentric/habitcentric/track/habit/jwt/HabitTrackingControllerJwtRestAssuredTest.java +++ b/services/track/src/intTest/java/de/codecentric/habitcentric/track/habit/jwt/HabitTrackingControllerJwtRestAssuredTest.java @@ -1,7 +1,7 @@ package de.codecentric.habitcentric.track.habit.jwt; -import static de.codecentric.habitcentric.track.error.matcher.ApiErrorMatcher.hasHabitIdViolationError; -import static de.codecentric.habitcentric.track.error.matcher.ApiErrorMatcher.hasUserIdViolationError; +import static de.codecentric.habitcentric.track.habit.matcher.HabitApiMatcher.hasHabitIdViolationError; +import static de.codecentric.habitcentric.track.habit.matcher.HabitApiMatcher.hasUserIdViolationError; import static io.restassured.RestAssured.given; import static io.restassured.http.ContentType.JSON; import static org.assertj.core.api.Assertions.assertThat; @@ -29,7 +29,7 @@ public class HabitTrackingControllerJwtRestAssuredTest extends RestAssuredTest { @AfterEach public void cleanUp() { - String[] tableNames = {"HC_TRACK.HABIT_TRACKING"}; + String[] tableNames = {"HC_TRACK.TRACKED_DATES", "HC_TRACK.HABIT_TRACKING"}; JdbcTestUtils.deleteFromTables(jdbcTemplate, tableNames); } diff --git a/services/track/src/intTest/java/de/codecentric/habitcentric/track/habit/matcher/HabitApiMatcher.java b/services/track/src/intTest/java/de/codecentric/habitcentric/track/habit/matcher/HabitApiMatcher.java new file mode 100644 index 00000000..71b88620 --- /dev/null +++ b/services/track/src/intTest/java/de/codecentric/habitcentric/track/habit/matcher/HabitApiMatcher.java @@ -0,0 +1,17 @@ +package de.codecentric.habitcentric.track.habit.matcher; + +import static de.codecentric.habitcentric.track.habit.HabitTrackingError.CONSTRAINT_VIOLATION; +import static de.codecentric.habitcentric.track.utils.ApiErrorMatcher.hasError; + +import org.hamcrest.Matcher; + +public class HabitApiMatcher { + + public static Matcher hasUserIdViolationError() { + return hasError(CONSTRAINT_VIOLATION, "must not be blank and size must be between 5 and 64"); + } + + public static Matcher hasHabitIdViolationError() { + return hasError(CONSTRAINT_VIOLATION, "must be greater than 0"); + } +} diff --git a/services/track/src/intTest/java/de/codecentric/habitcentric/track/error/matcher/ApiErrorMatcherTest.java b/services/track/src/intTest/java/de/codecentric/habitcentric/track/habit/matcher/HabitApiMatcherTest.java similarity index 90% rename from services/track/src/intTest/java/de/codecentric/habitcentric/track/error/matcher/ApiErrorMatcherTest.java rename to services/track/src/intTest/java/de/codecentric/habitcentric/track/habit/matcher/HabitApiMatcherTest.java index f7474899..55d1b7c1 100644 --- a/services/track/src/intTest/java/de/codecentric/habitcentric/track/error/matcher/ApiErrorMatcherTest.java +++ b/services/track/src/intTest/java/de/codecentric/habitcentric/track/habit/matcher/HabitApiMatcherTest.java @@ -1,6 +1,6 @@ -package de.codecentric.habitcentric.track.error.matcher; +package de.codecentric.habitcentric.track.habit.matcher; -import static de.codecentric.habitcentric.track.error.matcher.ApiErrorMatcher.hasHabitIdViolationError; +import static de.codecentric.habitcentric.track.habit.matcher.HabitApiMatcher.hasHabitIdViolationError; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.not; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -10,7 +10,7 @@ import org.hamcrest.Description; import org.junit.jupiter.api.Test; -public class ApiErrorMatcherTest { +public class HabitApiMatcherTest { private final String habitIdViolationErrorResponse = "{\"errors\":[{\"code\":\"TRACK_101\",\"title\":\"Constraint violation\",\"detail\":\"must be greater than 0\",\"id\":\"123\"}]}"; diff --git a/services/track/src/intTest/java/de/codecentric/habitcentric/track/error/matcher/ApiErrorMatcher.java b/services/track/src/intTest/java/de/codecentric/habitcentric/track/utils/ApiErrorMatcher.java similarity index 81% rename from services/track/src/intTest/java/de/codecentric/habitcentric/track/error/matcher/ApiErrorMatcher.java rename to services/track/src/intTest/java/de/codecentric/habitcentric/track/utils/ApiErrorMatcher.java index b85f0bbe..81b9f5a8 100644 --- a/services/track/src/intTest/java/de/codecentric/habitcentric/track/error/matcher/ApiErrorMatcher.java +++ b/services/track/src/intTest/java/de/codecentric/habitcentric/track/utils/ApiErrorMatcher.java @@ -1,6 +1,5 @@ -package de.codecentric.habitcentric.track.error.matcher; +package de.codecentric.habitcentric.track.utils; -import static de.codecentric.habitcentric.track.habit.HabitTrackingError.CONSTRAINT_VIOLATION; import static org.apache.commons.lang3.builder.ToStringStyle.JSON_STYLE; import com.fasterxml.jackson.databind.ObjectMapper; @@ -55,12 +54,4 @@ public void describeTo(Description description) { public static Matcher hasError(ApiError expected, Object... templateProperties) { return new ApiErrorMatcher(expected, templateProperties); } - - public static Matcher hasUserIdViolationError() { - return hasError(CONSTRAINT_VIOLATION, "must not be blank and size must be between 5 and 64"); - } - - public static Matcher hasHabitIdViolationError() { - return hasError(CONSTRAINT_VIOLATION, "must be greater than 0"); - } } diff --git a/services/track/src/intTest/resources/application-intTest.yml b/services/track/src/intTest/resources/application-intTest.yml index 74e383c7..5e977389 100644 --- a/services/track/src/intTest/resources/application-intTest.yml +++ b/services/track/src/intTest/resources/application-intTest.yml @@ -1,7 +1,7 @@ spring: datasource: driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver - url: jdbc:tc:postgresql:14://localhost/postgres + url: jdbc:tc:postgresql:16://localhost/postgres jpa: properties: hibernate: diff --git a/services/track/src/main/java/de/codecentric/habitcentric/track/CustomJsonSerializer.java b/services/track/src/main/java/de/codecentric/habitcentric/track/CustomJsonSerializer.java new file mode 100644 index 00000000..0fc7ce9b --- /dev/null +++ b/services/track/src/main/java/de/codecentric/habitcentric/track/CustomJsonSerializer.java @@ -0,0 +1,33 @@ +package de.codecentric.habitcentric.track; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.springframework.kafka.support.JacksonUtils; +import org.springframework.kafka.support.serializer.JsonSerializer; + +/** + * Custom Spring Kafka {@link JsonSerializer} that serializes timestamps as strings instead of + * integer arrays. + * + *

For some reason, Spring Boot does not use the auto-configured {@link ObjectMapper} when + * auto-configuring Spring Kafka. Instead, it calls the no-args constructor of the {@link + * JsonSerializer} class provided by the property {@code spring.kafka.producer.value-serializer}. + * + * @param + * @see https://github.com/spring-projects/spring-kafka/issues/680 + * @see https://docs.spring.io/spring-kafka/reference/tips.html#tip-json + */ +public class CustomJsonSerializer extends JsonSerializer { + + public CustomJsonSerializer() { + super(createMapper()); + } + + private static ObjectMapper createMapper() { + var mapper = JacksonUtils.enhancedObjectMapper(); + mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + return mapper; + } +} diff --git a/services/track/src/main/java/de/codecentric/habitcentric/track/habit/validation/UserId.java b/services/track/src/main/java/de/codecentric/habitcentric/track/auth/UserId.java similarity index 92% rename from services/track/src/main/java/de/codecentric/habitcentric/track/habit/validation/UserId.java rename to services/track/src/main/java/de/codecentric/habitcentric/track/auth/UserId.java index ee8190b4..cdf6903e 100644 --- a/services/track/src/main/java/de/codecentric/habitcentric/track/habit/validation/UserId.java +++ b/services/track/src/main/java/de/codecentric/habitcentric/track/auth/UserId.java @@ -1,4 +1,4 @@ -package de.codecentric.habitcentric.track.habit.validation; +package de.codecentric.habitcentric.track.auth; import jakarta.validation.Constraint; import jakarta.validation.Payload; diff --git a/services/track/src/main/java/de/codecentric/habitcentric/track/auth/UserIdArgumentResolver.java b/services/track/src/main/java/de/codecentric/habitcentric/track/auth/UserIdArgumentResolver.java index 25f2e589..837ada38 100644 --- a/services/track/src/main/java/de/codecentric/habitcentric/track/auth/UserIdArgumentResolver.java +++ b/services/track/src/main/java/de/codecentric/habitcentric/track/auth/UserIdArgumentResolver.java @@ -2,7 +2,6 @@ import static de.codecentric.habitcentric.track.auth.AuthError.JWT_TOKEN_MISSING; -import de.codecentric.habitcentric.track.habit.validation.UserId; import jakarta.servlet.http.HttpServletRequest; import lombok.Setter; import org.springframework.beans.factory.annotation.Autowired; diff --git a/services/track/src/main/java/de/codecentric/habitcentric/track/habit/HabitTracking.java b/services/track/src/main/java/de/codecentric/habitcentric/track/habit/HabitTracking.java index 2c516b50..6c287d3c 100644 --- a/services/track/src/main/java/de/codecentric/habitcentric/track/habit/HabitTracking.java +++ b/services/track/src/main/java/de/codecentric/habitcentric/track/habit/HabitTracking.java @@ -1,8 +1,13 @@ package de.codecentric.habitcentric.track.habit; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; import jakarta.persistence.Embeddable; import jakarta.persistence.EmbeddedId; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; import jakarta.persistence.Table; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -11,27 +16,78 @@ import jakarta.validation.constraints.Size; import java.io.Serializable; import java.time.LocalDate; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; +import org.springframework.data.domain.AbstractAggregateRoot; +import org.springframework.modulith.events.Externalized; @Entity @AllArgsConstructor @NoArgsConstructor -@EqualsAndHashCode +@EqualsAndHashCode(callSuper = false) @Getter @Setter @ToString @Table -public class HabitTracking { +public class HabitTracking extends AbstractAggregateRoot { @EmbeddedId @Valid private Id id; - public HabitTracking(String userId, Long habitId, LocalDate trackDate) { - this.id = new HabitTracking.Id(userId, habitId, trackDate); + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "tracked_dates", + joinColumns = {@JoinColumn(name = "habit_id"), @JoinColumn(name = "user_id")}) + @Column(name = "tracking_date") + private Set trackings; + + public void track(Set dates) { + var untrackedDates = calculateUntrackedDates(dates); + var trackedDates = calculateTrackedDates(dates); + + untrackedDates.forEach(this::registerUntrackedEvent); + trackedDates.forEach(this::registerTrackedEvent); + + trackings = new HashSet<>(dates); + } + + private Set calculateUntrackedDates(Set dates) { + Set copy = new HashSet<>(trackings); + copy.removeAll(dates); + return copy; + } + + private Set calculateTrackedDates(Set dates) { + Set copy = new HashSet<>(dates); + copy.removeAll(trackings); + return copy; + } + + private void registerTrackedEvent(LocalDate date) { + registerEvent(new DateTracked(id.userId, id.habitId, date)); + } + + private void registerUntrackedEvent(LocalDate date) { + registerEvent(new DateUntracked(id.userId, id.habitId, date)); + } + + public List getSortedTrackingDates() { + return trackings.stream().sorted().toList(); + } + + public static HabitTracking from(String userId, Long habitId) { + return new HabitTracking(new Id(userId, habitId), new HashSet<>()); + } + + public static HabitTracking from(String userId, Long habitId, Collection trackings) { + return new HabitTracking(new Id(userId, habitId), new HashSet<>(trackings)); } @Embeddable @@ -42,13 +98,24 @@ public HabitTracking(String userId, Long habitId, LocalDate trackDate) { @EqualsAndHashCode @ToString public static class Id implements Serializable { - @NotBlank @Size(max = 64) private String userId; @NotNull @Positive private Long habitId; + } + + @Externalized("habit-tracking-events::#{#this.getId()}") + public record DateTracked(String userId, Long habitId, LocalDate trackDate) { + public String getId() { + return userId + "-" + habitId; + } + } - @NotNull private LocalDate trackDate; + @Externalized("habit-tracking-events::#{#this.getId()}") + public record DateUntracked(String userId, Long habitId, LocalDate trackDate) { + public String getId() { + return userId + "-" + habitId; + } } } diff --git a/services/track/src/main/java/de/codecentric/habitcentric/track/habit/HabitTrackingController.java b/services/track/src/main/java/de/codecentric/habitcentric/track/habit/HabitTrackingController.java index 5dca4851..8e9b052b 100644 --- a/services/track/src/main/java/de/codecentric/habitcentric/track/habit/HabitTrackingController.java +++ b/services/track/src/main/java/de/codecentric/habitcentric/track/habit/HabitTrackingController.java @@ -1,12 +1,12 @@ package de.codecentric.habitcentric.track.habit; +import de.codecentric.habitcentric.track.auth.UserId; import de.codecentric.habitcentric.track.habit.validation.HabitId; -import de.codecentric.habitcentric.track.habit.validation.UserId; import jakarta.transaction.Transactional; import java.time.LocalDate; -import java.util.List; +import java.util.Collection; +import java.util.Collections; import java.util.Set; -import java.util.stream.Collectors; import org.springframework.stereotype.Controller; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; @@ -28,7 +28,7 @@ public HabitTrackingController(HabitTrackingRepository repository) { @Transactional @PutMapping("/track/habits/{habitId}") @ResponseBody - public List putHabitTrackingRecordsWithJwt( + public Collection putHabitTrackingRecordsWithJwt( @UserId String userId, @PathVariable @HabitId Long habitId, @RequestBody Set dates) { @@ -38,16 +38,20 @@ public List putHabitTrackingRecordsWithJwt( @Transactional @PutMapping("/track/users/{userId}/habits/{habitId}") @ResponseBody - public List putHabitTrackingRecords( + public Collection putHabitTrackingRecords( @PathVariable @UserId String userId, @PathVariable @HabitId Long habitId, @RequestBody Set dates) { - repository.deleteByIdHabitId(habitId); - Set trackRecords = - dates.stream() - .map(date -> new HabitTracking(userId, habitId, date)) - .collect(Collectors.toSet()); - return extractDates(repository.saveAll(trackRecords)); + var existingHabitTrackings = + repository + .findByIdUserIdAndIdHabitId(userId, habitId) + .orElse(HabitTracking.from(userId, habitId)); + + existingHabitTrackings.track(dates); + + repository.save(existingHabitTrackings); + + return existingHabitTrackings.getSortedTrackingDates(); } @GetMapping("/track/habits/{habitId}") @@ -61,13 +65,9 @@ public Iterable getHabitTrackingRecordsWithJwt( @ResponseBody public Iterable getHabitTrackingRecords( @PathVariable @UserId String userId, @PathVariable @HabitId Long habitId) { - return extractDates(repository.findByIdUserIdAndIdHabitId(userId, habitId)); - } - - protected List extractDates(List trackRecords) { - return trackRecords.stream() - .map(tracking -> tracking.getId().getTrackDate()) - .sorted() - .collect(Collectors.toList()); + return repository + .findByIdUserIdAndIdHabitId(userId, habitId) + .map(HabitTracking::getSortedTrackingDates) + .orElse(Collections.emptyList()); } } diff --git a/services/track/src/main/java/de/codecentric/habitcentric/track/habit/HabitTrackingRepository.java b/services/track/src/main/java/de/codecentric/habitcentric/track/habit/HabitTrackingRepository.java index 884b6df3..7626cdf7 100644 --- a/services/track/src/main/java/de/codecentric/habitcentric/track/habit/HabitTrackingRepository.java +++ b/services/track/src/main/java/de/codecentric/habitcentric/track/habit/HabitTrackingRepository.java @@ -1,12 +1,8 @@ package de.codecentric.habitcentric.track.habit; -import de.codecentric.habitcentric.track.habit.HabitTracking.Id; -import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -public interface HabitTrackingRepository extends JpaRepository { - - List findByIdUserIdAndIdHabitId(String userId, Long habitId); - - void deleteByIdHabitId(Long habitId); +public interface HabitTrackingRepository extends JpaRepository { + Optional findByIdUserIdAndIdHabitId(String userId, Long habitId); } diff --git a/services/track/src/main/resources/application.yml b/services/track/src/main/resources/application.yml index e8a847ac..9279f2ea 100644 --- a/services/track/src/main/resources/application.yml +++ b/services/track/src/main/resources/application.yml @@ -11,6 +11,7 @@ spring: flyway.schemas: hc_track jackson: serialization: + WRITE_DATES_AS_TIMESTAMPS: false FAIL_ON_EMPTY_BEANS: false jpa: database-platform: postgres @@ -25,6 +26,19 @@ spring: lob: # https://github.com/spring-projects/spring-boot/issues/12007#issuecomment-370774241 non_contextual_creation: true + + kafka: + client-id: track + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:11003} + properties: + "security.protocol": SASL_PLAINTEXT + "sasl.mechanism": PLAIN + "sasl.jaas.config": 'org.apache.kafka.common.security.plain.PlainLoginModule required username="${KAFKA_USER:track}" password="${KAFKA_PASSWORD:track}";' + producer: + properties: + "spring.json.type.mapping": "date-tracked:de.codecentric.habitcentric.track.habit.HabitTracking$DateTracked,date-untracked:de.codecentric.habitcentric.track.habit.HabitTracking$DateUntracked" + value-serializer: de.codecentric.habitcentric.track.CustomJsonSerializer + server: port: 9002 error: diff --git a/services/track/src/main/resources/db/migration/V1.1__create-modulith-schema.sql b/services/track/src/main/resources/db/migration/V1.1__create-modulith-schema.sql new file mode 100644 index 00000000..cd00532f --- /dev/null +++ b/services/track/src/main/resources/db/migration/V1.1__create-modulith-schema.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS event_publication +( + id UUID NOT NULL, + listener_id TEXT NOT NULL, + event_type TEXT NOT NULL, + serialized_event TEXT NOT NULL, + publication_date TIMESTAMP NOT NULL, + completion_date TIMESTAMP, + PRIMARY KEY (id) +) diff --git a/services/track/src/main/resources/db/migration/V1__create-schema.sql b/services/track/src/main/resources/db/migration/V1__create-schema.sql index be72a515..26ef597f 100644 --- a/services/track/src/main/resources/db/migration/V1__create-schema.sql +++ b/services/track/src/main/resources/db/migration/V1__create-schema.sql @@ -1,2 +1,5 @@ -CREATE TABLE habit_tracking (user_id VARCHAR(64) NOT NULL, habit_id BIGINT NOT NULL, track_date DATE NOT NULL); -ALTER TABLE habit_tracking ADD PRIMARY KEY (user_id, habit_id, track_date); +CREATE TABLE habit_tracking (user_id VARCHAR(64) NOT NULL, habit_id BIGINT NOT NULL); +ALTER TABLE habit_tracking ADD PRIMARY KEY (user_id, habit_id); + +CREATE TABLE tracked_dates(tracking_date DATE NOT NULL, user_id VARCHAR(64) NOT NULL, habit_id BIGINT NOT NULL); +ALTER TABLE tracked_dates ADD FOREIGN KEY (user_id, habit_id) REFERENCES habit_tracking(user_id, habit_id); diff --git a/services/track/src/test/java/de/codecentric/habitcentric/track/CustomJsonSerializerTest.java b/services/track/src/test/java/de/codecentric/habitcentric/track/CustomJsonSerializerTest.java new file mode 100644 index 00000000..574215e2 --- /dev/null +++ b/services/track/src/test/java/de/codecentric/habitcentric/track/CustomJsonSerializerTest.java @@ -0,0 +1,15 @@ +package de.codecentric.habitcentric.track; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class CustomJsonSerializerTest { + + @Test + void creates() { + try (var subject = new CustomJsonSerializer<>()) { + assertThat(subject).isNotNull(); + } + } +} diff --git a/services/track/src/test/java/de/codecentric/habitcentric/track/auth/UserIdArgumentResolverTest.java b/services/track/src/test/java/de/codecentric/habitcentric/track/auth/UserIdArgumentResolverTest.java index aaa54c30..e4879447 100644 --- a/services/track/src/test/java/de/codecentric/habitcentric/track/auth/UserIdArgumentResolverTest.java +++ b/services/track/src/test/java/de/codecentric/habitcentric/track/auth/UserIdArgumentResolverTest.java @@ -8,7 +8,6 @@ import de.codecentric.habitcentric.track.error.ApiError; import de.codecentric.habitcentric.track.error.ApiErrorException; -import de.codecentric.habitcentric.track.habit.validation.UserId; import jakarta.servlet.http.HttpServletRequest; import org.assertj.core.api.Condition; import org.junit.jupiter.api.BeforeEach; diff --git a/services/track/src/test/java/de/codecentric/habitcentric/track/habit/HabitTrackingControllerTest.java b/services/track/src/test/java/de/codecentric/habitcentric/track/habit/HabitTrackingControllerTest.java deleted file mode 100644 index 6b092a78..00000000 --- a/services/track/src/test/java/de/codecentric/habitcentric/track/habit/HabitTrackingControllerTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package de.codecentric.habitcentric.track.habit; - -import static java.time.Month.DECEMBER; -import static java.time.Month.JANUARY; -import static java.time.Month.MARCH; -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.LocalDate; -import java.util.Arrays; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.junit.jupiter.SpringExtension; - -@ExtendWith(SpringExtension.class) -public class HabitTrackingControllerTest { - - @MockBean private HabitTrackingRepository repository; - - private HabitTrackingController controller; - - @BeforeEach - public void beforeEach() { - controller = new HabitTrackingController(repository); - } - - @Test - public void shouldExtractDatesFromTrackRecords() { - - final String userId = "abc.def"; - final Long habitId = 123L; - - List trackRecords = - Arrays.asList( - new HabitTracking(userId, habitId, LocalDate.of(2019, MARCH, 21)), - new HabitTracking(userId, habitId, LocalDate.of(2018, DECEMBER, 31)), - new HabitTracking(userId, habitId, LocalDate.of(2019, JANUARY, 1))); - - assertThat(controller.extractDates(trackRecords)) - .containsExactly( - LocalDate.of(2018, DECEMBER, 31), - LocalDate.of(2019, JANUARY, 1), - LocalDate.of(2019, MARCH, 21)); - } -} diff --git a/services/track/src/test/java/de/codecentric/habitcentric/track/habit/HabitTrackingControllerWebMvcTest.java b/services/track/src/test/java/de/codecentric/habitcentric/track/habit/HabitTrackingControllerWebMvcTest.java index 36b87bd8..5a6ed1d4 100644 --- a/services/track/src/test/java/de/codecentric/habitcentric/track/habit/HabitTrackingControllerWebMvcTest.java +++ b/services/track/src/test/java/de/codecentric/habitcentric/track/habit/HabitTrackingControllerWebMvcTest.java @@ -2,7 +2,7 @@ import static java.time.Month.DECEMBER; import static java.time.Month.JANUARY; -import static org.mockito.ArgumentMatchers.anyIterable; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -11,9 +11,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.time.LocalDate; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; +import java.util.Optional; +import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -31,11 +30,14 @@ public class HabitTrackingControllerWebMvcTest { private final String userId = "abc.def"; private final Long habitId = 123L; - private final List defaultTrackRecords = - Arrays.asList( - new HabitTracking(userId, habitId, LocalDate.of(2019, JANUARY, 31)), - new HabitTracking(userId, habitId, LocalDate.of(2018, DECEMBER, 31)), - new HabitTracking(userId, habitId, LocalDate.of(2019, JANUARY, 1))); + private final HabitTracking defaultTrackRecords = + HabitTracking.from( + userId, + habitId, + Set.of( + LocalDate.of(2019, JANUARY, 31), + LocalDate.of(2018, DECEMBER, 31), + LocalDate.of(2019, JANUARY, 1))); private final String expected = "[\"2018-12-31\",\"2019-01-01\",\"2019-01-31\"]"; @@ -47,7 +49,8 @@ public class HabitTrackingControllerWebMvcTest { @Test public void shouldReturnTrackRecords() throws Exception { - given(repository.findByIdUserIdAndIdHabitId(userId, habitId)).willReturn(defaultTrackRecords); + given(repository.findByIdUserIdAndIdHabitId(userId, habitId)) + .willReturn(Optional.of(defaultTrackRecords)); mockMvc .perform(get(urlTemplate, userId, habitId)) .andExpect(status().isOk()) @@ -56,7 +59,8 @@ public void shouldReturnTrackRecords() throws Exception { @Test public void shouldReturnEmptyArrayWhenTrackRecordsAreNotFound() throws Exception { - given(repository.findByIdUserIdAndIdHabitId(userId, habitId)).willReturn(new ArrayList<>()); + given(repository.findByIdUserIdAndIdHabitId(userId, habitId)) + .willReturn(Optional.of(HabitTracking.from(userId, habitId))); mockMvc .perform(get(urlTemplate, userId, habitId)) .andExpect(status().isOk()) @@ -65,7 +69,9 @@ public void shouldReturnEmptyArrayWhenTrackRecordsAreNotFound() throws Exception @Test public void shouldFilterOutDuplicateTrackRecords() throws Exception { - given(repository.saveAll(anyIterable())).willReturn(defaultTrackRecords); + given(repository.findByIdUserIdAndIdHabitId(userId, habitId)) + .willReturn(Optional.of(defaultTrackRecords)); + given(repository.save(any())).willReturn(defaultTrackRecords); mockMvc .perform( put(urlTemplate, userId, habitId) diff --git a/services/track/src/test/java/de/codecentric/habitcentric/track/habit/HabitTrackingTest.java b/services/track/src/test/java/de/codecentric/habitcentric/track/habit/HabitTrackingTest.java new file mode 100644 index 00000000..61c85a04 --- /dev/null +++ b/services/track/src/test/java/de/codecentric/habitcentric/track/habit/HabitTrackingTest.java @@ -0,0 +1,60 @@ +package de.codecentric.habitcentric.track.habit; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class HabitTrackingTest { + + @Test + void trackShouldAddTrackingDates() { + var subject = HabitTracking.from("userId", 1L); + subject.track(Set.of(LocalDate.parse("2023-01-01"))); + assertThat(subject.getTrackings()).contains(LocalDate.parse("2023-01-01")); + } + + @Test + void trackShouldOverwriteExistingTrackingDates() { + var subject = HabitTracking.from("userId", 1L, List.of(LocalDate.parse("2023-01-01"))); + subject.track(Set.of(LocalDate.parse("2023-01-02"))); + assertThat(subject.getTrackings()).containsOnly(LocalDate.parse("2023-01-02")); + } + + @Test + void trackShouldNotAddTrackingDateIfTrackingDateAlreadyExists() { + var subject = HabitTracking.from("userId", 1L, List.of(LocalDate.parse("2023-01-01"))); + subject.track(Set.of(LocalDate.parse("2023-01-01"))); + assertThat(subject.getTrackings()).containsOnly(LocalDate.parse("2023-01-01")); + } + + @Test + void getSortedTrackingDatesShouldReturnTrackingDatesSortedAscending() { + var subject = + HabitTracking.from( + "userId", 1L, List.of(LocalDate.parse("2023-01-02"), LocalDate.parse("2023-01-01"))); + assertThat(subject.getSortedTrackingDates()) + .containsExactly(LocalDate.parse("2023-01-01"), LocalDate.parse("2023-01-02")); + } + + @Nested + class DateTrackedTest { + @Test + void getIdShouldReturnCombinedId() { + var subject = new HabitTracking.DateTracked("userId", 1L, LocalDate.now()); + assertThat(subject.getId()).isEqualTo("userId-1"); + } + } + + @Nested + class DateUntrackedTest { + @Test + void getIdShouldReturnCombinedId() { + var subject = new HabitTracking.DateUntracked("userId", 1L, LocalDate.now()); + assertThat(subject.getId()).isEqualTo("userId-1"); + } + } +} diff --git a/services/track/src/test/java/de/codecentric/habitcentric/track/habit/jwt/HabitTrackingControllerJwtWebMvcTest.java b/services/track/src/test/java/de/codecentric/habitcentric/track/habit/jwt/HabitTrackingControllerJwtWebMvcTest.java index f0ecc291..4b881e5e 100644 --- a/services/track/src/test/java/de/codecentric/habitcentric/track/habit/jwt/HabitTrackingControllerJwtWebMvcTest.java +++ b/services/track/src/test/java/de/codecentric/habitcentric/track/habit/jwt/HabitTrackingControllerJwtWebMvcTest.java @@ -3,7 +3,6 @@ import static java.time.Month.DECEMBER; import static java.time.Month.JANUARY; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyIterable; import static org.mockito.BDDMockito.given; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -36,11 +35,14 @@ public class HabitTrackingControllerJwtWebMvcTest { private final String authorizationHeader = "Bearer _"; private final Long habitId = 123L; - private final List defaultTrackRecords = - Arrays.asList( - new HabitTracking(userId, habitId, LocalDate.of(2019, JANUARY, 31)), - new HabitTracking(userId, habitId, LocalDate.of(2018, DECEMBER, 31)), - new HabitTracking(userId, habitId, LocalDate.of(2019, JANUARY, 1))); + private final HabitTracking defaultTrackRecords = + HabitTracking.from( + userId, + habitId, + Set.of( + LocalDate.of(2019, JANUARY, 31), + LocalDate.of(2018, DECEMBER, 31), + LocalDate.of(2019, JANUARY, 1))); private final String expected = "[\"2018-12-31\",\"2019-01-01\",\"2019-01-31\"]"; @@ -74,7 +76,8 @@ private Map claimsWithSubject() { @Test public void shouldReturnTrackRecords() throws Exception { - given(repository.findByIdUserIdAndIdHabitId(userId, habitId)).willReturn(defaultTrackRecords); + given(repository.findByIdUserIdAndIdHabitId(userId, habitId)) + .willReturn(Optional.of(defaultTrackRecords)); mockMvc .perform(get(urlTemplate, habitId).header(HttpHeaders.AUTHORIZATION, authorizationHeader)) .andExpect(status().isOk()) @@ -83,7 +86,8 @@ public void shouldReturnTrackRecords() throws Exception { @Test public void shouldReturnEmptyArrayWhenTrackRecordsAreNotFound() throws Exception { - given(repository.findByIdUserIdAndIdHabitId(userId, habitId)).willReturn(new ArrayList<>()); + given(repository.findByIdUserIdAndIdHabitId(userId, habitId)) + .willReturn(Optional.of(HabitTracking.from(userId, habitId))); mockMvc .perform(get(urlTemplate, habitId).header(HttpHeaders.AUTHORIZATION, authorizationHeader)) .andExpect(status().isOk()) @@ -92,7 +96,9 @@ public void shouldReturnEmptyArrayWhenTrackRecordsAreNotFound() throws Exception @Test public void shouldFilterOutDuplicateTrackRecords() throws Exception { - given(repository.saveAll(anyIterable())).willReturn(defaultTrackRecords); + given(repository.findByIdUserIdAndIdHabitId(userId, habitId)) + .willReturn(Optional.of(defaultTrackRecords)); + given(repository.save(any())).willReturn(defaultTrackRecords); mockMvc .perform( put(urlTemplate, habitId) diff --git a/services/track/track-request.http b/services/track/track-request.http new file mode 100644 index 00000000..0d36b404 --- /dev/null +++ b/services/track/track-request.http @@ -0,0 +1,7 @@ +PUT localhost:9002/track/users/12345/habits/1 +Content-Type: application/json + +[ + "2021-01-01", + "2021-01-02" +]