diff --git a/.gitignore b/.gitignore index d3e71b0..7422024 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,9 @@ gradle/** out/ !**/src/main/**/out/ !**/src/test/**/out/ - +**/src/main/generated +!*.env +local.env ### NetBeans ### /nbproject/private/ /nbbuild/ diff --git a/build.gradle b/build.gradle index f74b83d..6330c5d 100644 --- a/build.gradle +++ b/build.gradle @@ -45,9 +45,12 @@ dependencies { implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' implementation 'org.jetbrains:annotations:23.0.0' implementation 'org.flywaydb:flyway-core' - implementation 'com.odeyalo.sonata:suite:1.0.0' + implementation 'com.odeyalo.sonata.suite:common:1.0.1' + implementation 'com.odeyalo.sonata.suite:suite-brokers:0.0.11' implementation "com.odeyalo.sonata.suite:security:${suiteSecurityVersion}" implementation "org.mapstruct:mapstruct:${mapstructVersion}" + implementation 'io.projectreactor.kafka:reactor-kafka:1.3.21' + implementation 'org.springframework.kafka:spring-kafka:3.0.9' compileOnly 'org.projectlombok:lombok' runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'org.postgresql:r2dbc-postgresql' @@ -62,7 +65,7 @@ dependencies { testImplementation 'org.springframework.cloud:spring-cloud-contract-stub-runner' testImplementation 'io.r2dbc:r2dbc-h2' testImplementation "com.github.javafaker:javafaker:${javaFakerVersion}" - + testImplementation 'org.testcontainers:postgresql' } dependencyManagement { diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 0218c2b..c7bfbbd 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -17,12 +17,16 @@ services: # Used to download required dependencies from Suite module GITHUB_USERNAME: ${GH_USERNAME} GITHUB_ACCESS_TOKEN: ${GH_ACCESS_TOKEN} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + db: image: 'postgres:15.2' container_name: db + command: -c 'max_connections=200' ports: - - "5433:5432" + - "5435:5432" environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=root diff --git a/sonata-playlists-test-run.sh b/sonata-playlists-test-run.sh index 722fc50..74fa9cb 100644 --- a/sonata-playlists-test-run.sh +++ b/sonata-playlists-test-run.sh @@ -1,3 +1,3 @@ #!/bin/bash -docker-compose -f docker-compose.test.yml up --abort-on-container-exit \ No newline at end of file +docker compose -f docker-compose.test.yml up --abort-on-container-exit \ No newline at end of file diff --git a/src/main/java/com/odeyalo/sonata/playlists/config/kafka/KafkaConsumerConfiguration.java b/src/main/java/com/odeyalo/sonata/playlists/config/kafka/KafkaConsumerConfiguration.java new file mode 100644 index 0000000..6fb7c9c --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/config/kafka/KafkaConsumerConfiguration.java @@ -0,0 +1,55 @@ +package com.odeyalo.sonata.playlists.config.kafka; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.GenerativePlaylistEvent; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.reactive.ReactiveKafkaConsumerTemplate; +import org.springframework.kafka.support.serializer.JsonDeserializer; +import reactor.kafka.receiver.ReceiverOptions; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class KafkaConsumerConfiguration { + + @Bean + public Jackson2ObjectMapperBuilderCustomizer customizer() { + return jacksonObjectMapperBuilder -> jacksonObjectMapperBuilder.modules( + new JavaTimeModule(), + new ParameterNamesModule() + ); + } + + @Bean + public ReceiverOptions generatedPlaylistReceiverOptions(ObjectMapper objectMapper) { + final Map consumerProps = new HashMap<>(); + consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "generated-playlists-consumers"); + consumerProps.put(JsonDeserializer.TRUSTED_PACKAGES, "*"); + consumerProps.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, false); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class); + + final JsonDeserializer deserializer = new JsonDeserializer<>(GenerativePlaylistEvent.class, objectMapper); + + deserializer.setUseTypeHeaders(false); + + return ReceiverOptions.create(consumerProps) + .withValueDeserializer(deserializer) + .subscription(Collections.singleton("playlists.gen.images")); + } + + @Bean + public ReactiveKafkaConsumerTemplate reactiveKafkaConsumerTemplate(ReceiverOptions kafkaReceiverOptions) { + return new ReactiveKafkaConsumerTemplate<>(kafkaReceiverOptions); + } +} diff --git a/src/main/java/com/odeyalo/sonata/playlists/config/security/local/AuthenticationConfiguration.java b/src/main/java/com/odeyalo/sonata/playlists/config/security/local/AuthenticationConfiguration.java new file mode 100644 index 0000000..5549298 --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/config/security/local/AuthenticationConfiguration.java @@ -0,0 +1,72 @@ +package com.odeyalo.sonata.playlists.config.security.local; + +import com.odeyalo.suite.security.auth.TokenAuthenticationManager; +import com.odeyalo.suite.security.auth.token.AccessTokenMetadata; +import com.odeyalo.suite.security.auth.token.ReactiveAccessTokenValidator; +import com.odeyalo.suite.security.auth.token.ValidatedAccessToken; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import reactor.core.publisher.Mono; + +import java.time.Instant; +import java.util.Map; + +@Configuration +@Profile("local") +public class AuthenticationConfiguration { + + @Bean + @Primary + public ReactiveAuthenticationManager reactiveAuthenticationManager() { + return new TokenAuthenticationManager( + new LocalDevelopmentAccessTokenValidator() + ); + } + + private static class LocalDevelopmentAccessTokenValidator implements ReactiveAccessTokenValidator { + private final Map tokensCache = Map.of( + "token1", ValidatedAccessToken.valid( + AccessTokenMetadata.of("123", new String[]{"read", "write"}, + Instant.now().getEpochSecond(), + Instant.now().plusSeconds(600).getEpochSecond() + )), + "token1_2", ValidatedAccessToken.valid( + AccessTokenMetadata.of("123", new String[]{"read", "write", "playlist"}, + Instant.now().getEpochSecond(), + Instant.now().plusSeconds(600).getEpochSecond() + )), + "token2", ValidatedAccessToken.valid( + AccessTokenMetadata.of("miku", new String[]{"read", "write", "playlist"}, + Instant.now().getEpochSecond(), + Instant.now().plusSeconds(600).getEpochSecond() + )) + ); + + private final Logger logger = LoggerFactory.getLogger(LocalDevelopmentAccessTokenValidator.class); + + public LocalDevelopmentAccessTokenValidator() { + logger.info("Using local 'DEV' mode, cache of available access tokens to use. Token that is not exist in cache will cause HTTP 401 UNAUTHORIZED status"); + + tokensCache.forEach((key, value) -> { + final AccessTokenMetadata tokenMetadata = value.getToken(); + logger.info("Generated access token: '{}' for user 'ID({})' with following scopes: '({})', expire after: {} seconds", key, tokenMetadata.getUserId(), tokenMetadata.getScopes(), tokenMetadata.getExpiresIn() - Instant.now().getEpochSecond()); + }); + + } + + @Override + @NotNull + public Mono validateToken(@NotNull final String tokenValue) { + + final ValidatedAccessToken validatedAccessToken = tokensCache.getOrDefault(tokenValue, ValidatedAccessToken.invalid()); + + return Mono.just(validatedAccessToken); + } + } +} diff --git a/src/main/java/com/odeyalo/sonata/playlists/entity/GeneratedPlaylistEntity.java b/src/main/java/com/odeyalo/sonata/playlists/entity/GeneratedPlaylistEntity.java new file mode 100644 index 0000000..0de28fe --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/entity/GeneratedPlaylistEntity.java @@ -0,0 +1,36 @@ +package com.odeyalo.sonata.playlists.entity; + +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.GeneratedPlaylistType; +import lombok.*; +import lombok.experimental.FieldDefaults; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +import java.time.Instant; + +@Data +@AllArgsConstructor(staticName = "of") +@NoArgsConstructor +@Builder +@FieldDefaults(level = AccessLevel.PRIVATE) +@Table(name = "generated_playlists") +public class GeneratedPlaylistEntity { + @Id + @Nullable + Long id; + @NotNull + @Column("playlist_id") + String playlistId; + @NotNull + @Column("generated_for") + String userId; + @Nullable + @Column("playlist_type") + GeneratedPlaylistType generatedPlaylistType; + @NotNull + @Column("generated_at") + Instant generatedAt; +} diff --git a/src/main/java/com/odeyalo/sonata/playlists/repository/GeneratedPlaylistRepository.java b/src/main/java/com/odeyalo/sonata/playlists/repository/GeneratedPlaylistRepository.java new file mode 100644 index 0000000..a839aad --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/repository/GeneratedPlaylistRepository.java @@ -0,0 +1,38 @@ +package com.odeyalo.sonata.playlists.repository; + +import com.odeyalo.sonata.playlists.entity.GeneratedPlaylistEntity; +import com.odeyalo.sonata.playlists.model.Playlist; +import org.jetbrains.annotations.NotNull; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Base interface to work with {@link com.odeyalo.sonata.playlists.entity.GeneratedPlaylistEntity} + */ +public interface GeneratedPlaylistRepository { + /** + * Save or update the given GeneratedPlaylistEntity to the repository + * + * @param playlist - GeneratedPlaylistEntity to save + * @return saved GeneratedPlaylistEntity + */ + @NotNull + Mono save(@NotNull GeneratedPlaylistEntity playlist); + + /** + * Search for the GeneratedPlaylistEntity for the user + * + * @param userId - user id to use for search + * @return {@link Flux} with found {@link GeneratedPlaylistEntity} + */ + @NotNull + Flux findByGeneratedFor(@NotNull String userId); + + /** + * Clear the repository. Commonly used in tests + * + * @return - empty mono + */ + @NotNull + Mono clear(); +} diff --git a/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/R2dbcGeneratedPlaylistRepository.java b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/R2dbcGeneratedPlaylistRepository.java new file mode 100644 index 0000000..48d86a6 --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/R2dbcGeneratedPlaylistRepository.java @@ -0,0 +1,36 @@ +package com.odeyalo.sonata.playlists.repository.r2dbc; + +import com.odeyalo.sonata.playlists.entity.GeneratedPlaylistEntity; +import com.odeyalo.sonata.playlists.repository.GeneratedPlaylistRepository; +import com.odeyalo.sonata.playlists.repository.r2dbc.delegate.R2dbcGeneratedPlaylistRepositoryDelegate; +import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Component +public final class R2dbcGeneratedPlaylistRepository implements GeneratedPlaylistRepository { + private final R2dbcGeneratedPlaylistRepositoryDelegate delegate; + + public R2dbcGeneratedPlaylistRepository(final R2dbcGeneratedPlaylistRepositoryDelegate delegate) { + this.delegate = delegate; + } + + @Override + @NotNull + public Mono save(@NotNull final GeneratedPlaylistEntity playlist) { + return delegate.save(playlist); + } + + @Override + @NotNull + public Flux findByGeneratedFor(@NotNull final String userId) { + return delegate.findByUserId(userId); + } + + @Override + @NotNull + public Mono clear() { + return delegate.deleteAll(); + } +} diff --git a/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/R2dbcImageRepository.java b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/R2dbcImageRepository.java index 0dd0a8e..cd1a34a 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/R2dbcImageRepository.java +++ b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/R2dbcImageRepository.java @@ -1,6 +1,9 @@ package com.odeyalo.sonata.playlists.repository.r2dbc; import com.odeyalo.sonata.playlists.entity.ImageEntity; +import org.jetbrains.annotations.NotNull; +import org.springframework.data.r2dbc.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.data.repository.reactive.ReactiveCrudRepository; import org.springframework.stereotype.Repository; import reactor.core.publisher.Mono; @@ -9,4 +12,15 @@ public interface R2dbcImageRepository extends ReactiveCrudRepository { Mono findByUrl(String url); + + @Query(""" + INSERT INTO images (url, width, height) + VALUES (:#{#image.url}, :#{#image.width}, :#{#image.height}) + ON CONFLICT (url) DO UPDATE + SET url = excluded.url + RETURNING * + """) + @NotNull + Mono upsert(@NotNull @Param("image") ImageEntity image); + } diff --git a/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/callback/write/SavePlaylistImageOnMissingAfterSaveCallback.java b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/callback/write/SavePlaylistImageOnMissingAfterSaveCallback.java index 9f3cb73..7b60c49 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/callback/write/SavePlaylistImageOnMissingAfterSaveCallback.java +++ b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/callback/write/SavePlaylistImageOnMissingAfterSaveCallback.java @@ -1,11 +1,10 @@ package com.odeyalo.sonata.playlists.repository.r2dbc.callback.write; -import com.odeyalo.sonata.playlists.entity.PlaylistImage; import com.odeyalo.sonata.playlists.entity.ImageEntity; import com.odeyalo.sonata.playlists.entity.PlaylistEntity; +import com.odeyalo.sonata.playlists.entity.PlaylistImage; import com.odeyalo.sonata.playlists.repository.PlaylistImagesRepository; import com.odeyalo.sonata.playlists.repository.r2dbc.R2dbcImageRepository; -import org.apache.commons.lang3.BooleanUtils; import org.jetbrains.annotations.NotNull; import org.reactivestreams.Publisher; import org.springframework.context.annotation.Lazy; @@ -40,24 +39,24 @@ public Publisher onAfterSave(@NotNull final PlaylistEntity entit @NotNull private Mono> saveImages(PlaylistEntity playlist) { List images = playlist.getImages(); + return Flux.fromIterable(images) - .filterWhen(this::isImageNotExist) - .flatMap(entity -> playlistImagesRepository.deleteAllByPlaylistId(playlist.getId()).thenReturn(entity)) - .flatMap(r2DbcImageRepository::save) - .flatMap(imageEntity -> buildAndSave(playlist, imageEntity)) + .flatMap(image -> { + + return playlistImagesRepository.deleteAllByPlaylistId(playlist.getId()) + .then(Mono.defer(() -> r2DbcImageRepository.upsert(image) + .flatMap(imageEntity -> buildAndSave(playlist, imageEntity)))); + }) .collectList(); } @NotNull - private Mono buildAndSave(PlaylistEntity parent, ImageEntity imageEntity) { - PlaylistImage imageToSave = PlaylistImage.builder().imageId(imageEntity.getId()).playlistId(parent.getId()).build(); + private Mono buildAndSave(@NotNull final PlaylistEntity parent, + @NotNull final ImageEntity imageEntity) { + final PlaylistImage imageToSave = PlaylistImage.builder() + .imageId(imageEntity.getId()) + .playlistId(parent.getId()) + .build(); return playlistImagesRepository.save(imageToSave); } - - @NotNull - private Mono isImageNotExist(ImageEntity entity) { - return r2DbcImageRepository.findByUrl(entity.getUrl()) - .hasElement() - .map(BooleanUtils::negate); - } } diff --git a/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/delegate/R2dbcGeneratedPlaylistRepositoryDelegate.java b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/delegate/R2dbcGeneratedPlaylistRepositoryDelegate.java new file mode 100644 index 0000000..2b5d358 --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/repository/r2dbc/delegate/R2dbcGeneratedPlaylistRepositoryDelegate.java @@ -0,0 +1,15 @@ +package com.odeyalo.sonata.playlists.repository.r2dbc.delegate; + +import com.odeyalo.sonata.playlists.entity.GeneratedPlaylistEntity; +import org.jetbrains.annotations.NotNull; +import org.springframework.data.r2dbc.repository.R2dbcRepository; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; + +@Repository +public interface R2dbcGeneratedPlaylistRepositoryDelegate extends R2dbcRepository { + + @NotNull + Flux findByUserId(@NotNull String userId); + +} diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/DefaultPlaylistService.java b/src/main/java/com/odeyalo/sonata/playlists/service/DefaultPlaylistService.java index 8520077..e39a6dd 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/service/DefaultPlaylistService.java +++ b/src/main/java/com/odeyalo/sonata/playlists/service/DefaultPlaylistService.java @@ -41,6 +41,15 @@ public Mono create(@NotNull final CreatePlaylistInfo playlistInfo, .map(it -> playlist); } + @Override + @NotNull + public Mono save(@NotNull final Playlist playlist) { + final PlaylistEntity playlistEntity = playlistEntityFactory.create(playlist); + + return playlistRepository.save(playlistEntity) + .map(it -> playlist); + } + @Override @NotNull public Mono update(@NotNull final Playlist playlist) { diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/InMemoryPlaylistService.java b/src/main/java/com/odeyalo/sonata/playlists/service/InMemoryPlaylistService.java index 569cbda..6ab05f4 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/service/InMemoryPlaylistService.java +++ b/src/main/java/com/odeyalo/sonata/playlists/service/InMemoryPlaylistService.java @@ -38,6 +38,12 @@ public Mono create(@NotNull final CreatePlaylistInfo playlistInfo, return Mono.fromCallable(() -> doSave(playlist)); } + @Override + @NotNull + public Mono save(@NotNull final Playlist playlist) { + return Mono.fromCallable(() -> doSave(playlist)); + } + @Override @NotNull public Mono update(@NotNull final Playlist playlist) { diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/PlaylistService.java b/src/main/java/com/odeyalo/sonata/playlists/service/PlaylistService.java index beccaa8..4f7d13e 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/service/PlaylistService.java +++ b/src/main/java/com/odeyalo/sonata/playlists/service/PlaylistService.java @@ -23,6 +23,9 @@ public interface PlaylistService extends PlaylistLoader { Mono create(@NotNull CreatePlaylistInfo playlistInfo, @NotNull PlaylistOwner owner); + @NotNull + Mono save(@NotNull Playlist playlist); + /** * Update existing playlist with new values * diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/generation/GeneratedPlaylist.java b/src/main/java/com/odeyalo/sonata/playlists/service/generation/GeneratedPlaylist.java new file mode 100644 index 0000000..062753c --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/service/generation/GeneratedPlaylist.java @@ -0,0 +1,27 @@ +package com.odeyalo.sonata.playlists.service.generation; + +import com.odeyalo.sonata.playlists.model.PlayableItemType; +import com.odeyalo.sonata.playlists.model.Playlist; +import com.odeyalo.sonata.playlists.model.PlaylistItem; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * A meta that was auto-generated by Sonata + * @param meta - generated meta info + * @param tracks - tracks in this meta + * @param generatedFor - user id for which this playlist was generated + */ +public record GeneratedPlaylist( + @NotNull Playlist meta, + @NotNull List tracks, + @NotNull String generatedFor +) { + + public record Item( + @NotNull String id, + @NotNull PlayableItemType type, + int index + ) {} +} diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManager.java b/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManager.java new file mode 100644 index 0000000..e31a3ff --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManager.java @@ -0,0 +1,93 @@ +package com.odeyalo.sonata.playlists.service.generation; + +import com.odeyalo.sonata.common.context.ContextUri; +import com.odeyalo.sonata.playlists.entity.GeneratedPlaylistEntity; +import com.odeyalo.sonata.playlists.model.*; +import com.odeyalo.sonata.playlists.repository.GeneratedPlaylistRepository; +import com.odeyalo.sonata.playlists.service.PlaylistService; +import com.odeyalo.sonata.playlists.service.tracks.PlaylistItemsService; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistTracksGeneratedPayload; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; + +import java.time.Instant; +import java.util.List; + +@Service +public class PlaylistGenerationManager { + private final PlaylistGenerationService generationService; + private final PlaylistItemsService playlistItemsService; + private final PlaylistService playlistService; + private final GeneratedPlaylistRepository generatedPlaylistRepository; + + private final Logger logger = LoggerFactory.getLogger(PlaylistGenerationManager.class); + + private static final PlaylistCollaborator SONATA_COLLABORATOR = PlaylistCollaborator.builder() + .id("sonata") + .displayName("Sonata") + .contextUri("sonata:user:sonata") + .type(EntityType.USER) + .build(); + + public PlaylistGenerationManager(final PlaylistGenerationService generationService, + final PlaylistService playlistService, + final PlaylistItemsService playlistItemsService, + final GeneratedPlaylistRepository generatedPlaylistRepository) { + this.generationService = generationService; + this.playlistItemsService = playlistItemsService; + this.playlistService = playlistService; + this.generatedPlaylistRepository = generatedPlaylistRepository; + } + + @NotNull + @Transactional(isolation = Isolation.READ_UNCOMMITTED) + public Mono handle(@NotNull final PlaylistImagesGeneratedEvent event) { + final PlaylistTracksGeneratedPayload parentEvent = event.getBody().getParent().getParent(); + logger.info("Starting playlist generation for user {} with type: {}", parentEvent.getUserId(), event.getType()); + + return generationService.generate(event) + .flatMap(generatedPlaylist -> { + return playlistService.save(generatedPlaylist.meta()) + .flatMap(playlist -> { + return saveGeneratedEvent(event, generatedPlaylist, playlist); + }).then(Mono.defer(() -> Mono.fromRunnable(() -> logger.info("Completed playlist generation for user: {}", parentEvent.getUserId())))); + }); + } + + @NotNull + private Mono saveGeneratedEvent(@NotNull final PlaylistImagesGeneratedEvent event, + @NotNull final GeneratedPlaylist generatedPlaylist, + @NotNull final Playlist playlist) { + final List items = getTracks(generatedPlaylist, playlist); + + return playlistItemsService.insertAll(items) + .then(Mono.defer(() -> generatedPlaylistRepository.save( + GeneratedPlaylistEntity.builder() + .userId(event.getBody().getParent().getParent().getUserId()) + .playlistId(playlist.getId().value()) + .generatedAt(Instant.now()) + .generatedPlaylistType(event.getType()) + .build() + ))) + .then(); + } + + @NotNull + private static List getTracks(final GeneratedPlaylist generatedPlaylist, + final Playlist playlist) { + List tracks = generatedPlaylist.tracks(); + + return tracks.stream().map(item -> new SimplePlaylistItem( + playlist.getId(), + SONATA_COLLABORATOR, + ContextUri.forTrack(item.id()), + PlaylistItemPosition.at(item.index()) + )).toList(); + } +} diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationService.java b/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationService.java new file mode 100644 index 0000000..dd5bd28 --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationService.java @@ -0,0 +1,68 @@ +package com.odeyalo.sonata.playlists.service.generation; + +import com.odeyalo.sonata.playlists.model.*; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.GeneratedTrack; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistImagesGeneratedPayload; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistMetaGeneratedPayload; +import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.util.List; + +@Service +public final class PlaylistGenerationService { + + private static final PlaylistOwner SONATA_ACCOUNT = PlaylistOwner.builder() + .id("sonata") + .displayName("Sonata") + .entityType(EntityType.USER) + .build(); + + @NotNull + public Mono generate(@NotNull final PlaylistImagesGeneratedEvent event) { + + final PlaylistImagesGeneratedPayload body = event.getBody(); + + + return Mono.just( + new GeneratedPlaylist( + baseInfoPlaylist(body), + getPlaylistItems(body), + event.getBody().getParent().getParent().getUserId() + )); + } + + @NotNull + private static Playlist baseInfoPlaylist(@NotNull final PlaylistImagesGeneratedPayload body) { + final Playlist.PlaylistBuilder playlistBuilder = Playlist.builder(); + final PlaylistMetaGeneratedPayload meta = body.getParent(); + + final PlaylistId playlistId = PlaylistId.random(); + + final List images = body.getImages() + .stream() + .map(image -> Image.of(image.getUrl(), image.getWidth(), image.getHeight())) + .toList(); + + return playlistBuilder + .id(playlistId) + .name(meta.getMeta().getName()) + .description(meta.getMeta().getDescription()) + .contextUri(playlistId.asContextUri()) + .playlistType(PlaylistType.PUBLIC) + .images(Images.of(images)) + .playlistOwner(SONATA_ACCOUNT) + .build(); + } + + @NotNull + private static List getPlaylistItems(@NotNull final PlaylistImagesGeneratedPayload body) { + final List tracks = body.getParent().getParent().getTracks(); + + return tracks.stream().map(track -> new GeneratedPlaylist.Item( + track.getTrackId(), PlayableItemType.TRACK, track.getIndex() + )).toList(); + } +} diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/generation/consumer/MessageConsumer.java b/src/main/java/com/odeyalo/sonata/playlists/service/generation/consumer/MessageConsumer.java new file mode 100644 index 0000000..0d152c9 --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/playlists/service/generation/consumer/MessageConsumer.java @@ -0,0 +1,35 @@ +package com.odeyalo.sonata.playlists.service.generation.consumer; + +import com.odeyalo.sonata.playlists.service.generation.PlaylistGenerationManager; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; +import jakarta.annotation.PostConstruct; +import lombok.extern.log4j.Log4j2; +import org.springframework.kafka.core.reactive.ReactiveKafkaConsumerTemplate; +import org.springframework.stereotype.Component; +import reactor.core.Disposable; +import reactor.core.scheduler.Schedulers; +import reactor.kafka.receiver.ReceiverRecord; + +@Component +@Log4j2 +public final class MessageConsumer { + private final ReactiveKafkaConsumerTemplate reactiveKafkaConsumerTemplate; + private final PlaylistGenerationManager playlistGenerationManager; + + // TODO: save the received playlist to database + public MessageConsumer(final ReactiveKafkaConsumerTemplate reactiveKafkaConsumerTemplate, + final PlaylistGenerationManager playlistGenerationManager) { + this.reactiveKafkaConsumerTemplate = reactiveKafkaConsumerTemplate; + this.playlistGenerationManager = playlistGenerationManager; + } + + @PostConstruct + public Disposable consumeRecord() { + return reactiveKafkaConsumerTemplate.receive() + .map(ReceiverRecord::value) + .flatMap(playlistGenerationManager::handle) + .doOnError(error -> log.error("Consumer error: {}", error.getMessage())) + .subscribeOn(Schedulers.boundedElastic()) + .subscribe(); + } +} diff --git a/src/main/java/com/odeyalo/sonata/playlists/service/tracks/PlaylistItemsService.java b/src/main/java/com/odeyalo/sonata/playlists/service/tracks/PlaylistItemsService.java index c444c94..db3d0dc 100644 --- a/src/main/java/com/odeyalo/sonata/playlists/service/tracks/PlaylistItemsService.java +++ b/src/main/java/com/odeyalo/sonata/playlists/service/tracks/PlaylistItemsService.java @@ -14,6 +14,8 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.util.List; + /** * Used to work with playlist items, middleware between repository but returns a domain models instead of entities */ @@ -57,6 +59,13 @@ public Mono insertItemAtSpecificPosition(@NotNull final SimplePlaylistItem .then(saveItem(playlistItem)); } + @NotNull + public Mono insertAll(@NotNull final List items) { + return Flux.fromIterable(items) + .flatMap(this::saveItem) + .then(); + } + @NotNull private Mono saveItem(@NotNull final SimplePlaylistItem playlistItem) { diff --git a/src/main/resources/db/migration/V12__default_sonata_user.sql b/src/main/resources/db/migration/V12__default_sonata_user.sql new file mode 100644 index 0000000..4b83cd0 --- /dev/null +++ b/src/main/resources/db/migration/V12__default_sonata_user.sql @@ -0,0 +1,5 @@ +INSERT INTO playlist_collaborators(public_id, display_name, entity_type, context_uri) +VALUES ('sonata', 'Sonata', 'USER', 'sonata:user:sonata'); + +INSERT INTO playlist_owner(public_id, display_name, entity_type) +VALUES ('sonata', 'Sonata', 'USER'); \ No newline at end of file diff --git a/src/main/resources/db/migration/V13__create_generated_playlists_table.sql b/src/main/resources/db/migration/V13__create_generated_playlists_table.sql new file mode 100644 index 0000000..722b5b1 --- /dev/null +++ b/src/main/resources/db/migration/V13__create_generated_playlists_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE generated_playlists +( + id SERIAL PRIMARY KEY, +-- A public User ID for which this playlist was generated for + generated_for VARCHAR(30) NOT NULL, +-- We know that public playlist ID is always unique and can't be changed. +-- Use public ID instead of internal primary key for performance reasons, +-- to skip non-mandatory JOIN and SELECT queries, and additional INSERT as well + playlist_id VARCHAR(255) NOT NULL REFERENCES playlists(public_id), + generated_at timestamp NOT NULL, + playlist_type VARCHAR(100) +); diff --git a/src/test/java/com/odeyalo/sonata/playlists/repository/r2dbc/delegate/R2dbcGeneratedPlaylistRepositoryDelegateTest.java b/src/test/java/com/odeyalo/sonata/playlists/repository/r2dbc/delegate/R2dbcGeneratedPlaylistRepositoryDelegateTest.java new file mode 100644 index 0000000..394fc9e --- /dev/null +++ b/src/test/java/com/odeyalo/sonata/playlists/repository/r2dbc/delegate/R2dbcGeneratedPlaylistRepositoryDelegateTest.java @@ -0,0 +1,82 @@ +package com.odeyalo.sonata.playlists.repository.r2dbc.delegate; + +import com.odeyalo.sonata.playlists.entity.GeneratedPlaylistEntity; +import com.odeyalo.sonata.playlists.entity.PlaylistEntity; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.GeneratedPlaylistType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import testing.faker.PlaylistEntityFaker; +import testing.spring.R2dbcCallbacksConfiguration; + +import java.util.List; + +import static java.time.Instant.now; +import static org.assertj.core.api.Assertions.assertThat; + +@DataR2dbcTest +@ActiveProfiles("test") +@Import(R2dbcCallbacksConfiguration.class) +class R2dbcGeneratedPlaylistRepositoryDelegateTest { + + @Autowired + R2dbcPlaylistRepositoryDelegate r2dbcPlaylistRepositoryDelegate; + + @Autowired + R2dbcGeneratedPlaylistRepositoryDelegate testable; + + public static final String PLAYLIST_ID_1 = "mikuuu"; + public static final String PLAYLIST_ID_2 = "nakano"; + + @BeforeEach + void setUp() { + final PlaylistEntity playlist1 = PlaylistEntityFaker.createWithNoId() + .setPublicId(PLAYLIST_ID_1) + .get(); + + final PlaylistEntity playlist2 = PlaylistEntityFaker.createWithNoId() + .setPublicId(PLAYLIST_ID_2) + .get(); + + r2dbcPlaylistRepositoryDelegate.save(playlist1).block(); + r2dbcPlaylistRepositoryDelegate.save(playlist2).block(); + } + + @AfterEach + void tearDown() { + testable.deleteAll().block(); + r2dbcPlaylistRepositoryDelegate.deleteAll().block(); + } + + @Test + void shouldFindTheGeneratedPlaylistForUser() { + testable.save( + GeneratedPlaylistEntity.builder() + .playlistId(PLAYLIST_ID_1) + .userId("odeyalo") + .generatedPlaylistType(GeneratedPlaylistType.ON_REPEAT) + .generatedAt(now()) + .build() + ).block(); + + testable.save( + GeneratedPlaylistEntity.builder() + .playlistId(PLAYLIST_ID_2) + .userId("odeyalo") + .generatedPlaylistType(GeneratedPlaylistType.ON_REPEAT) + .generatedAt(now()) + .build() + ).block(); + + + final List result = testable.findByUserId("odeyalo").collectList().block(); + + assertThat(result) + .extracting(GeneratedPlaylistEntity::getPlaylistId) + .containsExactlyInAnyOrder(PLAYLIST_ID_1, PLAYLIST_ID_2); + } +} \ No newline at end of file diff --git a/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManagerTest.java b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManagerTest.java new file mode 100644 index 0000000..b55a1f4 --- /dev/null +++ b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationManagerTest.java @@ -0,0 +1,84 @@ +package com.odeyalo.sonata.playlists.service.generation; + +import com.odeyalo.sonata.playlists.entity.GeneratedPlaylistEntity; +import com.odeyalo.sonata.playlists.entity.PlaylistEntity; +import com.odeyalo.sonata.playlists.entity.PlaylistItemEntity; +import com.odeyalo.sonata.playlists.model.PlaylistId; +import com.odeyalo.sonata.playlists.model.PlaylistType; +import com.odeyalo.sonata.playlists.repository.GeneratedPlaylistRepository; +import com.odeyalo.sonata.playlists.repository.PlaylistItemsRepository; +import com.odeyalo.sonata.playlists.repository.PlaylistRepository; +import com.odeyalo.sonata.playlists.support.pagination.OffsetBasedPageRequest; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.GeneratedPlaylistType; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.GeneratedTrack; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistImagesGeneratedPayload; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistMetaGeneratedPayload; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistTracksGeneratedPayload; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import testing.core.AbstractIntegrationTest; + +import java.util.Comparator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class PlaylistGenerationManagerTest extends AbstractIntegrationTest { + + @Autowired + PlaylistGenerationManager testable; + + @Autowired + GeneratedPlaylistRepository generatedPlaylistRepository; + + @Autowired + PlaylistItemsRepository playlistItemsRepository; + + @Autowired + PlaylistRepository playlistRepository; + + @Test + void shouldCreatePlaylist() { + + final var event = new PlaylistImagesGeneratedEvent(new PlaylistImagesGeneratedPayload( + new PlaylistMetaGeneratedPayload( + new PlaylistTracksGeneratedPayload("u123", List.of( + new GeneratedTrack("1", 0), + new GeneratedTrack("2", 1), + new GeneratedTrack("3", 2) + )), new PlaylistMetaGeneratedPayload.Meta("Cool name", "Cool desc")), + List.of( + new PlaylistImagesGeneratedPayload.Image("https://cdn.sonata.com/i/c/123") + ) + ), GeneratedPlaylistType.ON_REPEAT); + + testable.handle(event).block(); + + GeneratedPlaylistEntity generatedPlaylist = generatedPlaylistRepository.findByGeneratedFor("u123").blockFirst(); + + assertThat(generatedPlaylist).isNotNull(); + assertThat(generatedPlaylist.getGeneratedPlaylistType()).isEqualTo(GeneratedPlaylistType.ON_REPEAT); + + String playlistId = generatedPlaylist.getPlaylistId(); + + PlaylistEntity playlist = playlistRepository.findByPublicId(PlaylistId.of(playlistId)).block(); + + assertThat(playlist).isNotNull(); + assertThat(playlist.getPlaylistName()).isEqualTo("Cool name"); + assertThat(playlist.getPlaylistDescription()).isEqualTo("Cool desc"); + assertThat(playlist.getPlaylistType()).isEqualTo(PlaylistType.PUBLIC); + + List items = playlistItemsRepository.findAllByPlaylistId(playlistId, new OffsetBasedPageRequest(0, 100)) + .sort(Comparator.comparingInt(PlaylistItemEntity::getIndex)) + .collectList() + .block(); + + assertThat(items).isNotEmpty(); + + assertThat(items).map(e -> e.getItem().getPublicId()).containsExactly( + "1", "2", "3" + ); + } + +} diff --git a/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java new file mode 100644 index 0000000..f855b7f --- /dev/null +++ b/src/test/java/com/odeyalo/sonata/playlists/service/generation/PlaylistGenerationServiceTest.java @@ -0,0 +1,162 @@ +package com.odeyalo.sonata.playlists.service.generation; + +import com.odeyalo.sonata.playlists.model.*; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.GeneratedPlaylistType; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.PlaylistImagesGeneratedEvent; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.GeneratedTrack; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistImagesGeneratedPayload; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistMetaGeneratedPayload; +import com.odeyalo.sonata.suite.brokers.events.playlist.gen.payload.PlaylistTracksGeneratedPayload; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlaylistGenerationServiceTest { + + @Test + void shouldSetThePlaylistMetadata() { + // given + final PlaylistImagesGeneratedEvent event = new PlaylistImagesGeneratedEvent( + new PlaylistImagesGeneratedPayload(new PlaylistMetaGeneratedPayload( + new PlaylistTracksGeneratedPayload("123", List.of( + new GeneratedTrack("1", 0), + new GeneratedTrack("2", 1), + new GeneratedTrack("3", 2) + )), + new PlaylistMetaGeneratedPayload.Meta("On Repeat", "Songs you love the most") + ), List.of(new PlaylistImagesGeneratedPayload.Image("https://cdn.sonata.com/i/c/abc123"))), + GeneratedPlaylistType.ON_REPEAT); + + final PlaylistGenerationService testable = new PlaylistGenerationService(); + + // when + GeneratedPlaylist generatedPlaylist = testable.generate(event).block(); + assertThat(generatedPlaylist).isNotNull(); + + final Playlist playlistInfo = generatedPlaylist.meta(); + + assertThat(playlistInfo.getName()).isEqualTo("On Repeat"); + assertThat(playlistInfo.getDescription()).isEqualTo("Songs you love the most"); + assertThat(playlistInfo.getPlaylistType()).isEqualTo(PlaylistType.PUBLIC); + assertThat(playlistInfo.getContextUri().asString()).isEqualTo("sonata:playlist:" + playlistInfo.getId().value()); + } + + @Test + void shouldSetThePlaylistImages() { + // given + final PlaylistImagesGeneratedEvent event = new PlaylistImagesGeneratedEvent( + new PlaylistImagesGeneratedPayload(new PlaylistMetaGeneratedPayload( + new PlaylistTracksGeneratedPayload("123", List.of( + new GeneratedTrack("1", 0), + new GeneratedTrack("2", 1), + new GeneratedTrack("3", 2) + )), + new PlaylistMetaGeneratedPayload.Meta("On Repeat", "Songs you love the most") + ), List.of( + new PlaylistImagesGeneratedPayload.Image("https://cdn.sonata.com/i/c/abc123", 50, 50), + new PlaylistImagesGeneratedPayload.Image("https://cdn.sonata.com/i/c/abc124", 300, 350), + new PlaylistImagesGeneratedPayload.Image("https://cdn.sonata.com/i/c/abc125", 600, 600) + )), GeneratedPlaylistType.ON_REPEAT); + + final PlaylistGenerationService testable = new PlaylistGenerationService(); + + // when + GeneratedPlaylist generatedPlaylist = testable.generate(event).block(); + assertThat(generatedPlaylist).isNotNull(); + + assertThat(generatedPlaylist.meta().getImages()).containsExactlyInAnyOrder( + Image.of("https://cdn.sonata.com/i/c/abc123", 50, 50), + Image.of("https://cdn.sonata.com/i/c/abc124", 350, 300), + Image.of("https://cdn.sonata.com/i/c/abc125", 600, 600) + ); + } + + @Test + void shouldSetThePlaylistOwner() { + // given + final PlaylistImagesGeneratedEvent event = new PlaylistImagesGeneratedEvent( + new PlaylistImagesGeneratedPayload(new PlaylistMetaGeneratedPayload( + new PlaylistTracksGeneratedPayload("123", List.of( + new GeneratedTrack("1", 0), + new GeneratedTrack("2", 1), + new GeneratedTrack("3", 2) + )), + new PlaylistMetaGeneratedPayload.Meta("On Repeat", "Songs you love the most") + ), List.of( + new PlaylistImagesGeneratedPayload.Image("https://cdn.sonata.com/i/c/abc123", 50, 50) + )), GeneratedPlaylistType.ON_REPEAT); + + final PlaylistGenerationService testable = new PlaylistGenerationService(); + + // when + GeneratedPlaylist generatedPlaylist = testable.generate(event).block(); + assertThat(generatedPlaylist).isNotNull(); + + final PlaylistOwner playlistOwner = generatedPlaylist.meta().getPlaylistOwner(); + + assertThat(playlistOwner.getId()).isEqualTo("sonata"); + assertThat(playlistOwner.getDisplayName()).isEqualTo("Sonata"); + assertThat(playlistOwner.getEntityType()).isEqualTo(EntityType.USER); + } + + @Test + void shouldSetTheTracks() { + // given + final PlaylistImagesGeneratedEvent event = new PlaylistImagesGeneratedEvent( + new PlaylistImagesGeneratedPayload(new PlaylistMetaGeneratedPayload( + new PlaylistTracksGeneratedPayload("123", List.of( + new GeneratedTrack("1", 0), + new GeneratedTrack("2", 1), + new GeneratedTrack("3", 2) + )), + new PlaylistMetaGeneratedPayload.Meta("On Repeat", "Songs you love the most") + ), List.of( + new PlaylistImagesGeneratedPayload.Image("https://cdn.sonata.com/i/c/abc123", 50, 50) + )), GeneratedPlaylistType.ON_REPEAT); + + final PlaylistGenerationService testable = new PlaylistGenerationService(); + + // when + GeneratedPlaylist generatedPlaylist = testable.generate(event).block(); + + // then + assertThat(generatedPlaylist).isNotNull(); + + final List tracks = generatedPlaylist.tracks(); + assertThat(tracks).containsExactlyInAnyOrder( + new GeneratedPlaylist.Item("1", PlayableItemType.TRACK, 0), + new GeneratedPlaylist.Item("2", PlayableItemType.TRACK, 1), + new GeneratedPlaylist.Item("3", PlayableItemType.TRACK, 2) + ); + } + + @Test + void shouldSetUserForWhichTrackWasGenerated() { + // given + final PlaylistImagesGeneratedEvent event = new PlaylistImagesGeneratedEvent( + new PlaylistImagesGeneratedPayload(new PlaylistMetaGeneratedPayload( + new PlaylistTracksGeneratedPayload("123", List.of( + new GeneratedTrack("1", 0), + new GeneratedTrack("2", 1), + new GeneratedTrack("3", 2) + )), + new PlaylistMetaGeneratedPayload.Meta("On Repeat", "Songs you love the most") + ), List.of( + new PlaylistImagesGeneratedPayload.Image("https://cdn.sonata.com/i/c/abc123", 50, 50) + )), GeneratedPlaylistType.ON_REPEAT); + + final PlaylistGenerationService testable = new PlaylistGenerationService(); + + // when + GeneratedPlaylist generatedPlaylist = testable.generate(event).block(); + + // then + assertThat(generatedPlaylist).isNotNull(); + + assertThat(generatedPlaylist.generatedFor()).isEqualTo("123"); + } +} \ No newline at end of file diff --git a/src/test/java/testing/core/AbstractIntegrationTest.java b/src/test/java/testing/core/AbstractIntegrationTest.java new file mode 100644 index 0000000..0c061e8 --- /dev/null +++ b/src/test/java/testing/core/AbstractIntegrationTest.java @@ -0,0 +1,38 @@ +package testing.core; + + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestInstance; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; + +@SpringBootTest +@ActiveProfiles("test") +public abstract class AbstractIntegrationTest { + + static PostgreSQLContainer postgres = new PostgreSQLContainer<>( + "postgres:15.2" + ); + + @BeforeAll + static void beforeAll() { + postgres.start(); + } + + @DynamicPropertySource + static void registerDynamicProperties(DynamicPropertyRegistry registry) { + registry.add("spring.r2dbc.url", () -> "r2dbc:postgresql://" + + postgres.getHost() + ":" + postgres.getFirstMappedPort() + + "/" + postgres.getDatabaseName()); + registry.add("spring.r2dbc.username", () -> postgres.getUsername()); + registry.add("spring.r2dbc.password", () -> postgres.getPassword()); + // flyway + registry.add("spring.flyway.enabled", () -> true); + registry.add("spring.flyway.user", () -> postgres.getUsername()); + registry.add("spring.flyway.password", () -> postgres.getPassword()); + registry.add("spring.flyway.url", () -> postgres.getJdbcUrl()); + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index b21aee2..04d1f48 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -5,6 +5,9 @@ spring.contracts.repository.root=git://https://github.com/Project-Sonata/Sonata- spring.main.web-application-type=reactive eureka.client.enabled=false +spring.datasource.hikari.minimum-idle=5 +spring.datasource.hikari.maximum-pool-size=2 + logging.level.io.r2dbc.postgresql.QUERY=DEBUG logging.level.io.r2dbc.postgresql.PARAM=DEBUG logging.level.org.springframework.cloud.contract=debug