diff --git a/build.gradle b/build.gradle index d94509a5..8d2b6785 100644 --- a/build.gradle +++ b/build.gradle @@ -17,6 +17,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-webflux' testImplementation 'io.github.hakky54:logcaptor:2.9.2' + testImplementation 'io.github.oshai:kotlin-logging-jvm:6.0.9' testImplementation 'org.apache.commons:commons-compress:1.26.2' testImplementation 'org.assertj:assertj-core' testImplementation 'org.junit.jupiter:junit-jupiter' @@ -40,6 +41,23 @@ test { jvmArgs '-Xshare:off' } +tasks.withType(Test) { + ext.failedTests = [] + afterTest { descriptor, result -> + if (result.resultType == TestResult.ResultType.FAILURE) { + failedTests << ["${descriptor.className}::${descriptor.name}"] + } + } + afterSuite { suite, result -> + if (!suite.parent) { + if (!failedTests.empty) { + logger.lifecycle("Failed tests:") + failedTests.each { failedTest -> logger.lifecycle("${failedTest}") } + } + } + } +} + jacocoTestReport { reports { xml.required = true diff --git a/docker-compose.yaml b/docker-compose.yaml index 5beb303c..ceff7e75 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -7,6 +7,7 @@ services: restart: unless-stopped shm_size: 128mb environment: + POSTGRES_DB: loom-webflux POSTGRES_USER: postgres POSTGRES_PASSWORD: password ports: diff --git a/src/main/java/uk/gleissner/loomwebflux/LoomWebfluxApp.java b/src/main/java/uk/gleissner/loomwebflux/LoomWebfluxApp.java index fa455474..62c21d02 100644 --- a/src/main/java/uk/gleissner/loomwebflux/LoomWebfluxApp.java +++ b/src/main/java/uk/gleissner/loomwebflux/LoomWebfluxApp.java @@ -3,6 +3,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.context.ConfigurableApplicationContext; import org.springframework.scheduling.annotation.EnableScheduling; import uk.gleissner.loomwebflux.config.AppProperties; @@ -10,8 +11,9 @@ @ConfigurationPropertiesScan(basePackageClasses = AppProperties.class) @EnableScheduling public class LoomWebfluxApp { + static ConfigurableApplicationContext ctx; public static void main(String[] args) { - SpringApplication.run(LoomWebfluxApp.class, args); + ctx = SpringApplication.run(LoomWebfluxApp.class, args); } } diff --git a/src/main/java/uk/gleissner/loomwebflux/movie/MovieController.java b/src/main/java/uk/gleissner/loomwebflux/movie/MovieController.java index f87368fa..6f8bb7e2 100644 --- a/src/main/java/uk/gleissner/loomwebflux/movie/MovieController.java +++ b/src/main/java/uk/gleissner/loomwebflux/movie/MovieController.java @@ -1,5 +1,6 @@ package uk.gleissner.loomwebflux.movie; +import jakarta.transaction.Transactional; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -13,24 +14,25 @@ import reactor.core.publisher.Mono; import uk.gleissner.loomwebflux.controller.LoomWebFluxController; import uk.gleissner.loomwebflux.movie.domain.Movie; -import uk.gleissner.loomwebflux.movie.repo.MovieRepo; +import uk.gleissner.loomwebflux.movie.repo.AppPropertiesAwareMovieRepo; import java.util.List; import java.util.Set; -import java.util.UUID; +import static reactor.core.scheduler.Schedulers.boundedElastic; import static uk.gleissner.loomwebflux.Approaches.LOOM_NETTY; import static uk.gleissner.loomwebflux.Approaches.LOOM_TOMCAT; import static uk.gleissner.loomwebflux.Approaches.PLATFORM_TOMCAT; import static uk.gleissner.loomwebflux.Approaches.WEBFLUX_NETTY; @RestController +@Transactional public class MovieController extends LoomWebFluxController { private static final String API_PATH = "/movies"; - private final MovieRepo movieRepo; + private final AppPropertiesAwareMovieRepo movieRepo; - MovieController(WebClient webClient, MovieRepo movieRepo) { + MovieController(WebClient webClient, AppPropertiesAwareMovieRepo movieRepo) { super(webClient); this.movieRepo = movieRepo; } @@ -42,7 +44,7 @@ public Set findMoviesByDirectorLastName(@RequestParam String directorLast @RequestParam Long delayInMillis) throws InterruptedException { log("findMoviesByDirectorLastName"); waitOrFetchEpochMillis(delayCallDepth, delayInMillis); - return movieRepo.findMoviesByDirector(directorLastName); + return movieRepo.findByDirectorName(directorLastName); } @GetMapping(WEBFLUX_NETTY + API_PATH) @@ -52,7 +54,7 @@ public Flux findMoviesByDirectorLastNameReactive(@RequestParam String dir @RequestParam Long delayInMillis) { log("findMoviesByDirectorLastNameReactive"); return waitOrFetchEpochMillisReactive(delayCallDepth, delayInMillis) - .thenMany(Flux.defer(() -> Flux.fromIterable(movieRepo.findMoviesByDirector(directorLastName)))); + .thenMany(Flux.defer(() -> Flux.fromIterable(movieRepo.findByDirectorName(directorLastName)))); } @PostMapping({PLATFORM_TOMCAT + API_PATH, LOOM_TOMCAT + API_PATH, LOOM_NETTY + API_PATH}) @@ -61,7 +63,7 @@ public List saveMovies(@RequestBody List movies, @RequestParam Long delayInMillis) throws InterruptedException { log("saveMovies"); waitOrFetchEpochMillis(delayCallDepth, delayInMillis); - return movieRepo.saveAll(movies); + return movies.stream().map(movieRepo::save).toList(); } @PostMapping(WEBFLUX_NETTY + API_PATH) @@ -70,11 +72,13 @@ public Flux saveMoviesReactive(@RequestBody Flux movies, @RequestParam Long delayInMillis) { log("saveMoviesReactive"); return waitOrFetchEpochMillisReactive(delayCallDepth, delayInMillis) - .flatMapMany(ignore -> movies.flatMap(movie -> Mono.just(movieRepo.save(movie)))); + .flatMapMany(ignore -> movies + .publishOn(boundedElastic()) + .map(movieRepo::save)); } @DeleteMapping({PLATFORM_TOMCAT + API_PATH + "/{id}", LOOM_TOMCAT + API_PATH + "/{id}", LOOM_NETTY + API_PATH + "/{id}"}) - public void deleteMovieById(@PathVariable UUID id, + public void deleteMovieById(@PathVariable Long id, @RequestParam Integer delayCallDepth, @RequestParam Long delayInMillis) throws InterruptedException { log("deleteMoviesById"); @@ -83,11 +87,13 @@ public void deleteMovieById(@PathVariable UUID id, } @DeleteMapping(WEBFLUX_NETTY + API_PATH + "/{id}") - public Mono deleteMovieByIdReactive(@PathVariable UUID id, + public Mono deleteMovieByIdReactive(@PathVariable Long id, @RequestParam Integer delayCallDepth, @RequestParam Long delayInMillis) { log("deleteMoviesByIdReactive"); return waitOrFetchEpochMillisReactive(delayCallDepth, delayInMillis) - .then(Mono.fromRunnable(() -> movieRepo.deleteById(id))); + .then(Mono.fromRunnable(() -> movieRepo.deleteById(id)) + .subscribeOn(boundedElastic())) + .then(); } } diff --git a/src/main/java/uk/gleissner/loomwebflux/movie/domain/Actor.java b/src/main/java/uk/gleissner/loomwebflux/movie/domain/Actor.java deleted file mode 100644 index b9bf6d17..00000000 --- a/src/main/java/uk/gleissner/loomwebflux/movie/domain/Actor.java +++ /dev/null @@ -1,13 +0,0 @@ -package uk.gleissner.loomwebflux.movie.domain; - -import lombok.Builder; -import lombok.Value; -import lombok.extern.jackson.Jacksonized; - -@Value -@Builder -@Jacksonized -public class Actor { - Person person; - String role; -} diff --git a/src/main/java/uk/gleissner/loomwebflux/movie/domain/Award.java b/src/main/java/uk/gleissner/loomwebflux/movie/domain/Award.java index bc978976..55dfe039 100644 --- a/src/main/java/uk/gleissner/loomwebflux/movie/domain/Award.java +++ b/src/main/java/uk/gleissner/loomwebflux/movie/domain/Award.java @@ -1,13 +1,28 @@ package uk.gleissner.loomwebflux.movie.domain; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Value; -import lombok.extern.jackson.Jacksonized; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; -@Value -@Builder -@Jacksonized +import static jakarta.persistence.GenerationType.IDENTITY; + +@Entity +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor public class Award { + + @Id + @GeneratedValue(strategy = IDENTITY) + @EqualsAndHashCode.Exclude + Long id; + String name; int year; } \ No newline at end of file diff --git a/src/main/java/uk/gleissner/loomwebflux/movie/domain/Character.java b/src/main/java/uk/gleissner/loomwebflux/movie/domain/Character.java new file mode 100644 index 00000000..fc1c5bd1 --- /dev/null +++ b/src/main/java/uk/gleissner/loomwebflux/movie/domain/Character.java @@ -0,0 +1,32 @@ +package uk.gleissner.loomwebflux.movie.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import static jakarta.persistence.CascadeType.PERSIST; +import static jakarta.persistence.GenerationType.IDENTITY; + +@Entity +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class Character { + + @Id + @GeneratedValue(strategy = IDENTITY) + @EqualsAndHashCode.Exclude + Long id; + + String name; + + @ManyToOne(cascade = PERSIST) + Person actor; +} diff --git a/src/main/java/uk/gleissner/loomwebflux/movie/domain/Movie.java b/src/main/java/uk/gleissner/loomwebflux/movie/domain/Movie.java index e2d67532..65bddc5e 100644 --- a/src/main/java/uk/gleissner/loomwebflux/movie/domain/Movie.java +++ b/src/main/java/uk/gleissner/loomwebflux/movie/domain/Movie.java @@ -1,29 +1,40 @@ package uk.gleissner.loomwebflux.movie.domain; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToMany; +import lombok.AllArgsConstructor; import lombok.Builder; +import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; import lombok.NonNull; -import lombok.Value; -import lombok.extern.jackson.Jacksonized; import org.jetbrains.annotations.NotNull; import java.util.Comparator; import java.util.List; -import java.util.UUID; -@Value +import static jakarta.persistence.CascadeType.ALL; +import static jakarta.persistence.CascadeType.PERSIST; +import static jakarta.persistence.GenerationType.IDENTITY; + +@Entity +@Data @Builder(toBuilder = true) -@Jacksonized -@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@NoArgsConstructor +@AllArgsConstructor public class Movie implements Comparable { private final static Comparator comparator = Comparator.comparing(Movie::getTitle) - .thenComparingInt(Movie::getReleaseYear) - .thenComparing(Movie::getGenre) - .thenComparing(Movie::getId); + .thenComparingInt(Movie::getReleaseYear) + .thenComparing(Movie::getGenre) + .thenComparing(Movie::getId); - @EqualsAndHashCode.Include - UUID id; + @Id + @GeneratedValue(strategy = IDENTITY) + @EqualsAndHashCode.Exclude + Long id; @NonNull String title; @@ -34,10 +45,18 @@ public class Movie implements Comparable { @NonNull Genre genre; - List actors; + @ManyToMany(cascade = ALL) + List characters; + + @ManyToMany(cascade = PERSIST) List directors; + + @ManyToMany(cascade = PERSIST) List writers; + + @ManyToMany(cascade = ALL) List awards; + Double rating; @Override diff --git a/src/main/java/uk/gleissner/loomwebflux/movie/domain/Person.java b/src/main/java/uk/gleissner/loomwebflux/movie/domain/Person.java index 954b4955..ec35ec26 100644 --- a/src/main/java/uk/gleissner/loomwebflux/movie/domain/Person.java +++ b/src/main/java/uk/gleissner/loomwebflux/movie/domain/Person.java @@ -1,17 +1,32 @@ package uk.gleissner.loomwebflux.movie.domain; import jakarta.annotation.Nullable; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Value; -import lombok.extern.jackson.Jacksonized; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; import lombok.val; import java.time.LocalDate; -@Value -@Builder -@Jacksonized +import static jakarta.persistence.GenerationType.IDENTITY; + +@Entity +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor public class Person { + + @Id + @GeneratedValue(strategy = IDENTITY) + @EqualsAndHashCode.Exclude + Long id; + String firstName; String lastName; LocalDate birthday; @@ -23,9 +38,9 @@ public static Person of(String fullName) { public static Person of(String fullName, @Nullable LocalDate birthday) { val lastSpaceIdx = fullName.lastIndexOf(' '); return Person.builder() - .firstName(fullName.substring(0, lastSpaceIdx).trim()) - .lastName(fullName.substring(lastSpaceIdx + 1)) - .birthday(birthday) - .build(); + .firstName(fullName.substring(0, lastSpaceIdx).trim()) + .lastName(fullName.substring(lastSpaceIdx + 1)) + .birthday(birthday) + .build(); } } diff --git a/src/main/java/uk/gleissner/loomwebflux/movie/repo/AppPropertiesAwareMovieRepo.java b/src/main/java/uk/gleissner/loomwebflux/movie/repo/AppPropertiesAwareMovieRepo.java new file mode 100644 index 00000000..ef824f86 --- /dev/null +++ b/src/main/java/uk/gleissner/loomwebflux/movie/repo/AppPropertiesAwareMovieRepo.java @@ -0,0 +1,28 @@ +package uk.gleissner.loomwebflux.movie.repo; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import uk.gleissner.loomwebflux.config.AppProperties; +import uk.gleissner.loomwebflux.movie.domain.Movie; + +import java.util.Set; + +@Component +@RequiredArgsConstructor +public class AppPropertiesAwareMovieRepo { + private final AppProperties appProperties; + private final MovieRepo underlying; + + public Set findByDirectorName(String directorName) { + return underlying.findByDirectorName(directorName); + } + + public Movie save(Movie movie) { + return appProperties.repoReadOnly() ? movie : underlying.save(movie); + } + + public void deleteById(Long id) { + if (!appProperties.repoReadOnly()) + underlying.deleteById(id); + } +} diff --git a/src/main/java/uk/gleissner/loomwebflux/movie/repo/InMemoryMovieRepo.java b/src/main/java/uk/gleissner/loomwebflux/movie/repo/InMemoryMovieRepo.java deleted file mode 100644 index 4f562a19..00000000 --- a/src/main/java/uk/gleissner/loomwebflux/movie/repo/InMemoryMovieRepo.java +++ /dev/null @@ -1,200 +0,0 @@ -package uk.gleissner.loomwebflux.movie.repo; - - -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.val; -import org.springframework.stereotype.Component; -import uk.gleissner.loomwebflux.config.AppProperties; -import uk.gleissner.loomwebflux.movie.domain.Actor; -import uk.gleissner.loomwebflux.movie.domain.Award; -import uk.gleissner.loomwebflux.movie.domain.Genre; -import uk.gleissner.loomwebflux.movie.domain.Movie; -import uk.gleissner.loomwebflux.movie.domain.Person; - -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentSkipListSet; - -import static java.util.UUID.randomUUID; - -@Component -@RequiredArgsConstructor -public class InMemoryMovieRepo implements MovieRepo { - - private final AppProperties appProperties; - private final Map movieById = new ConcurrentHashMap<>(); - private final Map> moviesByDirectorLastName = new ConcurrentHashMap<>(); - - @PostConstruct - void setupMockData() { - sampleMovies().forEach(m -> this.save(m, true)); - } - - @Override - public Optional findMovieById(UUID id) { - return Optional.ofNullable(movieById.get(id)); - } - - @Override - public Set findMoviesByDirector(String directorLastName) { - return moviesByDirectorLastName.get(directorLastName.toLowerCase()); - } - - @Override - public Movie save(Movie movie) { - return save(movie, false); - } - - private Movie save(Movie movie, boolean postConstruct) { - val movieToSave = movie.getId() == null ? movie.toBuilder().id(randomUUID()).build() : movie; - if (postConstruct || !appProperties.repoReadOnly()) { - for (val director : movieToSave.getDirectors()) { - movieById.put(movieToSave.getId(), movieToSave); - moviesByDirectorLastName.computeIfAbsent(director.getLastName().toLowerCase(), k -> new ConcurrentSkipListSet<>()).add(movieToSave); - } - } - return movieToSave; - } - - @Override - public void deleteById(UUID id) { - if (!appProperties.repoReadOnly()) { - Optional.ofNullable(movieById.remove(id)).ifPresent(movie -> - movie.getDirectors().forEach(d -> moviesByDirectorLastName.get(d.getLastName().toLowerCase()).remove(movie))); - } - } - - private static List sampleMovies() { - val movies = new ArrayList(); - - // Alfred Hitchcock movies - val hitchcock = Person.of("Alfred Hitchcock", LocalDate.of(1899, 8, 13)); - movies.add(Movie.builder() - .title("Rear Window") - .releaseYear(1954) - .actors(List.of( - Actor.builder().person(Person.of("James Stewart")).role("L.B. Jefferies").build(), - Actor.builder().person(Person.of("Grace Kelly")).role("Lisa Carol Fremont").build() - )) - .directors(List.of(hitchcock)) - .writers(List.of(Person.of("Joseph Stefano"))) - .awards(List.of(Award.builder().name("Golden Globe").year(1955).build())) - .genre(Genre.MYSTERY) - .rating(8.5) - .build()); - - movies.add(Movie.builder() - .title("Vertigo") - .releaseYear(1958) - .actors(List.of( - Actor.builder().person(Person.of("James Stewart")).role("John 'Scottie' Ferguson").build(), - Actor.builder().person(Person.of("Kim Novak")).role("Madeleine Elster").build() - )) - .directors(List.of(hitchcock)) - .writers(List.of(Person.of("Alec Coppel"))) - .awards(List.of(Award.builder().name("National Film Registry").year(1989).build())) - .genre(Genre.MYSTERY) - .rating(8.3) - .build()); - - movies.add(Movie.builder() - .title("North by Northwest") - .releaseYear(1959) - .actors(List.of( - Actor.builder().person(Person.of("Cary Grant")).role("Roger O. Thornhill").build(), - Actor.builder().person(Person.of("Eva Marie Saint")).role("Eve Kendall").build() - )) - .directors(List.of(hitchcock)) - .writers(List.of(Person.of("Ernest Lehman"))) - .awards(List.of(Award.builder().name("Academy Award").year(1960).build())) - .genre(Genre.ACTION) - .rating(8.4) - .build()); - - // Stanley Kubrick movies - val kubrick = Person.of("Stanley Kubrick", LocalDate.of(1928, 7, 26)); - movies.add(Movie.builder() - .title("2001: A Space Odyssey") - .releaseYear(1968) - .actors(List.of( - Actor.builder().person(Person.of("Keir Dullea")).role("Dr. Dave Bowman").build(), - Actor.builder().person(Person.of("Gary Lockwood")).role("Dr. Frank Poole").build() - )) - .directors(List.of(kubrick)) - .writers(List.of(kubrick)) - .awards(List.of(Award.builder().name("Academy Award").year(1969).build())) - .genre(Genre.SCIENCE_FICTION) - .rating(8.7) - .build()); - - movies.add(Movie.builder() - .title("Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb") - .releaseYear(1964) - .actors(List.of( - Actor.builder().person(Person.of("Peter Sellers")).role("Group Capt. Lionel Mandrake / President Merkin Muffley / Dr. Strangelove").build(), - Actor.builder().person(Person.of("George C. Scott")).role("Gen. 'Buck' Turgidson").build() - )) - .directors(List.of(kubrick)) - .writers(List.of(kubrick)) - .awards(List.of(Award.builder().name("BAFTA Award").year(1965).build())) - .genre(Genre.COMEDY) - .rating(8.4) - .build()); - - // Woody Allen movies - val allen = Person.of("Woody Allen", LocalDate.of(1935, 11, 30)); - movies.add(Movie.builder() - .title("Annie Hall") - .releaseYear(1977) - .actors(List.of( - Actor.builder().person(allen).role("Alvy Singer").build(), - Actor.builder().person(Person.of("Diane Keaton")).role("Annie Hall").build() - )) - .directors(List.of(allen)) - .writers(List.of(allen)) - .awards(List.of( - Award.builder().name("Academy Award").year(1978).build(), - Award.builder().name("Golden Globe").year(1978).build() - )) - .genre(Genre.ROMANCE) - .rating(8.0) - .build()); - - movies.add(Movie.builder() - .title("Manhattan") - .releaseYear(1979) - .actors(List.of( - Actor.builder().person(allen).role("Isaac Davis").build(), - Actor.builder().person(Person.of("Diane Keaton")).role("Mary Wilkie").build() - )) - .directors(List.of(allen)) - .writers(List.of(allen)) - .awards(List.of(Award.builder().name("BAFTA Award").year(1980).build())) - .genre(Genre.DRAMA) - .rating(7.9) - .build()); - - movies.add(Movie.builder() - .title("Zelig") - .releaseYear(1983) - .actors(List.of( - Actor.builder().person(allen).role("Leonard Zelig").build(), - Actor.builder().person(Person.of("Mia Farrow")).role("Dr. Eudora Nesbitt Fletcher").build() - )) - .directors(List.of(allen)) - .writers(List.of(allen)) - .awards(List.of(Award.builder().name("Golden Globe").year(1984).build())) - .genre(Genre.COMEDY) - .rating(7.8) - .build()); - - return movies; - } -} diff --git a/src/main/java/uk/gleissner/loomwebflux/movie/repo/MovieRepo.java b/src/main/java/uk/gleissner/loomwebflux/movie/repo/MovieRepo.java index f43ca910..50cb8669 100644 --- a/src/main/java/uk/gleissner/loomwebflux/movie/repo/MovieRepo.java +++ b/src/main/java/uk/gleissner/loomwebflux/movie/repo/MovieRepo.java @@ -1,20 +1,13 @@ package uk.gleissner.loomwebflux.movie.repo; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.ListCrudRepository; import uk.gleissner.loomwebflux.movie.domain.Movie; -import java.util.List; import java.util.Set; -import java.util.UUID; -public interface MovieRepo { +public interface MovieRepo extends ListCrudRepository { - Set findMoviesByDirector(String directorName); - - Movie save(Movie movie); - - default List saveAll(List movies) { - return movies.stream().map(this::save).toList(); - } - - void deleteById(UUID id); + @Query("SELECT m FROM Movie m JOIN m.directors d WHERE d.lastName = :directorName") + Set findByDirectorName(String directorName); } diff --git a/src/main/java/uk/gleissner/loomwebflux/movie/repo/MovieRepoInitializer.java b/src/main/java/uk/gleissner/loomwebflux/movie/repo/MovieRepoInitializer.java new file mode 100644 index 00000000..fb8b1235 --- /dev/null +++ b/src/main/java/uk/gleissner/loomwebflux/movie/repo/MovieRepoInitializer.java @@ -0,0 +1,153 @@ +package uk.gleissner.loomwebflux.movie.repo; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import uk.gleissner.loomwebflux.movie.domain.Award; +import uk.gleissner.loomwebflux.movie.domain.Character; +import uk.gleissner.loomwebflux.movie.domain.Genre; +import uk.gleissner.loomwebflux.movie.domain.Movie; +import uk.gleissner.loomwebflux.movie.domain.Person; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +class MovieRepoInitializer { + + private final MovieRepo movieRepo; + + @EventListener(ApplicationReadyEvent.class) + void initMovieRepo() { + val movies = new ArrayList(); + + // Alfred Hitchcock movies + val hitchcock = Person.of("Alfred Hitchcock", LocalDate.of(1899, 8, 13)); + movies.add(Movie.builder() + .title("Rear Window") + .releaseYear(1954) + .characters(List.of( + Character.builder().actor(Person.of("James Stewart")).name("L.B. Jefferies").build(), + Character.builder().actor(Person.of("Grace Kelly")).name("Lisa Carol Fremont").build() + )) + .directors(List.of(hitchcock)) + .writers(List.of(Person.of("Joseph Stefano"))) + .awards(List.of(Award.builder().name("Golden Globe").year(1955).build())) + .genre(Genre.MYSTERY) + .rating(8.5) + .build()); + + movies.add(Movie.builder() + .title("Vertigo") + .releaseYear(1958) + .characters(List.of( + Character.builder().actor(Person.of("James Stewart")).name("John 'Scottie' Ferguson").build(), + Character.builder().actor(Person.of("Kim Novak")).name("Madeleine Elster").build() + )) + .directors(List.of(hitchcock)) + .writers(List.of(Person.of("Alec Coppel"))) + .awards(List.of(Award.builder().name("National Film Registry").year(1989).build())) + .genre(Genre.MYSTERY) + .rating(8.3) + .build()); + + movies.add(Movie.builder() + .title("North by Northwest") + .releaseYear(1959) + .characters(List.of( + Character.builder().actor(Person.of("Cary Grant")).name("Roger O. Thornhill").build(), + Character.builder().actor(Person.of("Eva Marie Saint")).name("Eve Kendall").build() + )) + .directors(List.of(hitchcock)) + .writers(List.of(Person.of("Ernest Lehman"))) + .awards(List.of(Award.builder().name("Academy Award").year(1960).build())) + .genre(Genre.ACTION) + .rating(8.4) + .build()); + + // Stanley Kubrick movies + val kubrick = Person.of("Stanley Kubrick", LocalDate.of(1928, 7, 26)); + movies.add(Movie.builder() + .title("2001: A Space Odyssey") + .releaseYear(1968) + .characters(List.of( + Character.builder().actor(Person.of("Keir Dullea")).name("Dr. Dave Bowman").build(), + Character.builder().actor(Person.of("Gary Lockwood")).name("Dr. Frank Poole").build() + )) + .directors(List.of(kubrick)) + .writers(List.of(kubrick)) + .awards(List.of(Award.builder().name("Academy Award").year(1969).build())) + .genre(Genre.SCIENCE_FICTION) + .rating(8.7) + .build()); + + movies.add(Movie.builder() + .title("Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb") + .releaseYear(1964) + .characters(List.of( + Character.builder().actor(Person.of("Peter Sellers")).name("Group Capt. Lionel Mandrake / President Merkin Muffley / Dr. Strangelove").build(), + Character.builder().actor(Person.of("George C. Scott")).name("Gen. 'Buck' Turgidson").build() + )) + .directors(List.of(kubrick)) + .writers(List.of(kubrick)) + .awards(List.of(Award.builder().name("BAFTA Award").year(1965).build())) + .genre(Genre.COMEDY) + .rating(8.4) + .build()); + + // Woody Allen movies + val allen = Person.of("Woody Allen", LocalDate.of(1935, 11, 30)); + movies.add(Movie.builder() + .title("Annie Hall") + .releaseYear(1977) + .characters(List.of( + Character.builder().actor(allen).name("Alvy Singer").build(), + Character.builder().actor(Person.of("Diane Keaton")).name("Annie Hall").build() + )) + .directors(List.of(allen)) + .writers(List.of(allen)) + .awards(List.of( + Award.builder().name("Academy Award").year(1978).build(), + Award.builder().name("Golden Globe").year(1978).build() + )) + .genre(Genre.ROMANCE) + .rating(8.0) + .build()); + + movies.add(Movie.builder() + .title("Manhattan") + .releaseYear(1979) + .characters(List.of( + Character.builder().actor(allen).name("Isaac Davis").build(), + Character.builder().actor(Person.of("Diane Keaton")).name("Mary Wilkie").build() + )) + .directors(List.of(allen)) + .writers(List.of(allen)) + .awards(List.of(Award.builder().name("BAFTA Award").year(1980).build())) + .genre(Genre.DRAMA) + .rating(7.9) + .build()); + + movies.add(Movie.builder() + .title("Zelig") + .releaseYear(1983) + .characters(List.of( + Character.builder().actor(allen).name("Leonard Zelig").build(), + Character.builder().actor(Person.of("Mia Farrow")).name("Dr. Eudora Nesbitt Fletcher").build() + )) + .directors(List.of(allen)) + .writers(List.of(allen)) + .awards(List.of(Award.builder().name("Golden Globe").year(1984).build())) + .genre(Genre.COMEDY) + .rating(7.8) + .build()); + + movieRepo.saveAll(movies); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 14d115a9..c770c6b3 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -10,18 +10,19 @@ spring: jpa: properties: hibernate: - jdbc: - time_zone: UTC + enable_lazy_load_no_trans: true # TODO cg Avoid this; see https://vladmihalcea.com/the-best-way-to-map-a-projection-query-to-a-dto-with-jpa-and-hibernate/ hibernate: - ddl-auto: create-drop + ddl-auto: create database-platform: org.hibernate.dialect.PostgreSQLDialect database: postgresql - show-sql: false datasource: url: jdbc:postgresql://localhost:5432/loom-webflux username: postgres password: password - + hikari: + connectionTimeout: 60000 + idleTimeout: 60000 + maximumPoolSize: 1000 server: http2: enabled: true diff --git a/src/test/java/uk/gleissner/loomwebflux/LoomWebfluxAppIntegrationTest.kt b/src/test/java/uk/gleissner/loomwebflux/LoomWebfluxAppIntegrationTest.kt index 72256ae0..d5844acd 100644 --- a/src/test/java/uk/gleissner/loomwebflux/LoomWebfluxAppIntegrationTest.kt +++ b/src/test/java/uk/gleissner/loomwebflux/LoomWebfluxAppIntegrationTest.kt @@ -1,11 +1,19 @@ package uk.gleissner.loomwebflux import org.junit.jupiter.api.Test +import uk.gleissner.loomwebflux.fixture.AbstractIntegrationTest +import uk.gleissner.loomwebflux.fixture.AbstractIntegrationTest.Companion.SPRING_DATASOURCE_URL internal class LoomWebfluxAppIntegrationTest { @Test fun `load context`() { - LoomWebfluxApp.main(arrayOf()) + try { + System.setProperty(SPRING_DATASOURCE_URL, AbstractIntegrationTest.postgres.jdbcUrl) + LoomWebfluxApp.main(arrayOf()) + } finally { + LoomWebfluxApp.ctx.close() + System.clearProperty(SPRING_DATASOURCE_URL) + } } } \ No newline at end of file diff --git a/src/test/java/uk/gleissner/loomwebflux/fixture/AbstractIntegrationTest.kt b/src/test/java/uk/gleissner/loomwebflux/fixture/AbstractIntegrationTest.kt index 09cd09ff..ddc06162 100644 --- a/src/test/java/uk/gleissner/loomwebflux/fixture/AbstractIntegrationTest.kt +++ b/src/test/java/uk/gleissner/loomwebflux/fixture/AbstractIntegrationTest.kt @@ -1,8 +1,6 @@ package uk.gleissner.loomwebflux.fixture import nl.altindag.log.LogCaptor -import org.junit.jupiter.api.AfterAll -import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeEach import org.junitpioneer.jupiter.cartesian.ArgumentSets import org.springframework.beans.factory.annotation.Autowired @@ -13,7 +11,7 @@ import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource import org.springframework.test.web.reactive.server.WebTestClient import org.testcontainers.containers.PostgreSQLContainer -import uk.gleissner.loomwebflux.Approaches +import uk.gleissner.loomwebflux.Approaches.* import uk.gleissner.loomwebflux.controller.LoomWebFluxController @@ -33,36 +31,25 @@ abstract class AbstractIntegrationTest { companion object { - @JvmStatic - var postgres = PostgreSQLContainer("postgres:16-alpine") + const val SPRING_DATASOURCE_URL = "spring.datasource.url" - @JvmStatic - @BeforeAll - fun beforeAll() { - postgres.start() - } + val postgres: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:16-alpine") + .withDatabaseName("loom-webflux") + .withUsername("postgres") + .withPassword("password") - @JvmStatic - @AfterAll - fun afterAll() { - postgres.stop() + init { + postgres.start() } @JvmStatic @DynamicPropertySource fun configureProperties(registry: DynamicPropertyRegistry) { - registry.add("spring.datasource.url", postgres::getJdbcUrl) - registry.add("spring.datasource.username", postgres::getUsername) - registry.add("spring.datasource.password", postgres::getPassword) + registry.add(SPRING_DATASOURCE_URL, postgres::getJdbcUrl) } @JvmStatic - fun approaches(): List = listOf( - Approaches.PLATFORM_TOMCAT, - Approaches.LOOM_TOMCAT, - Approaches.LOOM_NETTY, - Approaches.WEBFLUX_NETTY - ) + fun approaches(): List = listOf(PLATFORM_TOMCAT, LOOM_TOMCAT, LOOM_NETTY, WEBFLUX_NETTY) @JvmStatic fun approachesAndDelayCallDepths(): ArgumentSets { diff --git a/src/test/java/uk/gleissner/loomwebflux/movie/MovieControllerIntegrationTest.kt b/src/test/java/uk/gleissner/loomwebflux/movie/MovieControllerIntegrationTest.kt index 0d74419b..24a3d2c7 100644 --- a/src/test/java/uk/gleissner/loomwebflux/movie/MovieControllerIntegrationTest.kt +++ b/src/test/java/uk/gleissner/loomwebflux/movie/MovieControllerIntegrationTest.kt @@ -8,12 +8,13 @@ import org.springframework.web.reactive.function.BodyInserters import uk.gleissner.loomwebflux.fixture.AbstractIntegrationTest import uk.gleissner.loomwebflux.fixture.CartesianTestApproachesAndDelayCallDepths import uk.gleissner.loomwebflux.fixture.LogCaptorFixture.assertCorrectThreadType -import uk.gleissner.loomwebflux.movie.domain.* +import uk.gleissner.loomwebflux.movie.domain.Directors.davidLynch +import uk.gleissner.loomwebflux.movie.domain.Movie +import uk.gleissner.loomwebflux.movie.domain.Movies.mulhollandDrive +import uk.gleissner.loomwebflux.movie.domain.Movies.theStraightStory import uk.gleissner.loomwebflux.movie.repo.MovieRepo import java.time.Duration import java.time.Instant.now -import java.time.LocalDate -import java.util.* internal class MovieControllerIntegrationTest : AbstractIntegrationTest() { @@ -26,7 +27,7 @@ internal class MovieControllerIntegrationTest : AbstractIntegrationTest() { @CartesianTestApproachesAndDelayCallDepths fun `find movies by director last name`(approach: String, delayCallDepth: Int) { val movies = getMovies(approach, delayCallDepth = delayCallDepth) - assertThat(movies).containsExactlyElementsOf(movieRepo.findMoviesByDirector("Allen")) + assertThat(movies).containsExactlyElementsOf(movieRepo.findByDirectorName("Allen")) logCaptor.assertCorrectThreadType(approach, delayCallDepth + 1) } @@ -34,19 +35,14 @@ internal class MovieControllerIntegrationTest : AbstractIntegrationTest() { @CartesianTestApproachesAndDelayCallDepths fun `save and delete a movie`(approach: String, delayCallDepth: Int) { val moviesByDavidLynch = listOf(mulhollandDrive, theStraightStory) - moviesByDavidLynch.forEach { - assertThat(it.id).isNull() - } - - val movies = getMovies(approach, directorLastName = davidLynch.lastName, delayCallDepth = delayCallDepth) - assertThat(movies).isEmpty() + assertThat(getMovies(approach, directorLastName = davidLynch.lastName, delayCallDepth = delayCallDepth)).isEmpty() val savedMovies = saveMovies(approach, moviesByDavidLynch, delayCallDepth = delayCallDepth) assertThat(savedMovies).hasSize(moviesByDavidLynch.size) savedMovies.forEach { assertThat(it.id).isNotNull() } - assertThat(savedMovies).usingRecursiveComparison().ignoringFields("id").isEqualTo(moviesByDavidLynch) + assertThat(savedMovies).usingRecursiveComparison().ignoringFieldsMatchingRegexes(".*id").isEqualTo(moviesByDavidLynch) savedMovies.forEach { deleteMovie(approach, movieId = it.id, delayCallDepth = delayCallDepth) @@ -76,7 +72,9 @@ internal class MovieControllerIntegrationTest : AbstractIntegrationTest() { .build() }.exchange() .expectStatus().isOk() - .expectBodyList(Movie::class.java).returnResult().responseBody + .expectBodyList(Movie::class.java) + .returnResult() + .responseBody assertThat(Duration.between(startTime, now())).isGreaterThan(Duration.ofMillis(delayInMillis)) return movies } @@ -100,7 +98,7 @@ internal class MovieControllerIntegrationTest : AbstractIntegrationTest() { return savedMovies } - private fun deleteMovie(approach: String, movieId: UUID, delayCallDepth: Int = 1) { + private fun deleteMovie(approach: String, movieId: Long, delayCallDepth: Int = 1) { val startTime = now() client.delete().uri { it @@ -111,55 +109,4 @@ internal class MovieControllerIntegrationTest : AbstractIntegrationTest() { }.exchange().expectStatus().isOk assertThat(Duration.between(startTime, now())).isGreaterThan(Duration.ofMillis(delayInMillis)) } - - private val davidLynch = Person.of("David Lynch", LocalDate.of(1946, 1, 20)) - - private val mulhollandDrive = Movie.builder() - .title("Mulholland Drive") - .releaseYear(2001) - .actors( - listOf( - Actor.builder().person(Person.of("Naomi Watts", LocalDate.of(1968, 9, 28))).role("Betty Elms").build(), - Actor.builder().person(Person.of("Laura Harring", LocalDate.of(1964, 3, 3))).role("Rita").build() - ) - ) - .directors(listOf(davidLynch)) - .writers(listOf(davidLynch)) - .awards( - listOf( - Award.builder().name("Cannes Film Festival").year(2001).build(), - Award.builder().name("National Society of Film Critics Award").year(2001).build() - ) - ) - .genre(Genre.MYSTERY) - .rating(7.9) - .build() - - private val theStraightStory = Movie.builder() - .title("The Straight Story") - .releaseYear(1999) - .actors( - listOf( - Actor.builder().person(Person.of("Richard Farnsworth", LocalDate.of(1920, 9, 1))).role("Alvin Straight") - .build(), - Actor.builder().person(Person.of("Sissy Spacek", LocalDate.of(1949, 12, 25))).role("Rose Straight") - .build() - ) - ) - .directors(listOf(davidLynch)) - .writers( - listOf( - Person.of("John Roach", LocalDate.of(1960, 3, 2)), - Person.of("Mary Sweeney", LocalDate.of(1954, 4, 29)) - ) - ) - .awards( - listOf( - Award.builder().name("Cannes Film Festival").year(1999).build(), - Award.builder().name("Golden Globe Award").year(2000).build() - ) - ) - .genre(Genre.DRAMA) - .rating(8.0) - .build() } \ No newline at end of file diff --git a/src/test/java/uk/gleissner/loomwebflux/movie/domain/Directors.kt b/src/test/java/uk/gleissner/loomwebflux/movie/domain/Directors.kt new file mode 100644 index 00000000..f647be41 --- /dev/null +++ b/src/test/java/uk/gleissner/loomwebflux/movie/domain/Directors.kt @@ -0,0 +1,7 @@ +package uk.gleissner.loomwebflux.movie.domain + +import java.time.LocalDate + +object Directors { + val davidLynch = Person.of("David Lynch", LocalDate.of(1946, 1, 20)) +} \ No newline at end of file diff --git a/src/test/java/uk/gleissner/loomwebflux/movie/domain/Movies.kt b/src/test/java/uk/gleissner/loomwebflux/movie/domain/Movies.kt new file mode 100644 index 00000000..ab9b082d --- /dev/null +++ b/src/test/java/uk/gleissner/loomwebflux/movie/domain/Movies.kt @@ -0,0 +1,56 @@ +package uk.gleissner.loomwebflux.movie.domain + +import uk.gleissner.loomwebflux.movie.domain.Directors.davidLynch +import java.time.LocalDate + +object Movies { + + val mulhollandDrive = Movie.builder() + .title("Mulholland Drive") + .releaseYear(2001) + .characters( + listOf( + Character.builder().actor(Person.of("Naomi Watts", LocalDate.of(1968, 9, 28))).name("Betty Elms").build(), + Character.builder().actor(Person.of("Laura Harring", LocalDate.of(1964, 3, 3))).name("Rita").build() + ) + ) + .directors(listOf(davidLynch)) + .writers(listOf(davidLynch)) + .awards( + listOf( + Award.builder().name("Cannes Film Festival").year(2001).build(), + Award.builder().name("National Society of Film Critics Award").year(2001).build() + ) + ) + .genre(Genre.MYSTERY) + .rating(7.9) + .build() + + val theStraightStory = Movie.builder() + .title("The Straight Story") + .releaseYear(1999) + .characters( + listOf( + Character.builder().actor(Person.of("Richard Farnsworth", LocalDate.of(1920, 9, 1))).name("Alvin Straight") + .build(), + Character.builder().actor(Person.of("Sissy Spacek", LocalDate.of(1949, 12, 25))).name("Rose Straight") + .build() + ) + ) + .directors(listOf(davidLynch)) + .writers( + listOf( + Person.of("John Roach", LocalDate.of(1960, 3, 2)), + Person.of("Mary Sweeney", LocalDate.of(1954, 4, 29)) + ) + ) + .awards( + listOf( + Award.builder().name("Cannes Film Festival").year(1999).build(), + Award.builder().name("Golden Globe Award").year(2000).build() + ) + ) + .genre(Genre.DRAMA) + .rating(8.0) + .build() +} \ No newline at end of file diff --git a/src/test/java/uk/gleissner/loomwebflux/movie/repo/AppPropertiesAwareMovieRepoTest.kt b/src/test/java/uk/gleissner/loomwebflux/movie/repo/AppPropertiesAwareMovieRepoTest.kt new file mode 100644 index 00000000..fe53ee8b --- /dev/null +++ b/src/test/java/uk/gleissner/loomwebflux/movie/repo/AppPropertiesAwareMovieRepoTest.kt @@ -0,0 +1,83 @@ +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Mockito.* +import org.mockito.junit.jupiter.MockitoExtension +import uk.gleissner.loomwebflux.config.AppProperties +import uk.gleissner.loomwebflux.movie.domain.Directors.davidLynch +import uk.gleissner.loomwebflux.movie.domain.Movie +import uk.gleissner.loomwebflux.movie.domain.Movies.mulhollandDrive +import uk.gleissner.loomwebflux.movie.domain.Movies.theStraightStory +import uk.gleissner.loomwebflux.movie.repo.AppPropertiesAwareMovieRepo +import uk.gleissner.loomwebflux.movie.repo.MovieRepo + +@ExtendWith(MockitoExtension::class) +class AppPropertiesAwareMovieRepoTest { + + @Mock + private lateinit var appProperties: AppProperties + + @Mock + private lateinit var movieRepo: MovieRepo + + @InjectMocks + private lateinit var appPropertiesAwareMovieRepo: AppPropertiesAwareMovieRepo + + @Test + fun `findByDirectorName should delegate to underlying repo`() { + val directorName = davidLynch.lastName + val expectedMovies = setOf(mulhollandDrive, theStraightStory) + `when`(movieRepo.findByDirectorName(directorName)).thenReturn(expectedMovies) + + val movies = appPropertiesAwareMovieRepo.findByDirectorName(directorName) + + assertThat(movies).isEqualTo(expectedMovies) + verify(movieRepo).findByDirectorName(directorName) + } + + @Test + fun `save should return movie without saving when repoReadOnly is true`() { + `when`(appProperties.repoReadOnly()).thenReturn(true) + + val movie = appPropertiesAwareMovieRepo.save(mulhollandDrive) + + assertThat(movie).isSameAs(mulhollandDrive) + verify(movieRepo, never()).save(any(Movie::class.java)) + } + + @Test + fun `save should delegate to underlying repo when repoReadOnly is false`() { + val movie = mulhollandDrive + val savedMovie = mulhollandDrive + + `when`(appProperties.repoReadOnly()).thenReturn(false) + `when`(movieRepo.save(movie)).thenReturn(savedMovie) + + val result = appPropertiesAwareMovieRepo.save(movie) + + assertThat(result).isEqualTo(savedMovie) + verify(movieRepo).save(movie) + } + + @Test + fun `deleteById should not delete when repoReadOnly is true`() { + val movieId = 1L + `when`(appProperties.repoReadOnly()).thenReturn(true) + + appPropertiesAwareMovieRepo.deleteById(movieId) + + verify(movieRepo, never()).deleteById(movieId) + } + + @Test + fun `deleteById should delegate to underlying repo when repoReadOnly is false`() { + val movieId = 1L + `when`(appProperties.repoReadOnly()).thenReturn(false) + + appPropertiesAwareMovieRepo.deleteById(movieId) + + verify(movieRepo).deleteById(movieId) + } +} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 37974c82..ab77d28d 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -4,4 +4,3 @@ loom-webflux: logging: level: uk.gleissner.loomwebflux: debug -