commit 9958711e7396101506a5806c76aa8cb6bb31096d Author: wehub-resource-sync Date: Mon Jul 13 13:16:17 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..b5f1b18 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,33 @@ +version: 2 +jobs: + build: + docker: + - image: circleci/openjdk:11-jdk-stretch + steps: + - checkout + + - restore_cache: + key: maven-dependencies-{{ checksum "pom.xml" }} + + - run: + name: Downloading dependencies + command: ./mvnw dependency:go-offline + + - save_cache: + paths: + - ~/.m2 + key: maven-dependencies-{{ checksum "pom.xml" }} + + - run: + name: Building and testing + command: ./mvnw verify + + - store_test_results: + path: target/surefire-reports + + - store_test_results: + path: target/failsafe-reports + + - run: + name: Uploading coverage reports + command: bash <(curl -s https://codecov.io/bash) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbbdd0d --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +/target/ +target/ +!.mvn/wrapper/maven-wrapper.jar + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/build/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ \ No newline at end of file diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..9cc84ea Binary files /dev/null and b/.mvn/wrapper/maven-wrapper.jar differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..b573bb5 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https://repo1.maven.org/maven2/org/apache/maven/apache-maven/3.5.3/apache-maven-3.5.3-bin.zip diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6d8eecc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM openjdk:11.0.1-jre-slim-stretch +EXPOSE 8080 +WORKDIR /app +ARG JAR=library-0.0.1-SNAPSHOT.jar + +COPY /target/$JAR /app.jar +ENTRYPOINT ["java","-jar","/app.jar"] diff --git a/Dockerfile.build b/Dockerfile.build new file mode 100644 index 0000000..97ffef1 --- /dev/null +++ b/Dockerfile.build @@ -0,0 +1,12 @@ +FROM maven:3.6-jdk-11-slim as BUILD +COPY . /src +WORKDIR /src +RUN mvn install -DskipTests + +FROM openjdk:11.0.1-jre-slim-stretch +EXPOSE 8080 +WORKDIR /app +ARG JAR=library-0.0.1-SNAPSHOT.jar + +COPY --from=BUILD /src/target/$JAR /app.jar +ENTRYPOINT ["java","-jar","/app.jar"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b37121a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Jakub Pilimon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..111b1ae --- /dev/null +++ b/README.md @@ -0,0 +1,820 @@ +[![CircleCI](https://circleci.com/gh/ddd-by-examples/library.svg?style=svg)](https://circleci.com/gh/ddd-by-examples/library) +[![Code Coverage](https://codecov.io/gh/ddd-by-examples/library/branch/master/graph/badge.svg)](https://codecov.io/gh/ddd-by-examples/library) + +# Table of contents + +1. [About](#about) +2. [Domain description](#domain-description) +3. [General assumptions](#general-assumptions) + 3.1 [Process discovery](#process-discovery) + 3.2 [Project structure and architecture](#project-structure-and-architecture) + 3.3 [Aggregates](#aggregates) + 3.4 [Events](#events) + 3.4.1 [Events in Repositories](#events-in-repositories) + 3.5 [ArchUnit](#archunit) + 3.6 [Functional thinking](#functional-thinking) + 3.7 [No ORM](#no-orm) + 3.8 [Architecture-code gap](#architecture-code-gap) + 3.9 [Model-code gap](#model-code-gap) + 3.10 [Spring](#spring) + 3.11 [Tests](#tests) +4. [How to contribute](#how-to-contribute) +5. [References](#references) + +## About + +This is a project of a library, driven by real [business requirements](#domain-description). +We use techniques strongly connected with Domain Driven Design, Behavior-Driven Development, +Event Storming, User Story Mapping. + +## Domain description + +A public library allows patrons to place books on hold at its various library branches. +Available books can be placed on hold only by one patron at any given point in time. +Books are either circulating or restricted, and can have retrieval or usage fees. +A restricted book can only be held by a researcher patron. A regular patron is limited +to five holds at any given moment, while a researcher patron is allowed an unlimited number +of holds. An open-ended book hold is active until the patron checks out the book, at which time it +is completed. A closed-ended book hold that is not completed within a fixed number of +days after it was requested will expire. This check is done at the beginning of a day by +taking a look at daily sheet with expiring holds. Only a researcher patron can request +an open-ended hold duration. Any patron with more than two overdue checkouts at a library +branch will get a rejection if trying a hold at that same library branch. A book can be +checked out for up to 60 days. Check for overdue checkouts is done by taking a look at +daily sheet with overdue checkouts. Patron interacts with his/her current holds, checkouts, etc. +by taking a look at patron profile. Patron profile looks like a daily sheet, but the +information there is limited to one patron and is not necessarily daily. Currently a +patron can see current holds (not canceled nor expired) and current checkouts (including overdue). +Also, he/she is able to hold a book and cancel a hold. + +How actually a patron knows which books are there to lend? Library has its catalogue of +books where books are added together with their specific instances. A specific book +instance of a book can be added only if there is book with matching ISBN already in +the catalogue. Book must have non-empty title and price. At the time of adding an instance +we decide whether it will be Circulating or Restricted. This enables +us to have book with same ISBN as circulated and restricted at the same time (for instance, +there is a book signed by the author that we want to keep as Restricted) + +## General assumptions + +### Process discovery + +The first thing we started with was domain exploration with the help of Big Picture EventStorming. +The description you found in the previous chapter, landed on our virtual wall: +![Event Storming Domain description](docs/images/eventstorming-domain-desc.png) +The EventStorming session led us to numerous discoveries, modeled with the sticky notes: +![Event Storming Big Picture](docs/images/eventstorming-big-picture.jpg) +During the session we discovered following definitions: +![Event Storming Definitions](docs/images/eventstorming-definitions.png) + +This made us think of real life scenarios that might happen. We discovered them described with the help of +the **Example mapping**: +![Example mapping](docs/images/example-mapping.png) + +This in turn became the base for our *Design Level* sessions, where we analyzed each example: +![Example mapping](docs/images/eventstorming-design-level.jpg) + +Please follow the links below to get more details on each of the mentioned steps: +- [Big Picture EventStorming](./docs/big-picture.md) +- [Example Mapping](docs/example-mapping.md) +- [Design Level EventStorming](docs/design-level.md) + +### Project structure and architecture +At the very beginning, not to overcomplicate the project, we decided to assign each bounded context +to a separate package, which means that the system is a modular monolith. There are no obstacles, though, +to put contexts into maven modules or finally into microservices. + +Bounded contexts should (amongst others) introduce autonomy in the sense of architecture. Thus, each module +encapsulating the context has its own local architecture aligned to problem complexity. +In the case of a context, where we identified true business logic (**lending**) we introduced a domain model +that is a simplified (for the purpose of the project) abstraction of the reality and utilized +hexagonal architecture. In the case of a context, that during Event Storming turned out to lack any complex +domain logic, we applied CRUD-like local architecture. + +![Architecture](docs/images/architecture-big-picture.png) + +If we are talking about hexagonal architecture, it lets us separate domain and application logic from +frameworks (and infrastructure). What do we gain with this approach? Firstly, we can unit test most important +part of the application - **business logic** - usually without the need to stub any dependency. +Secondly, we create ourselves an opportunity to adjust infrastructure layer without the worry of +breaking the core functionality. In the infrastructure layer we intensively use Spring Framework +as probably the most mature and powerful application framework with an incredible test support. +More information about how we use Spring you will find [here](#spring). + +As we already mentioned, the architecture was driven by Event Storming sessions. Apart from identifying +contexts and their complexity, we could also make a decision that we separate read and write models (CQRS). +As an example you can have a look at **Patron Profiles** and *Daily Sheets*. + +### Aggregates +Aggregates discovered during Event Storming sessions communicate with each other with events. There is +a contention, though, should they be consistent immediately or eventually? As aggregates in general +determine business boundaries, eventual consistency sounds like a better choice, but choices in software +are never costless. Providing eventual consistency requires some infrastructural tools, like message broker +or event store. That's why we could (and did) start with immediate consistency. + +> Good architecture is the one which postpones all important decisions + +... that's why we made it easy to change the consistency model, providing tests for each option, including +basic implementations based on **DomainEvents** interface, which can be adjusted to our needs and +toolset in future. Let's have a look at following examples: + +* Immediate consistency + ```groovy + def 'should synchronize Patron, Book and DailySheet with events'() { + given: + bookRepository.save(book) + and: + patronRepo.publish(patronCreated()) + when: + patronRepo.publish(placedOnHold(book)) + then: + patronShouldBeFoundInDatabaseWithOneBookOnHold(patronId) + and: + bookReactedToPlacedOnHoldEvent() + and: + dailySheetIsUpdated() + } + + boolean bookReactedToPlacedOnHoldEvent() { + return bookRepository.findBy(book.bookId).get() instanceof BookOnHold + } + + boolean dailySheetIsUpdated() { + return new JdbcTemplate(datasource).query("select count(*) from holds_sheet s where s.hold_by_patron_id = ?", + [patronId.patronId] as Object[], + new ColumnMapRowMapper()).get(0) + .get("COUNT(*)") == 1 + } + ``` + _Please note that here we are just reading from database right after events are being published_ + + Simple implementation of the event bus is based on Spring application events: + ```java + @AllArgsConstructor + public class JustForwardDomainEventPublisher implements DomainEvents { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(DomainEvent event) { + applicationEventPublisher.publishEvent(event); + } + } + ``` + +* Eventual consistency + ```groovy + def 'should synchronize Patron, Book and DailySheet with events'() { + given: + bookRepository.save(book) + and: + patronRepo.publish(patronCreated()) + when: + patronRepo.publish(placedOnHold(book)) + then: + patronShouldBeFoundInDatabaseWithOneBookOnHold(patronId) + and: + bookReactedToPlacedOnHoldEvent() + and: + dailySheetIsUpdated() + } + + void bookReactedToPlacedOnHoldEvent() { + pollingConditions.eventually { + assert bookRepository.findBy(book.bookId).get() instanceof BookOnHold + } + } + + void dailySheetIsUpdated() { + pollingConditions.eventually { + assert countOfHoldsInDailySheet() == 1 + } + } + ``` + _Please note that the test looks exactly the same as previous one, but now we utilized Groovy's + **PollingConditions** to perform asynchronous functionality tests_ + + Sample implementation of event bus is following: + + ```java + @AllArgsConstructor + public class StoreAndForwardDomainEventPublisher implements DomainEvents { + + private final JustForwardDomainEventPublisher justForwardDomainEventPublisher; + private final EventsStorage eventsStorage; + + @Override + public void publish(DomainEvent event) { + eventsStorage.save(event); + } + + @Scheduled(fixedRate = 3000L) + @Transactional + public void publishAllPeriodically() { + List domainEvents = eventsStorage.toPublish(); + domainEvents.forEach(justForwardDomainEventPublisher::publish); + eventsStorage.published(domainEvents); + } + } + ``` + +To clarify, we should always aim for aggregates that can handle a business operation atomically +(transactionally if you like), so each aggregate should be as independent and decoupled from other +aggregates as possible. Thus, eventual consistency is promoted. As we already mentioned, it comes +with some tradeoffs, so from the pragmatic point of view immediate consistency is also a choice. +You might ask yourself a question now: _What if I don't have any events yet?_. Well, a pragmatic +approach would be to encapsulate the communication between aggregates in a _Service-like_ class, +where you could call proper aggregates line by line explicitly. + +### Events +Talking about inter-aggregate communication, we must remember that events reduce coupling, but don't remove +it completely. Thus, it is very vital to share(publish) only those events, that are necessary for other +aggregates to exist and function. Otherwise there is a threat that the level of coupling will increase +introducing **feature envy**, because other aggregates might start using those events to perform actions +they are not supposed to perform. A solution to this problem could be the distinction of domain events +and integration events, which will be described here soon. + +### Events in Repositories +Repositories are one of the most popular design pattern. They abstract our domain model from data layer. +In other words, they deal with state. That said, a common use-case is when we pass a new state to our repository, +so that it gets persisted. It may look like so: + +```java +public class BusinessService { + + private final PatronRepository patronRepository; + + void businessMethod(PatronId patronId) { + Patron patron = patronRepository.findById(patronId); + //do sth + patronRepository.save(patron); + } +} +``` + +Conceptually, between 1st and 3rd line of that business method we change state of our Patron from A to B. +This change might be calculated by dirty checking or we might just override entire Patron state in the database. +Third option is _Let's make implicit explicit_ and actually call this state change A->B an **event**. +After all, event-driven architecture is all about promoting state changes as domain events. + +Thanks to this our domain model may become immutable and just return events as results of invoking a command like so: + +```java +public BookPlacedOnHold placeOnHold(AvailableBook book) { + ... +} +``` + +And our repository might operate directly on events like so: + +```java +public interface PatronRepository { + void save(PatronEvent event) { +} +``` + +### ArchUnit + +One of the main components of a successful project is technical leadership that lets the team go in the right +direction. Nevertheless, there are tools that can support teams in keeping the code clean and protect the +architecture, so that the project won't become a Big Ball of Mud, and thus will be pleasant to develop and +to maintain. The first option, the one we proposed, is [ArchUnit](https://www.archunit.org/) - a Java architecture +test tool. ArchUnit lets you write unit tests of your architecture, so that it is always consistent with initial +vision. Maven modules could be an alternative as well, but let's focus on the former. + +In terms of hexagonal architecture, it is essential to ensure, that we do not mix different levels of +abstraction (hexagon levels): +```java +@ArchTest +public static final ArchRule model_should_not_depend_on_infrastructure = + noClasses() + .that() + .resideInAPackage("..model..") + .should() + .dependOnClassesThat() + .resideInAPackage("..infrastructure.."); +``` +and that frameworks do not affect the domain model +```java +@ArchTest +public static final ArchRule model_should_not_depend_on_spring = + noClasses() + .that() + .resideInAPackage("..io.pillopl.library.lending..model..") + .should() + .dependOnClassesThat() + .resideInAPackage("org.springframework.."); +``` + +### Functional thinking +When you look at the code you might find a scent of functional programming. Although we do not follow +a _clean_ FP, we try to think of business processes as pipelines or workflows, utilizing functional style through +following concepts. + +_Please note that this is not a reference project for FP._ + +#### Immutable objects +Each class that represents a business concept is immutable, thanks to which we: +* provide full encapsulation and objects' states protection, +* secure objects for multithreaded access, +* control all side effects much clearer. + +#### Pure functions +We model domain operations, discovered in Design Level Event Storming, as pure functions, and declare them in +both domain and application layers in the form of Java's functional interfaces. Their implementations are placed +in infrastructure layer as ordinary methods with side effects. Thanks to this approach we can follow the abstraction +of ubiquitous language explicitly, and keep this abstraction implementation-agnostic. As an example, you could have +a look at `FindAvailableBook` interface and its implementation: + +```java +@FunctionalInterface +public interface FindAvailableBook { + + Option findAvailableBookBy(BookId bookId); +} +``` + +```java +@AllArgsConstructor +class BookDatabaseRepository implements FindAvailableBook { + + private final JdbcTemplate jdbcTemplate; + + @Override + public Option findAvailableBookBy(BookId bookId) { + return Match(findBy(bookId)).of( + Case($Some($(instanceOf(AvailableBook.class))), Option::of), + Case($(), Option::none) + ); + } + + Option findBy(BookId bookId) { + return findBookById(bookId) + .map(BookDatabaseEntity::toDomainModel); + } + + private Option findBookById(BookId bookId) { + return Try + .ofSupplier(() -> of(jdbcTemplate.queryForObject("SELECT b.* FROM book_database_entity b WHERE b.book_id = ?", + new BeanPropertyRowMapper<>(BookDatabaseEntity.class), bookId.getBookId()))) + .getOrElse(none()); + } +} +``` + +#### Type system +_Type system - like_ modelling - we modelled each domain object's state discovered during EventStorming as separate +classes: `AvailableBook`, `BookOnHold`, `CheckedOutBook`. With this approach we provide much clearer abstraction than +having a single `Book` class with an enum-based state management. Moving the logic to these specific classes brings +Single Responsibility Principle to a different level. Moreover, instead of checking invariants in every business method +we leave the role to the compiler. As an example, please consider following scenario: _you can place on hold only a book +that is currently available_. We could have done it in a following way: +```java +public Either placeOnHold(Book book) { + if (book.status == AVAILABLE) { + ... + } +} +``` +but we use the _type system_ and declare method of following signature +```java +public Either placeOnHold(AvailableBook book) { + ... +} +``` +The more errors we discover at compile time the better. + +Yet another advantage of applying such type system is that we can represent business flows and state transitions +with functions much easier. As an example, following functions: +``` +placeOnHold: AvailableBook -> BookHoldFailed | BookPlacedOnHold +cancelHold: BookOnHold -> BookHoldCancelingFailed | BookHoldCanceled +``` +are much more concise and descriptive than these: +``` +placeOnHold: Book -> BookHoldFailed | BookPlacedOnHold +cancelHold: Book -> BookHoldCancelingFailed | BookHoldCanceled +``` +as here we have a lot of constraints hidden within function implementations. + +Moreover if you think of your domain as a set of operations (functions) that are being executed on business objects +(aggregates) you don't think of any execution model (like async processing). It is fine, because you don't have to. +Domain functions are free from I/O operations, async, and other side-effects-prone things, which are put into the +infrastructure layer. Thanks to this, we can easily test them without mocking mentioned parts. + +#### Monads +Business methods might have different results. One might return a value or a `null`, throw an exception when something +unexpected happens or just return different objects under different circumstances. All those situations are typical +to object-oriented languages like Java, but do not fit into functional style. We are dealing with this issues +with monads (monadic containers provided by [Vavr](https://www.vavr.io)): +* When a method returns optional value, we use the `Option` monad: + + ```java + Option findBy(BookId bookId) { + ... + } + ``` + +* When a method might return one of two possible values, we use the `Either` monad: + + ```java + Either placeOnHold(AvailableBook book) { + ... + } + ``` + +* When an exception might occur, we use `Try` monad: + + ```java + Try placeOnHold(@NonNull PlaceOnHoldCommand command) { + ... + } + ``` + +Thanks to this, we can follow the functional programming style, but we also enrich our domain language and +make our code much more readable for the clients. + +#### Pattern Matching +Depending on a type of a given book object we often need to perform different actions. Series of if/else or switch/case statements +could be a choice, but it is the pattern matching that provides the most conciseness and flexibility. With the code +like below we can check numerous patterns against objects and access their constituents, so our code has a minimal dose +of language-construct noise: +```java +private Book handleBookPlacedOnHold(Book book, BookPlacedOnHold bookPlacedOnHold) { + return API.Match(book).of( + Case($(instanceOf(AvailableBook.class)), availableBook -> availableBook.handle(bookPlacedOnHold)), + Case($(instanceOf(BookOnHold.class)), bookOnHold -> raiseDuplicateHoldFoundEvent(bookOnHold, bookPlacedOnHold)), + Case($(), () -> book) + ); +} +``` + +### (No) ORM +If you run `mvn dependency:tree` you won't find any JPA implementation. Although we think that ORM solutions (like Hibernate) +are very powerful and useful, we decided not to use them, as we wouldn't utilize their features. What features are +talking about? Lazy loading, caching, dirty checking. Why don't we need them? We want to have more control +over SQL queries and minimize the object-relational impedance mismatch ourselves. Moreover, thanks to relatively +small aggregates, containing as little data as it is required to protect the invariants, we don't need the +lazy loading mechanism either. +With Hexagonal Architecture we have the ability to separate domain and persistence models and test them +independently. Moreover, we can also introduce different persistence strategies for different aggregates. +In this project, we utilize both plain SQL queries and `JdbcTemplate` and use new and very promising +project called Spring Data JDBC, that is free from the JPA-related overhead mentioned before. +Please find below an example of a repository: + +```java +interface PatronEntityRepository extends CrudRepository { + + @Query("SELECT p.* FROM patron_database_entity p where p.patron_id = :patronId") + PatronDatabaseEntity findByPatronId(@Param("patronId") UUID patronId); + +} +``` + +At the same time we propose other way of persisting aggregates, with plain SQL queries and `JdbcTemplate`: + +```java +@AllArgsConstructor +class BookDatabaseRepository implements BookRepository, FindAvailableBook, FindBookOnHold { + + private final JdbcTemplate jdbcTemplate; + + @Override + public Option findBy(BookId bookId) { + return findBookById(bookId) + .map(BookDatabaseEntity::toDomainModel); + } + + private Option findBookById(BookId bookId) { + return Try + .ofSupplier(() -> of(jdbcTemplate.queryForObject("SELECT b.* FROM book_database_entity b WHERE b.book_id = ?", + new BeanPropertyRowMapper<>(BookDatabaseEntity.class), bookId.getBookId()))) + .getOrElse(none()); + } + + ... +} +``` +_Please note that despite having the ability to choose different persistence implementations for aggregates +it is recommended to stick to one option within the app/team_ + +### Architecture-code gap +We put a lot of attention to keep the consistency between the overall architecture (including diagrams) +and the code structure. Having identified bounded contexts we could organize them in modules (packages, to +be more specific). Thanks to this we gain the famous microservices' autonomy, while having a monolithic +application. Each package has well defined public API, encapsulating all implementation details by using +package-protected or private scopes. + +Just by looking at the package structure: + +``` +└── library + ├── catalogue + ├── commons + │   ├── aggregates + │   ├── commands + │   └── events + │   └── publisher + └── lending + ├── book + │   ├── application + │   ├── infrastructure + │   └── model + ├── dailysheet + │   ├── infrastructure + │   └── model + ├── librarybranch + │   └── model + ├── patron + │   ├── application + │   ├── infrastructure + │   └── model + └── patronprofile + ├── infrastructure + ├── model + └── web +``` +you can see that the architecture is screaming that it has two bounded contexts: **catalogue** +and **lending**. Moreover, the **lending context** is built around five business objects: **book**, +**dailysheet**, **librarybranch**, **patron**, and **patronprofile**, while **catalogue** has no subpackages, +which suggests that it might be a CRUD with no complex logic inside. Please find the architecture diagram +below. + +![Component diagram](docs/c4/component-diagram.png) + +Yet another advantage of this approach comparing to packaging by layer for example is that in order to +deliver a functionality you would usually need to do it in one package only, which is the aforementioned +autonomy. This autonomy, then, could be transferred to the level of application as soon as we split our +_context-packages_ into separate microservices. Following this considerations, autonomy can be given away +to a product team that can take care of the whole business area end-to-end. + +### Model-code gap +In our project we do our best to reduce _model-code gap_ to bare minimum. It means we try to put equal attention +to both the model and the code and keep them consistent. Below you will find some examples. + +#### Placing on hold +![Placing on hold](docs/images/placing_on_hold.jpg) + +Starting with the easiest part, below you will find the model classes corresponding to depicted command and events: + +```java +@Value +class PlaceOnHoldCommand { + ... +} +``` +```java +@Value +class BookPlacedOnHold implements PatronEvent { + ... +} +``` +```java +@Value +class MaximumNumberOfHoldsReached implements PatronEvent { + ... +} +``` +```java +@Value +class BookHoldFailed implements PatronEvent { + ... +} +``` + +We know it might not look impressive now, but if you have a look at the implementation of an aggregate, +you will see that the code reflects not only the aggregate name, but also the whole scenario of `PlaceOnHold` +command handling. Let us uncover the details: + +```java +public class Patron { + + public Either placeOnHold(AvailableBook book) { + return placeOnHold(book, HoldDuration.openEnded()); + } + + ... +} +``` + +The signature of `placeOnHold` method screams, that it is possible to place a book on hold only when it +is available (more information about protecting invariants by compiler you will find in [Type system section](#type-system)). +Moreover, if you try to place available book on hold it can **either** fail (`BookHoldFailed`) or produce some events - +what events? + +```java +@Value +class BookPlacedOnHoldEvents implements PatronEvent { + @NonNull UUID eventId = UUID.randomUUID(); + @NonNull UUID patronId; + @NonNull BookPlacedOnHold bookPlacedOnHold; + @NonNull Option maximumNumberOfHoldsReached; + + @Override + public Instant getWhen() { + return bookPlacedOnHold.when; + } + + public static BookPlacedOnHoldEvents events(BookPlacedOnHold bookPlacedOnHold) { + return new BookPlacedOnHoldEvents(bookPlacedOnHold.getPatronId(), bookPlacedOnHold, Option.none()); + } + + public static BookPlacedOnHoldEvents events(BookPlacedOnHold bookPlacedOnHold, MaximumNumberOfHoldsReached maximumNumberOfHoldsReached) { + return new BookPlacedOnHoldEvents(bookPlacedOnHold.patronId, bookPlacedOnHold, Option.of(maximumNumberOfHoldsReached)); + } + + public List normalize() { + return List.of(bookPlacedOnHold).appendAll(maximumNumberOfHoldsReached.toList()); + } +} +``` + +`BookPlacedOnHoldEvents` is a container for `BookPlacedOnHold` event, and - if patron has 5 book placed on hold already - +`MaximumNumberOfHoldsReached` (please mind the `Option` monad). You can see now how perfectly the code reflects +the model. + +It is not everything, though. In the picture above you can also see a big rectangular yellow card with rules (policies) +that define the conditions that need to be fulfilled in order to get the given result. All those rules are implemented +as functions **either** allowing or rejecting the hold: + +![Restricted book policy](docs/images/placing-on-hold-policy-restricted.png) +```java +PlacingOnHoldPolicy onlyResearcherPatronsCanHoldRestrictedBooksPolicy = (AvailableBook toHold, Patron patron, HoldDuration holdDuration) -> { + if (toHold.isRestricted() && patron.isRegular()) { + return left(Rejection.withReason("Regular patrons cannot hold restricted books")); + } + return right(new Allowance()); +}; +``` + +![Overdue checkouts policy](docs/images/placing-on-hold-policy-overdue.png) + +```java +PlacingOnHoldPolicy overdueCheckoutsRejectionPolicy = (AvailableBook toHold, Patron patron, HoldDuration holdDuration) -> { + if (patron.overdueCheckoutsAt(toHold.getLibraryBranch()) >= OverdueCheckouts.MAX_COUNT_OF_OVERDUE_RESOURCES) { + return left(Rejection.withReason("cannot place on hold when there are overdue checkouts")); + } + return right(new Allowance()); +}; +``` + +![Max number of holds policy](docs/images/placing-on-hold-policy-max.png) + +```java +PlacingOnHoldPolicy regularPatronMaximumNumberOfHoldsPolicy = (AvailableBook toHold, Patron patron, HoldDuration holdDuration) -> { + if (patron.isRegular() && patron.numberOfHolds() >= PatronHolds.MAX_NUMBER_OF_HOLDS) { + return left(Rejection.withReason("patron cannot hold more books")); + } + return right(new Allowance()); +}; +``` + +![Open ended hold policy](docs/images/placing-on-hold-policy-open-ended.png) + +```java +PlacingOnHoldPolicy onlyResearcherPatronsCanPlaceOpenEndedHolds = (AvailableBook toHold, Patron patron, HoldDuration holdDuration) -> { + if (patron.isRegular() && holdDuration.isOpenEnded()) { + return left(Rejection.withReason("regular patron cannot place open ended holds")); + } + return right(new Allowance()); +}; +``` + +#### Spring +Spring Framework seems to be the most popular Java framework ever used. Unfortunately it is also quite common +to overuse its features in the business code. What you find in this project is that the domain packages +are fully focused on modelling business problems, and are free from any DI, which makes it easy to +unit-test it which is invaluable in terms of code reliability and maintainability. It does not mean, +though, that we do not use Spring Framework - we do. Below you will find some details: +- Each bounded context has its own independent application context. It means that we removed the runtime +coupling, which is a step towards extracting modules (and microservices). How did we do that? Let's have +a look: + ```java + @SpringBootConfiguration + @EnableAutoConfiguration + public class LibraryApplication { + + public static void main(String[] args) { + new SpringApplicationBuilder() + .parent(LibraryApplication.class) + .child(LendingConfig.class).web(WebApplicationType.SERVLET) + .sibling(CatalogueConfiguration.class).web(WebApplicationType.NONE) + .run(args); + } + } + ``` +- As you could see above, we also try not to use component scan wherever possible. Instead we utilize +`@Configuration` classes where we define module specific beans in the infrastructure layer. Those +configuration classes are explicitly declared in the main application class. + +### Tests +Tests are written in a BDD manner, expressing stories defined with Example Mapping. +It means we utilize both TDD and Domain Language discovered with Event Storming. + +We also made an effort to show how to create a DSL, that enables to write +tests as if they were sentences taken from the domain descriptions. Please +find an example below: + +```groovy +def 'should make book available when hold canceled'() { + given: + BookDSL bookOnHold = aCirculatingBook() with anyBookId() locatedIn anyBranch() placedOnHoldBy anyPatron() + and: + PatronEvent.BookHoldCanceled bookHoldCanceledEvent = the bookOnHold isCancelledBy anyPatron() + + when: + AvailableBook availableBook = the bookOnHold reactsTo bookHoldCanceledEvent + then: + availableBook.bookId == bookOnHold.bookId + availableBook.libraryBranch == bookOnHold.libraryBranchId + availableBook.version == bookOnHold.version +} +``` +_Please also note the **when** block, where we manifest the fact that books react to +cancellation event_ + +## How to contribute + +The project is still under construction, so if you like it enough to collaborate, just let us +know or simply create a Pull Request. + + +## How to Build + +### Requirements + +* Java 11 +* Maven + +### Quickstart + +You can run the library app by simply typing the following: + +```console +$ mvn spring-boot:run +... +... +2019-04-03 15:55:39.162 INFO 18957 --- [ main] o.s.b.a.e.web.EndpointLinksResolver : Exposing 2 endpoint(s) beneath base path '/actuator' +2019-04-03 15:55:39.425 INFO 18957 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path '' +2019-04-03 15:55:39.428 INFO 18957 --- [ main] io.pillopl.library.LibraryApplication : Started LibraryApplication in 5.999 seconds (JVM running for 23.018) + +``` + +### Build a Jar package + +You can build a jar with maven like so: + +```console +$ mvn clean package +... +... +[INFO] Building jar: /home/pczarkowski/development/spring/library/target/library-0.0.1-SNAPSHOT.jar +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +``` + +### Build with Docker + +If you've already built the jar file you can run: + +```console +docker build -t spring/library . +``` + +Otherwise you can build the jar file using the multistage dockerfile: + +```console +docker build -t spring/library -f Dockerfile.build . +``` + +Either way once built you can run it like so: + +```console +$ docker run -ti --rm --name spring-library -p 8080:8080 spring/library +``` + +### Production ready metrics and visualization +To run the application as well as Prometheus and Grafana dashboard for visualizing metrics you can run all services: + +```console +$ docker-compose up +``` + +If everything goes well, you can access the following services at given location: +* http://localhost:8080/actuator/prometheus - published Micrometer metrics +* http://localhost:9090 - Prometheus dashboard +* http://localhost:3000 - Grafana dashboard + +In order to see some metrics, you must create a dashboard. Go to `Create` -> `Import` and select attached `jvm-micrometer_rev8.json`. File has been pulled from +`https://grafana.com/grafana/dashboards/4701`. + +Please note application will be run with `local` Spring profile to setup some initial data. + +## References + +1. [Introducing EventStorming](https://leanpub.com/introducing_eventstorming) by Alberto Brandolini +2. [Domain Modelling Made Functional](https://pragprog.com/book/swdddf/domain-modeling-made-functional) by Scott Wlaschin +3. [Software Architecture for Developers](https://softwarearchitecturefordevelopers.com) by Simon Brown +4. [Clean Architecture](https://www.amazon.com/Clean-Architecture-Craftsmans-Software-Structure/dp/0134494164) by Robert C. Martin +5. [Domain-Driven Design: Tackling Complexity in the Heart of Software](https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215) by Eric Evans diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..8321a2d --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`ddd-by-examples/library` +- 原始仓库:https://github.com/ddd-by-examples/library +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c33e336 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: "3" +services: + app: + image: spring/library:latest + container_name: 'library' + build: + context: ./ + dockerfile: Dockerfile + environment: + - "SPRING_PROFILES_ACTIVE=local" + ports: + - '8080:8080' + + prometheus: + image: prom/prometheus:v2.4.3 + container_name: 'prometheus' + volumes: + - ./monitoring/prometheus:/etc/prometheus/ + ports: + - '9090:9090' + + grafana: + image: grafana/grafana:5.2.4 + container_name: 'grafana' + ports: + - '3000:3000' + volumes: + - ./monitoring/grafana/provisioning/:/etc/grafana/provisioning/ + env_file: + - ./monitoring/grafana/config.monitoring + depends_on: + - prometheus diff --git a/docs/big-picture.md b/docs/big-picture.md new file mode 100644 index 0000000..6619234 --- /dev/null +++ b/docs/big-picture.md @@ -0,0 +1,80 @@ +# Big Picture EventStorming + +We started the domain exploration with sticky notes and a pen. What turned out to be the first discovery, +was the **close-ended book holding** process: + +![Close ended book holding](images/es/bigpicture/close-ended-holding-process.png) + +Let's briefly walk through it: +- A **Regular Patron** can **place a book on a close-ended hold** +- A **Regular Patron** might **reach a maximum holds number** after a **hold is placed** +- A **Regular Patron** can either **cancel the hold** or **check out the book** +- While the book is **checked out** the **hold is completed** and so the **returning process** starts +- Whenever a new day starts, we check the **daily sheet** if a hold is not hanging for too long. If so, +the **book hold is expired** + +On the high level, the process looks clear now, but it does not work the way, that all of us +interpreted each word written no the sticky note similarly. That's why we established some definitions: + +![Definitions](images/es/bigpicture/definitions-1.png) + +Similar discoveries were made around **open-ened book holding** process: + +![Open ended book holding](images/es/bigpicture/open-ended-holding-process.png) + +- A **Researcher Patron** can **place a book on an open-ended hold** +- A **Researcher Patron** can either **cancel the hold** or **checkout a book** +- While the book is **checkedout** the **hold is completed** and so the **returning process** starts +- Within the **open-ended holding** a **hold** cannot **expire** (mind the lack of **hold expired** event) + +All right. These two processes are very similar. The part that they have in common, and we know nothing about +it yet is called the **book returning process**: + +![Book returning process](images/es/bigpicture/the-book-returning-process.png) + +Here's what you see there described with words: +- **Any Patron** can **return a book** +- If the **checkout is overdue**, it is being unregistered as soon as the **book is returned** +- In the moment of **returning a book** we start the process of **Fees application** +- From the moment of **book checkout**, a patron might not return the book on time. Whenever a **new day starts** +we check the **daily sheet** find and **register overdue checkouts** + +Wait, but what is this **checkout**? + +![Definitions](images/es/bigpicture/definitions-2.png) + +_- OK, now tell me what is this fee application process_ +_- Nope, it is not relevant by now, will get back to it later_ +_- But wait, why? Shouldn't you get the full picture from the storming?_ +_- Yes, but remember, the time has its cost. You always need to focus on the most relevant (at this moment) business part. +I promise to get back to this at the next workshop._ +_- Fair enough!_ + +Fundamental question that raises now is _where do these books come from?_ Looking again at the domain description, +we have a notion of a **catalogue**. We modelled it accordingly: + +![Catalogue](images/es/bigpicture/book-catalogue.png) + +Here's what happens: +- A **library employee** can add a book into a catalogue +- A specific **book instance** can be **added** as well, thanks to which it can be made **available** under some not +defined yet policy +- Both **Book removed from catalogue** and **Book instance removed from catalogue** are marked with **hot spots**, +as they became problematic. We left answering those problems for the future. + +There is one interesting thing we can spot in this simple **catalogue** flow. **A book** is not the same **book** that +we had in previous processes. To make things clear, let's have a look at new definitions: + +![Catalogue definitions](images/es/bigpicture/book-catalogue-definitions.png) + +Spotting such differences helps us in drawing linguistic boundaries, that are one of the heuristics for defining +**bounded contexts**. From this moment on, we can assume that we have at least two **bounded contexts**: +* **lending** - context containing all business processes logically connected with book lending, including holding, +checkout, and return +* **catalogue** - contexts for cataloguing books and their instances + +__More information on bounded contexts' defining will be added soon__ + +This is more or less where the first iteration of _Big Picture EventStorming_ finished. After this phase +we had a good understanding of how library processes work on high level, and, what is an invaluable outcome, +we got the **ubiquitous language** including well described definitions, and initial **bounded contexts**. \ No newline at end of file diff --git a/docs/c4/component-diagram.png b/docs/c4/component-diagram.png new file mode 100644 index 0000000..ddafc54 Binary files /dev/null and b/docs/c4/component-diagram.png differ diff --git a/docs/design-level.md b/docs/design-level.md new file mode 100644 index 0000000..e39a7bb --- /dev/null +++ b/docs/design-level.md @@ -0,0 +1,239 @@ +# Design Level EventStorming + +As soon as we got our examples written down, we could start digging deep into each of them, identifying key interactions +with the system, spotting business rules and constantly refining the model. In the following sections you will +find mentioned examples modelled with Design Level EventStorming. + +## Holding +### Regular patron + +The first example is _the one when regular patron tries to place his 6th hold_: + +![Holding example 1](images/dl/holding/example-1.png) + +What you can see here is that we are assuming, that a particular patron has already placed 5 books on hold. +Next, in order to place one more, a patron needs to interact with _the system_ somehow, so this is the reason +for placing a blue sticky note representing a command called **place on hold**. In order to make such decision, +a patron needs to have some view of the book that can be potentially placed on hold (green sticky note). +Because the regular patron cannot place more than 5 books on hold, we could identify a rule (rectangular yellow sticky note), +that describes conditions that needs to be fulfilled for the **Book hold failed** event to occur. + +Fair enough, let's go further. + +When a **patron** tries to place on hold a book that is currently not available it should not be possible, thus resulting +in **book hold failed** event, as it is depicted below: + +![Holding example 2](images/dl/holding/example-2.png) + +Taking a look at the domain description again, we find out that each patron can have no more than 2 **overdue checkouts**. +In such situation, every attempt to **place a book on hold** should fail: + +![Holding example 3](images/dl/holding/example-3.png) + +If we are talking about **regular patrons**, what is special about them is that they are not allowed to hold a +**restricted book**: + +![Holding example 4](images/dl/holding/example-4.png) + +Second thing that is not allowed for a **regular patron** is **open-ended** hold: +![Holding example 12](images/dl/holding/example-12.png) + +All right, enough with failures, let patrons lend some books, eventually: + +![Holding example 5](images/dl/holding/example-5.png) + +Having in mind all previous examples, we discovered following conditions that need to be fulfilled for **patron** to +**place a book on hold**: +* Book must be available +* Book must not be **restricted** +* At the moment of placing a hold, a patron cannot have more than 4 holds +* Patron cannot have more than 1 overdue checkout + +And here is the last example, partially covered before: + +![Holding example 6](images/dl/holding/example-6.png) + +### Researcher patron + +In the previous part of this paragraph we focused on a *regular patron* only. Let's have a look at *researcher patron* now. +The domain description clearly states that **any** patron with more than 2 **overdue checkouts** will get a rejection +when trying to place book on hold. So we have it modelled: + +![Holding example 7](images/dl/holding/example-7.png) + +There is also no exception in terms of holding a book that is **not available**: + +![Holding example 8](images/dl/holding/example-8.png) + +The thing that differentiates **researcher patron** from a **regular** one is that he/she can place on hold a **restricted** +book: + +![Holding example 9](images/dl/holding/example-9.png) + +Last three examples depict successful holding scenarios: + +![Holding example 10](images/dl/holding/example-10.png) +![Holding example 11](images/dl/holding/example-11.png) +![Holding example 13](images/dl/holding/example-13.png) + +## Canceling a hold + +Any patron can cancel the hold. The unbreakable condition to be fulfilled is the one that the hold exists. +If it is not the case **book hold cancelling failed** event occurs. What you can spot here is that now the **patron**, +in order to cancel a hold, he/she needs to have a view of current holds (mind the **Holds view** green sticky note). + +![Canceling hold example 1](images/dl/cancelinghold/example-1.png) + +If the hold is present, then it should be possible to cancel it: + +![Canceling hold example 2](images/dl/cancelinghold/example-2.png) + +We also need to take care of the scenario when a **patron** tries to **cancel a hold** that was actually +not placed by himself/herself: + +![Canceling hold example 3](images/dl/cancelinghold/example-3.png) + +It shouldn't be also possible to **cancel a hold** twice: + +![Canceling hold example 5](images/dl/cancelinghold/example-5.png) + +Getting back to holding-related examples, let's try to join them with hold cancellation. Each **patron** can have no more +than five holds at a particular point in time. Thus, cancelling one of them should be enough for **patron** to **place +on hold** another book: + +![Canceling hold example 4](images/dl/cancelinghold/example-4.png) + +## Checkout + +Checking out is actually the essence of library functioning. **Any patron** can checkout a hold, but it is only possible +when the **hold** exists: + +![Checkout example 1](images/dl/bookcheckouts/example-1.png) + +It is also not allowed to checkout someone else's hold: + +![Checkout example 2](images/dl/bookcheckouts/example-2.png) + +An example summing things up is depicted below: + +![Checkout example 3](images/dl/bookcheckouts/example-3.png) + +A real-life scenario could be that a **patron** cancels his/her hold, and tries to check the book out: + +![Checkout example 4](images/dl/bookcheckouts/example-4.png) + +It might also happen that a **patron** has the hold, whereas the book is missing in a library: + +![Checkout example 5](images/dl/bookcheckouts/example-5.png) + +## Expiring a hold + +According to the domain description, any **close-ended hold** is active until it is either checked out by **patron** or +expired. The expiration check is done automatically by the system at the **beginning of the day**. In order to find holds +that qualify to expiration, a system needs to have a read model of such entries. Domain description names it a **Daily sheet** +(please mind the green sticky note) + +![Expiring hold example 1](images/dl/expiringhold/example-1.png) + +When the book is **placed on hold** and the hold is **cancelled** before its expiration due date, it shouldn't be registered +as expired hold: + +![Expiring hold example 2](images/dl/expiringhold/example-2.png) + +The expiration check should mark each hold as expired only once: + +![Expiring hold example 3](images/dl/expiringhold/example-3.png) + +## Registering overdue checkout + +Each book can be checked out for not longer than 60 days. **Overdue checkouts** are identified on a daily basis by looking +at the **Daily sheet** (please mind the green sticky note): + +![Overdue checkout example 1](images/dl/overduecheckouts/example-1.png) + +Moreover we do not expect the **returned book** to be ever registered as **overdue checkout**: + +![Overdue checkout example 2](images/dl/overduecheckouts/example-2.png) + +## Adding to catalogue + +The last area of analysis is the book **catalogue**. Catalogue is a collection of books and their instances. +A book instance can be added only when there is a book with matching ISBN already registered in the catalogue: + +![Catalogue example 1](images/dl/addingtocatalogue/example-1.png) + +If this is not the case, adding a book instance into catalogue should end up with failure. +![Catalogue example 2](images/dl/addingtocatalogue/example-2.png) + + +## Bounded Context Classification + +Until now, we have already identified two **bounded contexts** - **lending contexts**, and **catalogue contexts**. +Having in mind the domain description, and looking at the amount of discovered business rules, we can clearly see, +that **lending context** is the one that requires a lot of attention. Comparing the business complexities of both +contexts led us to conclusion that using **tactical building blocks** of **Domain Driven Design** and applying +**hexagonal architecture** are a reasonable choice for **lending context** while **catalogue context** is just +a simple **CRUD**, and applying the same local architecture would be over-engineering. + +You may ask yourself now: __how do you know that **catalogue context** is a CRUD?__. Here's a heuristic. +If most of the events, named as verbs in past tense, are triggered by commands, being named with the same verbs +but as imperatives, then it means we are probably just creating, updating, or deleting an object from some database. +Moreover, if there are no specific (or very little) business rules, then it might suggest that the essential complexity +sourced in the business is low enough for CRUD to be well applicable. + +## Aggregates + +What you could see in the above examples is that we have not specified the **aggregates** that would be responsible for +handling commands and emitting events. +Such approach keeps us away from being steered into a particular solution/language and consequently limited from the very +beginning. Looking at behaviours and responsibilities first lets us understand the problem better, and thus find +a better name of the **aggregate**. In this paragraph you will see how we worked out the final aggregate model. + +The first shot was to use **Book** as an aggregate. We are __placing **a book** on hold__, __cancelling the hold for **a book**__, +__checking **a book** out__ - all this sentences make logical sense, and even suits linguistically: + +![Aggregate 1](images/aggregates/agg-1.png) + +The first question that raised, was: __What about the invariants? Do they apply to a book?__. Well, not only. +When you take a look again at the rules that we discovered in previous paragraphs, you will see things like: +* is the patron a **regular** one or a **researcher**? +* is patron's maximum number of holds reached? +* is patron's maximum number of patron's overdue checkouts reached? +* is book available? +* is book restricted? +Book availability and its potential restriction (which is actually a property/characteristic) does not seem to be +as critical as those connected with patrons. Secondly, we have more patron-related rules than book-related ones. + +OK, but why don't we just pass the **Patron** object into **Book's** methods like: +```java +book.placeOnHoldBy(patron); +``` +We could, but it is the **patron** that knows more invariants, and we do not want to let any other object to protect them. +Here is the alternative, then: + +![Aggregate 2](images/aggregates/agg-2.png) + +Okay, so now in order to for example __place a hold__ we need to pass a **Book** object into a **Patron**, right? + +```java +patron.hold(book); +``` + +Then, if both patron's and book's invariants pass, we would modify patron and book aggregates. But doesn't it sound like +modifying two different aggregates in one transaction? Moreover, there is one more catch. Book's invariants (including its +availability) are just our "best wish". Our book model is just an abstraction of the real world books to lend in a library. +Why? Because in the real world a book that is placed on hold, might be found damaged or lost in the meantime. +Patron's invariants are more likely to be up to date and "driven" by our system. Gauges like number of holds, overdue checkouts +are much easier to be "real ones". This in turn means that it is okay to follow (suggested - after all) eventual consistency +model of inter-aggregate communication. It would make our model more realistic. Classes would be smaller, and easier +to work with and to maintain. + +We have 2 aggregates now. We could revise the decision of *Patron* being the first aggregate to be modified, and the +**Book** being consistent in the future (eventually). We have already concluded that the **Book** is just a nice +projection of the real world plus patron has more invariants to drive the process. Also, these invariants are more +likely to be relevant. It is also probably less harmful, then, to place on hold a book which is actually not available +(and run compensation process) than let patrons place books on hold while having overdue checkouts. + +Now the final model is following: + +![Aggregate 3](images/aggregates/agg-3.png) diff --git a/docs/example-mapping.md b/docs/example-mapping.md new file mode 100644 index 0000000..c3ad859 --- /dev/null +++ b/docs/example-mapping.md @@ -0,0 +1,37 @@ +# Example Mapping + +After the Big Picture EventStorming we had a high level overview of the domain. The next step could have been +to start Design Level EventStorming right away. EventStorming, considered as a tool, can be modified +and adjusted to current needs, thus taking different forms. We could have dug deeper into the topic during Big Picture +session and model all possible paths and scenarios with events, policies and rules then. In that case, +it might have been difficult to spot particular business scenarios and prioritize them, as they would be spread +throughout the wall. The alternative that we chose to apply, was to discover those scenarios first, and model each +of them separately with Design Level EventStorming. The intermediate step is called Example Mapping. In the following +paragraphs, you will find results of this session. + +_Please note that this is a simplified variation of Example Mapping, where we just group examples by business areas, +not focusing on the rules, or use cases from the original technique_ + +## Holding + +![Holding](images/em/holding.png) + +## Checkout + +![Checkout](images/em/checking-out.png) + +## Expiring hold + +![Expiring hold](images/em/expiring-hold.png) + +## Canceling hold + +![Canceling hold](images/em/expiring-hold.png) + +## Overdue checkouts + +![Overdue checkouts](images/em/overdue-checkouts.png) + +## Adding to catalogue + +![Adding to catalogue](images/em/adding-to-catalogue.png) \ No newline at end of file diff --git a/docs/images/aggregates/agg-1.png b/docs/images/aggregates/agg-1.png new file mode 100644 index 0000000..fe99011 Binary files /dev/null and b/docs/images/aggregates/agg-1.png differ diff --git a/docs/images/aggregates/agg-2.png b/docs/images/aggregates/agg-2.png new file mode 100644 index 0000000..048a5ad Binary files /dev/null and b/docs/images/aggregates/agg-2.png differ diff --git a/docs/images/aggregates/agg-3.png b/docs/images/aggregates/agg-3.png new file mode 100644 index 0000000..8a0494b Binary files /dev/null and b/docs/images/aggregates/agg-3.png differ diff --git a/docs/images/architecture-big-picture.png b/docs/images/architecture-big-picture.png new file mode 100644 index 0000000..77a1130 Binary files /dev/null and b/docs/images/architecture-big-picture.png differ diff --git a/docs/images/dl/addingtocatalogue/example-1.png b/docs/images/dl/addingtocatalogue/example-1.png new file mode 100644 index 0000000..f8eca4b Binary files /dev/null and b/docs/images/dl/addingtocatalogue/example-1.png differ diff --git a/docs/images/dl/addingtocatalogue/example-2.png b/docs/images/dl/addingtocatalogue/example-2.png new file mode 100644 index 0000000..42e9370 Binary files /dev/null and b/docs/images/dl/addingtocatalogue/example-2.png differ diff --git a/docs/images/dl/bookcheckouts/example-1.png b/docs/images/dl/bookcheckouts/example-1.png new file mode 100644 index 0000000..fd40170 Binary files /dev/null and b/docs/images/dl/bookcheckouts/example-1.png differ diff --git a/docs/images/dl/bookcheckouts/example-2.png b/docs/images/dl/bookcheckouts/example-2.png new file mode 100644 index 0000000..e9f97b3 Binary files /dev/null and b/docs/images/dl/bookcheckouts/example-2.png differ diff --git a/docs/images/dl/bookcheckouts/example-3.png b/docs/images/dl/bookcheckouts/example-3.png new file mode 100644 index 0000000..ada2aba Binary files /dev/null and b/docs/images/dl/bookcheckouts/example-3.png differ diff --git a/docs/images/dl/bookcheckouts/example-4.png b/docs/images/dl/bookcheckouts/example-4.png new file mode 100644 index 0000000..2789e6f Binary files /dev/null and b/docs/images/dl/bookcheckouts/example-4.png differ diff --git a/docs/images/dl/bookcheckouts/example-5.png b/docs/images/dl/bookcheckouts/example-5.png new file mode 100644 index 0000000..07a277a Binary files /dev/null and b/docs/images/dl/bookcheckouts/example-5.png differ diff --git a/docs/images/dl/cancelinghold/example-1.png b/docs/images/dl/cancelinghold/example-1.png new file mode 100644 index 0000000..b359415 Binary files /dev/null and b/docs/images/dl/cancelinghold/example-1.png differ diff --git a/docs/images/dl/cancelinghold/example-2.png b/docs/images/dl/cancelinghold/example-2.png new file mode 100644 index 0000000..f041682 Binary files /dev/null and b/docs/images/dl/cancelinghold/example-2.png differ diff --git a/docs/images/dl/cancelinghold/example-3.png b/docs/images/dl/cancelinghold/example-3.png new file mode 100644 index 0000000..1417f01 Binary files /dev/null and b/docs/images/dl/cancelinghold/example-3.png differ diff --git a/docs/images/dl/cancelinghold/example-4.png b/docs/images/dl/cancelinghold/example-4.png new file mode 100644 index 0000000..deb68a2 Binary files /dev/null and b/docs/images/dl/cancelinghold/example-4.png differ diff --git a/docs/images/dl/cancelinghold/example-5.png b/docs/images/dl/cancelinghold/example-5.png new file mode 100644 index 0000000..2bb0494 Binary files /dev/null and b/docs/images/dl/cancelinghold/example-5.png differ diff --git a/docs/images/dl/expiringhold/example-1.png b/docs/images/dl/expiringhold/example-1.png new file mode 100644 index 0000000..ce95f5e Binary files /dev/null and b/docs/images/dl/expiringhold/example-1.png differ diff --git a/docs/images/dl/expiringhold/example-2.png b/docs/images/dl/expiringhold/example-2.png new file mode 100644 index 0000000..9ea0fde Binary files /dev/null and b/docs/images/dl/expiringhold/example-2.png differ diff --git a/docs/images/dl/expiringhold/example-3.png b/docs/images/dl/expiringhold/example-3.png new file mode 100644 index 0000000..dafe091 Binary files /dev/null and b/docs/images/dl/expiringhold/example-3.png differ diff --git a/docs/images/dl/holding/example-1.png b/docs/images/dl/holding/example-1.png new file mode 100644 index 0000000..3225840 Binary files /dev/null and b/docs/images/dl/holding/example-1.png differ diff --git a/docs/images/dl/holding/example-10.png b/docs/images/dl/holding/example-10.png new file mode 100644 index 0000000..2cd126a Binary files /dev/null and b/docs/images/dl/holding/example-10.png differ diff --git a/docs/images/dl/holding/example-11.png b/docs/images/dl/holding/example-11.png new file mode 100644 index 0000000..8f07947 Binary files /dev/null and b/docs/images/dl/holding/example-11.png differ diff --git a/docs/images/dl/holding/example-12.png b/docs/images/dl/holding/example-12.png new file mode 100644 index 0000000..31364c7 Binary files /dev/null and b/docs/images/dl/holding/example-12.png differ diff --git a/docs/images/dl/holding/example-13.png b/docs/images/dl/holding/example-13.png new file mode 100644 index 0000000..bfd0fc5 Binary files /dev/null and b/docs/images/dl/holding/example-13.png differ diff --git a/docs/images/dl/holding/example-2.png b/docs/images/dl/holding/example-2.png new file mode 100644 index 0000000..a9144d4 Binary files /dev/null and b/docs/images/dl/holding/example-2.png differ diff --git a/docs/images/dl/holding/example-3.png b/docs/images/dl/holding/example-3.png new file mode 100644 index 0000000..586a2b5 Binary files /dev/null and b/docs/images/dl/holding/example-3.png differ diff --git a/docs/images/dl/holding/example-4.png b/docs/images/dl/holding/example-4.png new file mode 100644 index 0000000..cd0c52c Binary files /dev/null and b/docs/images/dl/holding/example-4.png differ diff --git a/docs/images/dl/holding/example-5.png b/docs/images/dl/holding/example-5.png new file mode 100644 index 0000000..9640582 Binary files /dev/null and b/docs/images/dl/holding/example-5.png differ diff --git a/docs/images/dl/holding/example-6.png b/docs/images/dl/holding/example-6.png new file mode 100644 index 0000000..aeca569 Binary files /dev/null and b/docs/images/dl/holding/example-6.png differ diff --git a/docs/images/dl/holding/example-7.png b/docs/images/dl/holding/example-7.png new file mode 100644 index 0000000..633b446 Binary files /dev/null and b/docs/images/dl/holding/example-7.png differ diff --git a/docs/images/dl/holding/example-8.png b/docs/images/dl/holding/example-8.png new file mode 100644 index 0000000..efd9b7b Binary files /dev/null and b/docs/images/dl/holding/example-8.png differ diff --git a/docs/images/dl/holding/example-9.png b/docs/images/dl/holding/example-9.png new file mode 100644 index 0000000..1aecf1d Binary files /dev/null and b/docs/images/dl/holding/example-9.png differ diff --git a/docs/images/dl/overduecheckouts/example-1.png b/docs/images/dl/overduecheckouts/example-1.png new file mode 100644 index 0000000..6394dc7 Binary files /dev/null and b/docs/images/dl/overduecheckouts/example-1.png differ diff --git a/docs/images/dl/overduecheckouts/example-2.png b/docs/images/dl/overduecheckouts/example-2.png new file mode 100644 index 0000000..0f20053 Binary files /dev/null and b/docs/images/dl/overduecheckouts/example-2.png differ diff --git a/docs/images/em/adding-to-catalogue.png b/docs/images/em/adding-to-catalogue.png new file mode 100644 index 0000000..d29189e Binary files /dev/null and b/docs/images/em/adding-to-catalogue.png differ diff --git a/docs/images/em/canceling-hold.png b/docs/images/em/canceling-hold.png new file mode 100644 index 0000000..474b787 Binary files /dev/null and b/docs/images/em/canceling-hold.png differ diff --git a/docs/images/em/checking-out.png b/docs/images/em/checking-out.png new file mode 100644 index 0000000..d83e6ce Binary files /dev/null and b/docs/images/em/checking-out.png differ diff --git a/docs/images/em/expiring-hold.png b/docs/images/em/expiring-hold.png new file mode 100644 index 0000000..5777287 Binary files /dev/null and b/docs/images/em/expiring-hold.png differ diff --git a/docs/images/em/holding.png b/docs/images/em/holding.png new file mode 100644 index 0000000..608d037 Binary files /dev/null and b/docs/images/em/holding.png differ diff --git a/docs/images/em/overdue-checkouts.png b/docs/images/em/overdue-checkouts.png new file mode 100644 index 0000000..be34b68 Binary files /dev/null and b/docs/images/em/overdue-checkouts.png differ diff --git a/docs/images/es/bigpicture/book-catalogue-definitions.png b/docs/images/es/bigpicture/book-catalogue-definitions.png new file mode 100644 index 0000000..d3ed57f Binary files /dev/null and b/docs/images/es/bigpicture/book-catalogue-definitions.png differ diff --git a/docs/images/es/bigpicture/book-catalogue.png b/docs/images/es/bigpicture/book-catalogue.png new file mode 100644 index 0000000..eedaf8a Binary files /dev/null and b/docs/images/es/bigpicture/book-catalogue.png differ diff --git a/docs/images/es/bigpicture/close-ended-holding-process.png b/docs/images/es/bigpicture/close-ended-holding-process.png new file mode 100644 index 0000000..ddd3f9c Binary files /dev/null and b/docs/images/es/bigpicture/close-ended-holding-process.png differ diff --git a/docs/images/es/bigpicture/definitions-1.png b/docs/images/es/bigpicture/definitions-1.png new file mode 100644 index 0000000..e6e862b Binary files /dev/null and b/docs/images/es/bigpicture/definitions-1.png differ diff --git a/docs/images/es/bigpicture/definitions-2.png b/docs/images/es/bigpicture/definitions-2.png new file mode 100644 index 0000000..1779d88 Binary files /dev/null and b/docs/images/es/bigpicture/definitions-2.png differ diff --git a/docs/images/es/bigpicture/open-ended-holding-process.png b/docs/images/es/bigpicture/open-ended-holding-process.png new file mode 100644 index 0000000..cb07c56 Binary files /dev/null and b/docs/images/es/bigpicture/open-ended-holding-process.png differ diff --git a/docs/images/es/bigpicture/the-book-returning-process.png b/docs/images/es/bigpicture/the-book-returning-process.png new file mode 100644 index 0000000..9063997 Binary files /dev/null and b/docs/images/es/bigpicture/the-book-returning-process.png differ diff --git a/docs/images/eventstorming-big-picture.jpg b/docs/images/eventstorming-big-picture.jpg new file mode 100644 index 0000000..3204a7c Binary files /dev/null and b/docs/images/eventstorming-big-picture.jpg differ diff --git a/docs/images/eventstorming-definitions.png b/docs/images/eventstorming-definitions.png new file mode 100644 index 0000000..11edcf5 Binary files /dev/null and b/docs/images/eventstorming-definitions.png differ diff --git a/docs/images/eventstorming-design-level.jpg b/docs/images/eventstorming-design-level.jpg new file mode 100644 index 0000000..e44d647 Binary files /dev/null and b/docs/images/eventstorming-design-level.jpg differ diff --git a/docs/images/eventstorming-domain-desc.png b/docs/images/eventstorming-domain-desc.png new file mode 100644 index 0000000..aa60919 Binary files /dev/null and b/docs/images/eventstorming-domain-desc.png differ diff --git a/docs/images/example-mapping.png b/docs/images/example-mapping.png new file mode 100644 index 0000000..886d782 Binary files /dev/null and b/docs/images/example-mapping.png differ diff --git a/docs/images/placing-on-hold-policy-max.png b/docs/images/placing-on-hold-policy-max.png new file mode 100644 index 0000000..6fa3d45 Binary files /dev/null and b/docs/images/placing-on-hold-policy-max.png differ diff --git a/docs/images/placing-on-hold-policy-open-ended.png b/docs/images/placing-on-hold-policy-open-ended.png new file mode 100644 index 0000000..f741010 Binary files /dev/null and b/docs/images/placing-on-hold-policy-open-ended.png differ diff --git a/docs/images/placing-on-hold-policy-overdue.png b/docs/images/placing-on-hold-policy-overdue.png new file mode 100644 index 0000000..17a1269 Binary files /dev/null and b/docs/images/placing-on-hold-policy-overdue.png differ diff --git a/docs/images/placing-on-hold-policy-restricted.png b/docs/images/placing-on-hold-policy-restricted.png new file mode 100644 index 0000000..fa4def6 Binary files /dev/null and b/docs/images/placing-on-hold-policy-restricted.png differ diff --git a/docs/images/placing_on_hold.jpg b/docs/images/placing_on_hold.jpg new file mode 100644 index 0000000..a74bba0 Binary files /dev/null and b/docs/images/placing_on_hold.jpg differ diff --git a/lombok.config b/lombok.config new file mode 100644 index 0000000..8f7e8aa --- /dev/null +++ b/lombok.config @@ -0,0 +1 @@ +lombok.addLombokGeneratedAnnotation = true \ No newline at end of file diff --git a/monitoring/grafana/config.monitoring b/monitoring/grafana/config.monitoring new file mode 100644 index 0000000..aaba4e0 --- /dev/null +++ b/monitoring/grafana/config.monitoring @@ -0,0 +1,2 @@ +GF_SECURITY_ADMIN_PASSWORD=password +GF_USERS_ALLOW_SIGN_UP=false diff --git a/monitoring/grafana/jvm-micrometer_rev8.json b/monitoring/grafana/jvm-micrometer_rev8.json new file mode 100644 index 0000000..c0594e1 --- /dev/null +++ b/monitoring/grafana/jvm-micrometer_rev8.json @@ -0,0 +1,3199 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "4.6.4" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "singlestat", + "name": "Singlestat", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "limit": 100, + "name": "Annotations & Alerts", + "showIn": 0, + "type": "dashboard" + }, + { + "datasource": "${DS_PROMETHEUS}", + "enable": true, + "expr": "resets(process_uptime_seconds{application=\"$application\", instance=\"$instance\"}[1m]) > 0", + "iconColor": "rgba(255, 96, 96, 1)", + "name": "Restart Detection", + "showIn": 0, + "step": "1m", + "tagKeys": "restart-tag", + "textFormat": "uptime reset", + "titleFormat": "Restart" + } + ] + }, + "description": "Dashboard for Micrometer instrumented applications (Java, Spring Boot, Micronaut)", + "editable": true, + "gnetId": 4701, + "graphTooltip": 1, + "hideControls": false, + "id": null, + "links": [], + "refresh": "30s", + "rows": [ + { + "collapse": false, + "height": "100px", + "panels": [ + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "${DS_PROMETHEUS}", + "decimals": 1, + "editable": true, + "error": false, + "format": "s", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "height": "", + "id": 63, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "70%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "", + "targets": [ + { + "expr": "process_uptime_seconds{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "metric": "", + "refId": "A", + "step": 14400 + } + ], + "thresholds": "", + "title": "Uptime", + "transparent": false, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "${DS_PROMETHEUS}", + "decimals": null, + "editable": true, + "error": false, + "format": "dateTimeAsIso", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "height": "", + "id": 92, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "70%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "", + "targets": [ + { + "expr": "process_start_time_seconds{application=\"$application\", instance=\"$instance\"}*1000", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "metric": "", + "refId": "A", + "step": 14400 + } + ], + "thresholds": "", + "title": "Start time", + "transparent": false, + "type": "singlestat", + "valueFontSize": "70%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "${DS_PROMETHEUS}", + "decimals": 2, + "editable": true, + "error": false, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 65, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "70%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "", + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", area=\"heap\"})*100/sum(jvm_memory_max_bytes{application=\"$application\",instance=\"$instance\", area=\"heap\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A", + "step": 14400 + } + ], + "thresholds": "70,90", + "title": "Heap used", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "${DS_PROMETHEUS}", + "decimals": 2, + "editable": true, + "error": false, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 75, + "interval": null, + "links": [], + "mappingType": 2, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "70%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + }, + { + "from": "-99999999999999999999999999999999", + "text": "N/A", + "to": "0" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "", + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", area=\"nonheap\"})*100/sum(jvm_memory_max_bytes{application=\"$application\",instance=\"$instance\", area=\"nonheap\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A", + "step": 14400 + } + ], + "thresholds": "70,90", + "title": "Non-Heap used", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + }, + { + "op": "=", + "text": "x", + "value": "" + } + ], + "valueName": "current" + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "Quick Facts", + "titleSize": "h6" + }, + { + "collapse": false, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "fill": 1, + "id": 111, + "legend": { + "avg": false, + "current": true, + "max": false, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{application=\"$application\", instance=\"$instance\"}[1m]))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "HTTP", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Rate", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "decimals": null, + "format": "ops", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { + "HTTP": "#890f02", + "HTTP - 5xx": "#bf1b00" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "fill": 1, + "id": 112, + "legend": { + "avg": false, + "current": true, + "max": false, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{application=\"$application\", instance=\"$instance\", status=~\"5..\"}[1m]))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "HTTP - 5xx", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Errors", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "decimals": null, + "format": "ops", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "fill": 1, + "id": 113, + "legend": { + "avg": false, + "current": true, + "max": false, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_sum{application=\"$application\", instance=\"$instance\", status!~\"5..\"}[1m]))/sum(rate(http_server_requests_seconds_count{application=\"$application\", instance=\"$instance\", status!~\"5..\"}[1m]))", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "HTTP - AVG", + "refId": "A" + }, + { + "expr": "max(http_server_requests_seconds_max{application=\"$application\", instance=\"$instance\", status!~\"5..\"})", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "HTTP - MAX", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Duration", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "s", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "description": "", + "fill": 1, + "id": 119, + "legend": { + "alignAsTable": false, + "avg": false, + "current": true, + "max": false, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "tomcat_threads_busy{application=\"$application\", instance=\"$instance\"} or tomcat_threads_busy_threads{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "TOMCAT - BSY", + "refId": "A" + }, + { + "expr": "tomcat_threads_current{application=\"$application\", instance=\"$instance\"} or tomcat_threads_current_threads{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "TOMCAT - CUR", + "refId": "B" + }, + { + "expr": "tomcat_threads_config_max{application=\"$application\", instance=\"$instance\"} or tomcat_threads_config_max_threads{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "TOMCAT - MAX", + "refId": "C" + }, + { + "expr": "jetty_threads_busy{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "JETTY - BSY", + "refId": "D" + }, + { + "expr": "jetty_threads_current{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "JETTY - CUR", + "refId": "E" + }, + { + "expr": "jetty_threads_config_max{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "JETTY - MAX", + "refId": "F" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Utilisation", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "I/O Overview", + "titleSize": "h6" + }, + { + "collapse": false, + "height": "250px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "id": 24, + "legend": { + "avg": false, + "current": true, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", area=\"heap\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "used", + "metric": "", + "refId": "A", + "step": 2400 + }, + { + "expr": "sum(jvm_memory_committed_bytes{application=\"$application\", instance=\"$instance\", area=\"heap\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "committed", + "refId": "B", + "step": 2400 + }, + { + "expr": "sum(jvm_memory_max_bytes{application=\"$application\", instance=\"$instance\", area=\"heap\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "max", + "refId": "C", + "step": 2400 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "JVM Heap", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": [ + "mbytes", + "short" + ], + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "id": 25, + "legend": { + "avg": false, + "current": true, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", area=\"nonheap\"})", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "used", + "metric": "", + "refId": "A", + "step": 2400 + }, + { + "expr": "sum(jvm_memory_committed_bytes{application=\"$application\", instance=\"$instance\", area=\"nonheap\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "committed", + "refId": "B", + "step": 2400 + }, + { + "expr": "sum(jvm_memory_max_bytes{application=\"$application\", instance=\"$instance\", area=\"nonheap\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "max", + "refId": "C", + "step": 2400 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "JVM Non-Heap", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": [ + "mbytes", + "short" + ], + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "id": 26, + "legend": { + "alignAsTable": false, + "avg": false, + "current": true, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "used", + "metric": "", + "refId": "A", + "step": 2400 + }, + { + "expr": "sum(jvm_memory_committed_bytes{application=\"$application\", instance=\"$instance\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "committed", + "refId": "B", + "step": 2400 + }, + { + "expr": "sum(jvm_memory_max_bytes{application=\"$application\", instance=\"$instance\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "max", + "refId": "C", + "step": 2400 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "JVM Total", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": [ + "mbytes", + "short" + ], + "yaxes": [ + { + "format": "bytes", + "label": "", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "id": 86, + "legend": { + "avg": false, + "current": true, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "process_memory_vss_bytes{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "hide": true, + "intervalFactor": 2, + "legendFormat": "vss", + "metric": "", + "refId": "A", + "step": 2400 + }, + { + "expr": "process_memory_rss_bytes{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "rss", + "refId": "B" + }, + { + "expr": "process_memory_pss_bytes{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "pss", + "refId": "C" + }, + { + "expr": "process_memory_swap_bytes{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "swap", + "refId": "D" + }, + { + "expr": "process_memory_swappss_bytes{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "swappss", + "refId": "E" + }, + { + "expr": "process_memory_pss_bytes{application=\"$application\", instance=\"$instance\"} + process_memory_swap_bytes{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "phys (pss+swap)", + "refId": "F" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "JVM Process Memory", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": [ + "mbytes", + "short" + ], + "yaxes": [ + { + "format": "bytes", + "label": "", + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "JVM Memory", + "titleSize": "h6" + }, + { + "collapse": false, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "id": 106, + "legend": { + "avg": false, + "current": true, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "system_cpu_usage{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "system", + "metric": "", + "refId": "A", + "step": 2400 + }, + { + "expr": "process_cpu_usage{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "process", + "refId": "B" + }, + { + "expr": "avg_over_time(process_cpu_usage{application=\"$application\", instance=\"$instance\"}[1h])", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "process-1h", + "refId": "C" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "CPU", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": [ + "short", + "short" + ], + "yaxes": [ + { + "decimals": 1, + "format": "percentunit", + "label": "", + "logBase": 1, + "max": "1", + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "id": 93, + "legend": { + "avg": false, + "current": true, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "system_load_average_1m{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "system-1m", + "metric": "", + "refId": "A", + "step": 2400 + }, + { + "expr": "", + "format": "time_series", + "intervalFactor": 2, + "refId": "B" + }, + { + "expr": "system_cpu_count{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "cpu", + "refId": "C" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Load", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": [ + "short", + "short" + ], + "yaxes": [ + { + "decimals": 1, + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "id": 32, + "legend": { + "avg": false, + "current": true, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_threads_live{application=\"$application\", instance=\"$instance\"} or jvm_threads_live_threads{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "live", + "metric": "", + "refId": "A", + "step": 2400 + }, + { + "expr": "jvm_threads_daemon{application=\"$application\", instance=\"$instance\"} or jvm_threads_daemon_threads{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "daemon", + "metric": "", + "refId": "B", + "step": 2400 + }, + { + "expr": "jvm_threads_peak{application=\"$application\", instance=\"$instance\"} or jvm_threads_peak_threads{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "peak", + "refId": "C", + "step": 2400 + }, + { + "expr": "process_threads{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "process", + "refId": "D", + "step": 2400 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Threads", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": [ + "short", + "short" + ], + "yaxes": [ + { + "decimals": 0, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { + "blocked": "#bf1b00", + "new": "#fce2de", + "runnable": "#7eb26d", + "terminated": "#511749", + "timed-waiting": "#c15c17", + "waiting": "#eab839" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "fill": 1, + "id": 124, + "legend": { + "alignAsTable": false, + "avg": false, + "current": true, + "max": true, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_threads_states_threads{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{state}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Thread States", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { + "debug": "#1F78C1", + "error": "#BF1B00", + "info": "#508642", + "trace": "#6ED0E0", + "warn": "#EAB839" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "height": "", + "id": 91, + "legend": { + "alignAsTable": false, + "avg": false, + "current": true, + "hideEmpty": false, + "hideZero": false, + "max": true, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": true, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "error", + "yaxis": 1 + }, + { + "alias": "warn", + "yaxis": 1 + } + ], + "spaceLength": 10, + "span": 9, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "increase(logback_events_total{application=\"$application\", instance=\"$instance\"}[1m])", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{level}}", + "metric": "", + "refId": "A", + "step": 1200 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Log Events (1m)", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transparent": false, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": [ + "short", + "short" + ], + "yaxes": [ + { + "decimals": 0, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "id": 61, + "legend": { + "avg": false, + "current": true, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "process_open_fds{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "open", + "metric": "", + "refId": "A", + "step": 2400 + }, + { + "expr": "process_max_fds{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "max", + "metric": "", + "refId": "B", + "step": 2400 + }, + { + "expr": "process_files_open{application=\"$application\", instance=\"$instance\"} or process_files_open_files{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "open", + "refId": "C" + }, + { + "expr": "process_files_max{application=\"$application\", instance=\"$instance\"} or process_files_max_files{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "max", + "refId": "D" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "File Descriptors", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": [ + "short", + "short" + ], + "yaxes": [ + { + "decimals": 0, + "format": "short", + "label": null, + "logBase": 10, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "JVM Misc", + "titleSize": "h6" + }, + { + "collapse": false, + "height": "250px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "id": 3, + "legend": { + "alignAsTable": false, + "avg": false, + "current": true, + "max": true, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "minSpan": 4, + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": "jvm_memory_pool_heap", + "seriesOverrides": [], + "spaceLength": 10, + "span": 12, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", id=~\"$jvm_memory_pool_heap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "used", + "metric": "", + "refId": "A", + "step": 1800 + }, + { + "expr": "jvm_memory_committed_bytes{application=\"$application\", instance=\"$instance\", id=~\"$jvm_memory_pool_heap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "commited", + "metric": "", + "refId": "B", + "step": 1800 + }, + { + "expr": "jvm_memory_max_bytes{application=\"$application\", instance=\"$instance\", id=~\"$jvm_memory_pool_heap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "max", + "metric": "", + "refId": "C", + "step": 1800 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "$jvm_memory_pool_heap", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": [ + "mbytes", + "short" + ], + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": "persistence_counts", + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "JVM Memory Pools (Heap)", + "titleSize": "h6" + }, + { + "collapse": false, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "id": 78, + "legend": { + "alignAsTable": false, + "avg": false, + "current": true, + "max": true, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "minSpan": 4, + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": "jvm_memory_pool_nonheap", + "seriesOverrides": [], + "spaceLength": 10, + "span": 4, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", id=~\"$jvm_memory_pool_nonheap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "used", + "metric": "", + "refId": "A", + "step": 1800 + }, + { + "expr": "jvm_memory_committed_bytes{application=\"$application\", instance=\"$instance\", id=~\"$jvm_memory_pool_nonheap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "commited", + "metric": "", + "refId": "B", + "step": 1800 + }, + { + "expr": "jvm_memory_max_bytes{application=\"$application\", instance=\"$instance\", id=~\"$jvm_memory_pool_nonheap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "max", + "metric": "", + "refId": "C", + "step": 1800 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "$jvm_memory_pool_nonheap", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": [ + "mbytes", + "short" + ], + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "JVM Memory Pools (Non-Heap)", + "titleSize": "h6" + }, + { + "collapse": false, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "fill": 1, + "id": 98, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 4, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "rate(jvm_gc_pause_seconds_count{application=\"$application\", instance=\"$instance\"}[1m])", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "{{action}} ({{cause}})", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Collections", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "ops", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "fill": 1, + "id": 101, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 4, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "rate(jvm_gc_pause_seconds_sum{application=\"$application\", instance=\"$instance\"}[1m])/rate(jvm_gc_pause_seconds_count{application=\"$application\", instance=\"$instance\"}[1m])", + "format": "time_series", + "hide": false, + "instant": false, + "intervalFactor": 1, + "legendFormat": "avg {{action}} ({{cause}})", + "refId": "A" + }, + { + "expr": "jvm_gc_pause_seconds_max{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "hide": false, + "instant": false, + "intervalFactor": 1, + "legendFormat": "max {{action}} ({{cause}})", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Pause Durations", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "s", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "fill": 1, + "id": 99, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 4, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "rate(jvm_gc_memory_allocated_bytes_total{application=\"$application\", instance=\"$instance\"}[1m])", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "allocated", + "refId": "A" + }, + { + "expr": "rate(jvm_gc_memory_promoted_bytes_total{application=\"$application\", instance=\"$instance\"}[1m])", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "promoted", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Allocated/Promoted", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "Bps", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "Garbage Collection", + "titleSize": "h6" + }, + { + "collapse": false, + "height": "250px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "id": 37, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_classes_loaded{application=\"$application\", instance=\"$instance\"} or jvm_classes_loaded_classes{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "loaded", + "metric": "", + "refId": "A", + "step": 1200 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Classes loaded", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": [ + "short", + "short" + ], + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "id": 38, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "delta(jvm_classes_loaded{application=\"$application\",instance=\"$instance\"}[5m]) or delta(jvm_classes_loaded_classes{application=\"$application\",instance=\"$instance\"}[5m])", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "delta", + "metric": "", + "refId": "A", + "step": 1200 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Class delta (5m)", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": [ + "ops", + "short" + ], + "yaxes": [ + { + "decimals": null, + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "Classloading", + "titleSize": "h6" + }, + { + "collapse": false, + "height": "250px", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "id": 33, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_buffer_memory_used_bytes{application=\"$application\", instance=\"$instance\", id=\"direct\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "used", + "metric": "", + "refId": "A", + "step": 2400 + }, + { + "expr": "jvm_buffer_total_capacity_bytes{application=\"$application\", instance=\"$instance\", id=\"direct\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "capacity", + "metric": "", + "refId": "B", + "step": 2400 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Direct Buffers", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": [ + "short", + "short" + ], + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "id": 83, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_buffer_count{application=\"$application\", instance=\"$instance\", id=\"direct\"} or jvm_buffer_count_buffers{application=\"$application\", instance=\"$instance\", id=\"direct\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "count", + "metric": "", + "refId": "A", + "step": 2400 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Direct Buffers", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": [ + "short", + "short" + ], + "yaxes": [ + { + "decimals": 0, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "id": 85, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_buffer_memory_used_bytes{application=\"$application\", instance=\"$instance\", id=\"mapped\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "used", + "metric": "", + "refId": "A", + "step": 2400 + }, + { + "expr": "jvm_buffer_total_capacity_bytes{application=\"$application\", instance=\"$instance\", id=\"mapped\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "capacity", + "metric": "", + "refId": "B", + "step": 2400 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Mapped Buffers", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": [ + "short", + "short" + ], + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "id": 84, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "span": 3, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_buffer_count{application=\"$application\", instance=\"$instance\", id=\"mapped\"} or jvm_buffer_count_buffers{application=\"$application\", instance=\"$instance\", id=\"mapped\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "count", + "metric": "", + "refId": "A", + "step": 2400 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Mapped Buffers", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": [ + "short", + "short" + ], + "yaxes": [ + { + "decimals": 0, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "Buffer Pools", + "titleSize": "h6" + } + ], + "schemaVersion": 14, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "${DS_PROMETHEUS}", + "hide": 0, + "includeAll": false, + "label": "Application", + "multi": false, + "name": "application", + "options": [], + "query": "label_values(application)", + "refresh": 2, + "regex": "", + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allFormat": "glob", + "allValue": null, + "current": {}, + "datasource": "${DS_PROMETHEUS}", + "hide": 0, + "includeAll": false, + "label": "Instance", + "multi": false, + "multiFormat": "glob", + "name": "instance", + "options": [], + "query": "label_values(jvm_memory_used_bytes{application=\"$application\"}, instance)", + "refresh": 2, + "regex": "", + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allFormat": "glob", + "allValue": null, + "current": {}, + "datasource": "${DS_PROMETHEUS}", + "hide": 0, + "includeAll": true, + "label": "JVM Memory Pools Heap", + "multi": false, + "multiFormat": "glob", + "name": "jvm_memory_pool_heap", + "options": [], + "query": "label_values(jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", area=\"heap\"},id)", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allFormat": "glob", + "allValue": null, + "current": {}, + "datasource": "${DS_PROMETHEUS}", + "hide": 0, + "includeAll": true, + "label": "JVM Memory Pools Non-Heap", + "multi": false, + "multiFormat": "glob", + "name": "jvm_memory_pool_nonheap", + "options": [], + "query": "label_values(jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", area=\"nonheap\"},id)", + "refresh": 1, + "regex": "", + "sort": 2, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-24h", + "to": "now" + }, + "timepicker": { + "now": true, + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "JVM (Micrometer)", + "version": 3 +} \ No newline at end of file diff --git a/monitoring/grafana/provisioning/datasources/datasource.yml b/monitoring/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 0000000..1496bbe --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,16 @@ +apiVersion: 1 + +deleteDatasources: + - name: Prometheus + orgId: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + orgId: 1 + url: http://prometheus:9090 + basicAuth: false + isDefault: true + version: 1 + editable: true diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000..6825032 --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,11 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'library' + scrape_interval: 10s + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['library:8080'] + labels: + application: 'library' diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..5551fde --- /dev/null +++ b/mvnw @@ -0,0 +1,286 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven2 Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" + # TODO classpath? +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`which java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + jarUrl="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.4.2/maven-wrapper-0.4.2.jar" + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + wget "$jarUrl" -O "$wrapperJarPath" + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + curl -o "$wrapperJarPath" "$jarUrl" + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..e5cfb0a --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,161 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven2 Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing my setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" +if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.4.2/maven-wrapper-0.4.2.jar" +FOR /F "tokens=1,2 delims==" %%A IN (%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties) DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + echo Found %WRAPPER_JAR% +) else ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + powershell -Command "(New-Object Net.WebClient).DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')" + echo Finished downloading %WRAPPER_JAR% +) +@REM End of extension + +%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" +if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%" == "on" pause + +if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% + +exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..ee4f7fc --- /dev/null +++ b/pom.xml @@ -0,0 +1,263 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.2.0.M6 + + + io.pillopl + library + 0.0.1-SNAPSHOT + library + Domain-Driven Design example + + + 11 + 1.0.0.BUILD-SNAPSHOT + + + + + central + https://repo1.maven.org/maven2/ + + + spring-libs-snapshot + https://repo.spring.io/snapshot + + + repository.spring.milestone + Spring Milestone Repository + https://repo.spring.io/milestone + + + + + + central + https://repo1.maven.org/maven2/ + + + spring-libs-snapshot + https://repo.spring.io/snapshot + + + repository.spring.release + Spring GA Repository + https://repo.spring.io/plugins-release/ + + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-actuator + + + + io.vavr + vavr + 0.9.2 + + + + org.springframework.data + spring-data-jdbc + + + + org.springframework.boot + spring-boot-starter-hateoas + + + + io.micrometer + micrometer-registry-prometheus + + + + org.projectlombok + lombok + 1.18.4 + provided + + + + com.tngtech.archunit + archunit-junit4 + 0.9.3 + + + + org.codehaus.groovy + groovy + test + + + + org.spockframework + spock-spring + 1.2-groovy-2.5 + test + + + + org.spockframework + spock-core + 1.2-groovy-2.5 + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.h2database + h2 + 1.4.197 + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.20 + + + **/*Test.java + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-failsafe-plugin + 2.22.1 + + + integration-tests + + verify + + + + + + org.codehaus.gmavenplus + gmavenplus-plugin + 1.6.2 + + + + compileTests + addTestSources + + + + + + + src/test/groovy + + **/*.groovy + + + + src/integration-test/groovy + + **/*.groovy + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 1.12 + + + add-integration-test-resources + generate-test-resources + + add-test-resource + + + + + src/integration-test/resources + true + + + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.3 + + + prepare-agent + + prepare-agent + + + + prepare-agent-integration + + prepare-agent-integration + + + + merge-results + verify + + merge + + + + + target + + *.exec + + + + target/jacoco-all.exec + + + + report + verify + + report + + + target/jacoco-all.exec + + + + + + + + diff --git a/src/integration-test/groovy/io/pillopl/library/catalogue/CatalogueDatabaseIT.groovy b/src/integration-test/groovy/io/pillopl/library/catalogue/CatalogueDatabaseIT.groovy new file mode 100644 index 0000000..44845de --- /dev/null +++ b/src/integration-test/groovy/io/pillopl/library/catalogue/CatalogueDatabaseIT.groovy @@ -0,0 +1,46 @@ +package io.pillopl.library.catalogue + +import io.vavr.control.Option +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import spock.lang.Specification + +import static io.pillopl.library.catalogue.BookFixture.DDD +import static io.pillopl.library.catalogue.BookFixture.NON_PRESENT_ISBN +import static io.pillopl.library.catalogue.BookInstance.instanceOf +import static io.pillopl.library.catalogue.BookType.Restricted + +@SpringBootTest(classes = CatalogueConfiguration.class) +class CatalogueDatabaseIT extends Specification { + + @Autowired + CatalogueDatabase catalogueDatabase + + def 'should be able to save and load new book'() { + given: + Book book = DDD + when: + catalogueDatabase.saveNew(book) + and: + Option ddd = catalogueDatabase.findBy(book.bookIsbn) + then: + ddd.isDefined() + ddd.get() == book + } + + def 'should not load not present book'() { + when: + Option ddd = catalogueDatabase.findBy(NON_PRESENT_ISBN) + then: + ddd.isEmpty() + } + + def 'should save book instance'() { + when: + catalogueDatabase.saveNew(instanceOf(DDD, Restricted)) + then: + noExceptionThrown() + } + + +} diff --git a/src/integration-test/groovy/io/pillopl/library/common/events/publisher/DomainEventsTestConfig.java b/src/integration-test/groovy/io/pillopl/library/common/events/publisher/DomainEventsTestConfig.java new file mode 100644 index 0000000..ffd418a --- /dev/null +++ b/src/integration-test/groovy/io/pillopl/library/common/events/publisher/DomainEventsTestConfig.java @@ -0,0 +1,25 @@ +package io.pillopl.library.common.events.publisher; + +import io.micrometer.core.instrument.MeterRegistry; +import io.pillopl.library.commons.events.DomainEvents; +import io.pillopl.library.commons.events.publisher.JustForwardDomainEventPublisher; +import io.pillopl.library.commons.events.publisher.MeteredDomainEventPublisher; +import io.pillopl.library.commons.events.publisher.StoreAndForwardDomainEventPublisher; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@Configuration +public class DomainEventsTestConfig { + + @Bean + @Primary + DomainEvents domainEventsWithStorage(ApplicationEventPublisher applicationEventPublisher, MeterRegistry meterRegistry) { + return new StoreAndForwardDomainEventPublisher( + new MeteredDomainEventPublisher( + new JustForwardDomainEventPublisher(applicationEventPublisher), meterRegistry), + new InMemoryEventsStorage() + ); + } +} diff --git a/src/integration-test/groovy/io/pillopl/library/common/events/publisher/InMemoryEventsStorage.java b/src/integration-test/groovy/io/pillopl/library/common/events/publisher/InMemoryEventsStorage.java new file mode 100644 index 0000000..15c08c4 --- /dev/null +++ b/src/integration-test/groovy/io/pillopl/library/common/events/publisher/InMemoryEventsStorage.java @@ -0,0 +1,29 @@ +package io.pillopl.library.common.events.publisher; + +import io.pillopl.library.commons.events.DomainEvent; +import io.pillopl.library.commons.events.publisher.EventsStorage; +import io.vavr.collection.List; + +import java.util.ArrayList; +import java.util.Collections; + +public class InMemoryEventsStorage implements EventsStorage { + + //it's not thread safe, enough for testing + private final java.util.List eventList = Collections.synchronizedList(new ArrayList<>()); + + @Override + synchronized public void save(DomainEvent event) { + eventList.add(event); + } + + @Override + synchronized public List toPublish() { + return List.ofAll(eventList); + } + + @Override + synchronized public void published(List events) { + eventList.removeAll(events.asJava()); + } +} diff --git a/src/integration-test/groovy/io/pillopl/library/common/events/publisher/MeteredDomainEventPublisherIT.groovy b/src/integration-test/groovy/io/pillopl/library/common/events/publisher/MeteredDomainEventPublisherIT.groovy new file mode 100644 index 0000000..002bfab --- /dev/null +++ b/src/integration-test/groovy/io/pillopl/library/common/events/publisher/MeteredDomainEventPublisherIT.groovy @@ -0,0 +1,56 @@ +package io.pillopl.library.common.events.publisher + +import groovy.transform.CompileStatic +import io.micrometer.core.instrument.MeterRegistry +import io.pillopl.library.commons.events.DomainEvent +import io.pillopl.library.commons.events.publisher.MeteredDomainEventPublisher +import io.pillopl.library.lending.LendingTestContext +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import spock.lang.Specification + +import java.time.Instant + +@SpringBootTest(classes = [LendingTestContext.class, DomainEventsTestConfig.class]) +class MeteredDomainEventPublisherIT extends Specification { + + @Autowired + MeterRegistry meterRegistry + + @Autowired + MeteredDomainEventPublisher publisher + + def "should count published events"() { + when: + publisher.publish(new TestEvent()) + then: + countedEvents("domain_events", "name", "TestEvent") == 1.0 + when: + publisher.publish(new TestEvent()) + then: + countedEvents("domain_events", "name", "TestEvent") == 2.0 + } + + def countedEvents(String metricName, String key, String tag) { + meterRegistry.counter(metricName, key, tag).count() + } +} + +@CompileStatic +class TestEvent implements DomainEvent { + + @Override + UUID getEventId() { + return null + } + + @Override + UUID getAggregateId() { + return null + } + + @Override + Instant getWhen() { + return null + } +} diff --git a/src/integration-test/groovy/io/pillopl/library/lending/LendingTestContext.java b/src/integration-test/groovy/io/pillopl/library/lending/LendingTestContext.java new file mode 100644 index 0000000..13ace1a --- /dev/null +++ b/src/integration-test/groovy/io/pillopl/library/lending/LendingTestContext.java @@ -0,0 +1,9 @@ +package io.pillopl.library.lending; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +@Configuration +@Import({LendingConfig.class}) +public class LendingTestContext { +} \ No newline at end of file diff --git a/src/integration-test/groovy/io/pillopl/library/lending/book/infrastructure/BookDatabaseRepositoryIT.groovy b/src/integration-test/groovy/io/pillopl/library/lending/book/infrastructure/BookDatabaseRepositoryIT.groovy new file mode 100644 index 0000000..3084162 --- /dev/null +++ b/src/integration-test/groovy/io/pillopl/library/lending/book/infrastructure/BookDatabaseRepositoryIT.groovy @@ -0,0 +1,50 @@ +package io.pillopl.library.lending.book.infrastructure + +import io.pillopl.library.catalogue.BookId +import io.pillopl.library.lending.LendingTestContext +import io.pillopl.library.lending.book.model.AvailableBook +import io.pillopl.library.lending.book.model.Book +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.PatronId +import io.vavr.control.Option +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import spock.lang.Specification + +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.book.model.BookFixture.circulatingAvailableBookAt +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId + +@SpringBootTest(classes = LendingTestContext.class) +class BookDatabaseRepositoryIT extends Specification { + + BookId bookId = anyBookId() + LibraryBranchId libraryBranchId = anyBranch() + PatronId patronId = anyPatronId() + + @Autowired + BookDatabaseRepository bookEntityRepository + + def 'persistence in real database should work'() { + given: + AvailableBook availableBook = circulatingAvailableBookAt(bookId, libraryBranchId) + when: + bookEntityRepository.save(availableBook) + then: + bookIsPersistedAs(AvailableBook.class) + } + + void bookIsPersistedAs(Class clz) { + Book book = loadPersistedBook(bookId) + assert book.class == clz + } + + Book loadPersistedBook(BookId bookId) { + Option loaded = bookEntityRepository.findBy(bookId) + Book book = loaded.getOrElseThrow({ + new IllegalStateException("should have been persisted") + }) + return book + } +} \ No newline at end of file diff --git a/src/integration-test/groovy/io/pillopl/library/lending/book/infrastructure/DuplicateHoldFoundIT.groovy b/src/integration-test/groovy/io/pillopl/library/lending/book/infrastructure/DuplicateHoldFoundIT.groovy new file mode 100644 index 0000000..4044b1d --- /dev/null +++ b/src/integration-test/groovy/io/pillopl/library/lending/book/infrastructure/DuplicateHoldFoundIT.groovy @@ -0,0 +1,92 @@ +package io.pillopl.library.lending.book.infrastructure + +import io.pillopl.library.common.events.publisher.DomainEventsTestConfig +import io.pillopl.library.lending.LendingTestContext +import io.pillopl.library.lending.book.model.AvailableBook +import io.pillopl.library.lending.book.model.BookFixture +import io.pillopl.library.lending.book.model.BookRepository +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.HoldDuration +import io.pillopl.library.lending.patron.model.Patron +import io.pillopl.library.lending.patron.model.Patrons +import io.pillopl.library.lending.patron.model.PatronId +import io.vavr.control.Option +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import spock.lang.Specification +import spock.util.concurrent.PollingConditions + +import javax.sql.DataSource + +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHold.bookPlacedOnHoldNow +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents.events +import static io.pillopl.library.lending.patron.model.PatronEvent.PatronCreated +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId +import static io.pillopl.library.lending.patron.model.PatronType.Regular + +@SpringBootTest(classes = [LendingTestContext.class, DomainEventsTestConfig.class]) +class DuplicateHoldFoundIT extends Specification { + + PatronId patron = anyPatronId() + PatronId anotherPatron = anyPatronId() + + LibraryBranchId libraryBranchId = anyBranch() + AvailableBook book = BookFixture.circulatingBook() + + @Autowired + Patrons patronRepo + + @Autowired + BookRepository bookRepository + + @Autowired + DataSource datasource + + PollingConditions pollingConditions = new PollingConditions(timeout: 15) + + def 'should react to compensation event - duplicate hold on book found'() { + given: + bookRepository.save(book) + and: + patronRepo.publish(patronCreated(patron)) + and: + patronRepo.publish(patronCreated(anotherPatron)) + when: + patronRepo.publish(placedOnHold(book, patron)) + and: + patronRepo.publish(placedOnHold(book, anotherPatron)) + then: + patronShouldBeFoundInDatabaseWithZeroBookOnHold(anotherPatron) + + } + + BookPlacedOnHoldEvents placedOnHold(AvailableBook book, PatronId patronId) { + return events(bookPlacedOnHoldNow( + book.getBookId(), + book.type(), + book.libraryBranch, + patronId, + HoldDuration.closeEnded(5))) + } + + PatronCreated patronCreated(PatronId patronId) { + return PatronCreated.now(patronId, Regular) + } + + void patronShouldBeFoundInDatabaseWithZeroBookOnHold(PatronId patronId) { + pollingConditions.eventually { + Patron patron = loadPersistedPatron(patronId) + assert patron.numberOfHolds() == 0 + } + } + + Patron loadPersistedPatron(PatronId patronId) { + Option loaded = patronRepo.findBy(patronId) + Patron patron = loaded.getOrElseThrow({ + new IllegalStateException("should have been persisted") + }) + return patron + } +} diff --git a/src/integration-test/groovy/io/pillopl/library/lending/book/infrastructure/FindAvailableBookInDatabaseIT.groovy b/src/integration-test/groovy/io/pillopl/library/lending/book/infrastructure/FindAvailableBookInDatabaseIT.groovy new file mode 100644 index 0000000..9e355ef --- /dev/null +++ b/src/integration-test/groovy/io/pillopl/library/lending/book/infrastructure/FindAvailableBookInDatabaseIT.groovy @@ -0,0 +1,58 @@ +package io.pillopl.library.lending.book.infrastructure + +import io.pillopl.library.catalogue.BookId +import io.pillopl.library.lending.LendingTestContext +import io.pillopl.library.lending.book.model.AvailableBook +import io.pillopl.library.lending.book.model.BookOnHold +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.PatronEvent +import io.pillopl.library.lending.patron.model.PatronId +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import spock.lang.Specification + +import static io.pillopl.library.catalogue.BookType.Circulating +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.book.model.BookFixture.circulatingAvailableBookAt +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.HoldDuration.closeEnded +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHold.bookPlacedOnHoldNow +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents.events +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId + +@SpringBootTest(classes = LendingTestContext.class) +class FindAvailableBookInDatabaseIT extends Specification { + + BookId bookId = anyBookId() + LibraryBranchId libraryBranchId = anyBranch() + PatronId patronId = anyPatronId() + + @Autowired + BookDatabaseRepository bookEntityRepository + + def 'should find available book in database'() { + given: + AvailableBook availableBook = circulatingAvailableBookAt(bookId, libraryBranchId) + when: + bookEntityRepository.save(availableBook) + then: + bookEntityRepository.findAvailableBookBy(bookId).isDefined() + when: + BookOnHold bookOnHold = availableBook.handle(placedOnHold()) + and: + bookEntityRepository.save(bookOnHold) + then: + bookEntityRepository.findAvailableBookBy(bookId).isEmpty() + } + + + PatronEvent.BookPlacedOnHold placedOnHold() { + return events( + bookPlacedOnHoldNow( + bookId, Circulating, + libraryBranchId, patronId, + closeEnded(5))) + .bookPlacedOnHold + } + +} \ No newline at end of file diff --git a/src/integration-test/groovy/io/pillopl/library/lending/book/infrastructure/FindBookOnHoldInDatabaseIT.groovy b/src/integration-test/groovy/io/pillopl/library/lending/book/infrastructure/FindBookOnHoldInDatabaseIT.groovy new file mode 100644 index 0000000..b7c5fbe --- /dev/null +++ b/src/integration-test/groovy/io/pillopl/library/lending/book/infrastructure/FindBookOnHoldInDatabaseIT.groovy @@ -0,0 +1,57 @@ +package io.pillopl.library.lending.book.infrastructure + +import io.pillopl.library.catalogue.BookId +import io.pillopl.library.lending.LendingTestContext +import io.pillopl.library.lending.book.model.AvailableBook +import io.pillopl.library.lending.book.model.BookOnHold +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.HoldDuration +import io.pillopl.library.lending.patron.model.PatronEvent +import io.pillopl.library.lending.patron.model.PatronId +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import spock.lang.Specification + +import static io.pillopl.library.catalogue.BookType.Circulating +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.book.model.BookFixture.circulatingAvailableBookAt +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHold.bookPlacedOnHoldNow +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents.events +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId + +@SpringBootTest(classes = LendingTestContext.class) +class FindBookOnHoldInDatabaseIT extends Specification { + + BookId bookId = anyBookId() + LibraryBranchId libraryBranchId = anyBranch() + PatronId patronId = anyPatronId() + + @Autowired + BookDatabaseRepository bookEntityRepository + + def 'should find book on hold in database'() { + given: + AvailableBook availableBook = circulatingAvailableBookAt(bookId, libraryBranchId) + when: + bookEntityRepository.save(availableBook) + then: + bookEntityRepository.findBookOnHold(bookId, patronId).isEmpty() + when: + BookOnHold bookOnHold = availableBook.handle(placedOnHoldBy(patronId)) + and: + bookEntityRepository.save(bookOnHold) + then: + bookEntityRepository.findBookOnHold(bookId, patronId).isDefined() + } + + PatronEvent.BookPlacedOnHold placedOnHoldBy(PatronId patronId) { + return events(bookPlacedOnHoldNow( + bookId, + Circulating, + libraryBranchId, + patronId, + HoldDuration.closeEnded(5))).bookPlacedOnHold + } + +} diff --git a/src/integration-test/groovy/io/pillopl/library/lending/book/infrastructure/OptimisticLockingBookAggregateIT.groovy b/src/integration-test/groovy/io/pillopl/library/lending/book/infrastructure/OptimisticLockingBookAggregateIT.groovy new file mode 100644 index 0000000..be813e4 --- /dev/null +++ b/src/integration-test/groovy/io/pillopl/library/lending/book/infrastructure/OptimisticLockingBookAggregateIT.groovy @@ -0,0 +1,78 @@ +package io.pillopl.library.lending.book.infrastructure + +import io.pillopl.library.catalogue.BookId +import io.pillopl.library.commons.aggregates.AggregateRootIsStale +import io.pillopl.library.commons.aggregates.Version +import io.pillopl.library.lending.LendingTestContext +import io.pillopl.library.lending.book.model.AvailableBook +import io.pillopl.library.lending.book.model.Book +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.HoldDuration +import io.pillopl.library.lending.patron.model.PatronEvent +import io.pillopl.library.lending.patron.model.PatronId +import io.vavr.control.Option +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import spock.lang.Specification + +import static io.pillopl.library.catalogue.BookType.Circulating +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.book.model.BookFixture.circulatingAvailableBookAt +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHold.bookPlacedOnHoldNow +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents.events +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId + +@SpringBootTest(classes = LendingTestContext.class) +class OptimisticLockingBookAggregateIT extends Specification { + + BookId bookId = anyBookId() + LibraryBranchId libraryBranchId = anyBranch() + PatronId patronId = anyPatronId() + + @Autowired + BookDatabaseRepository bookEntityRepository + + def 'persistence in real database should work'() { + given: + AvailableBook availableBook = circulatingAvailableBookAt(bookId, libraryBranchId) + and: + bookEntityRepository.save(availableBook) + and: + Book loaded = loadPersistedBook(bookId) + and: + someoneModifiedBookInTheMeantime(availableBook) + when: + bookEntityRepository.save(loaded) + then: + thrown(AggregateRootIsStale) + loadPersistedBook(bookId).version == new Version(1) + } + + void someoneModifiedBookInTheMeantime(AvailableBook availableBook) { + bookEntityRepository.save(availableBook.handle(placedOnHoldBy(anyPatronId()))) + } + + void bookIsPersistedAs(Class clz) { + Book book = loadPersistedBook(bookId) + assert book.class == clz + } + + Book loadPersistedBook(BookId bookId) { + Option loaded = bookEntityRepository.findBy(bookId) + Book book = loaded.getOrElseThrow({ + new IllegalStateException("should have been persisted") + }) + return book + } + + PatronEvent.BookPlacedOnHold placedOnHoldBy(PatronId patronId) { + return events(bookPlacedOnHoldNow( + bookId, + Circulating, + libraryBranchId, + patronId, + HoldDuration.closeEnded(5))) + .bookPlacedOnHold + } +} diff --git a/src/integration-test/groovy/io/pillopl/library/lending/dailysheet/infrastructure/FindingHoldsInDailySheetDatabaseIT.groovy b/src/integration-test/groovy/io/pillopl/library/lending/dailysheet/infrastructure/FindingHoldsInDailySheetDatabaseIT.groovy new file mode 100644 index 0000000..68cb305 --- /dev/null +++ b/src/integration-test/groovy/io/pillopl/library/lending/dailysheet/infrastructure/FindingHoldsInDailySheetDatabaseIT.groovy @@ -0,0 +1,162 @@ +package io.pillopl.library.lending.dailysheet.infrastructure + +import io.pillopl.library.catalogue.BookId +import io.pillopl.library.catalogue.BookType +import io.pillopl.library.lending.LendingTestContext +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.PatronEvent +import io.pillopl.library.lending.patron.model.PatronId +import io.pillopl.library.lending.patron.model.PatronType +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import spock.lang.Specification + +import javax.sql.DataSource +import java.time.Duration +import java.time.Instant + +import static io.pillopl.library.catalogue.BookType.Restricted +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId +import static java.time.Clock.fixed +import static java.time.Instant.now +import static java.time.ZoneId.systemDefault + +@SpringBootTest(classes = LendingTestContext.class) +class FindingHoldsInDailySheetDatabaseIT extends Specification { + + PatronId patronId = anyPatronId() + PatronType regular = PatronType.Regular + LibraryBranchId libraryBranchId = anyBranch() + BookId bookId = anyBookId() + BookType type = Restricted + + static final Instant TIME_OF_EXPIRE_CHECK = now() + + @Autowired + DataSource dataSource + + SheetsReadModel readModel + + def setup() { + readModel = new SheetsReadModel(new JdbcTemplate(dataSource), fixed(TIME_OF_EXPIRE_CHECK, systemDefault())) + } + + def 'should find expired holds'() { + given: + int currentNoOfExpiredHolds = readModel.queryForHoldsToExpireSheet().count() + when: + readModel.handle(placedOnHold(aCloseEndedHoldTillYesterday())) + and: + readModel.handle(placedOnHold(aCloseEndedHoldTillTomorrow())) + then: + readModel.queryForHoldsToExpireSheet().count() == currentNoOfExpiredHolds + 1 + } + + def 'handling placed on hold should de idempotent'() { + given: + int currentNoOfExpiredHolds = readModel.queryForHoldsToExpireSheet().count() + and: + PatronEvent.BookPlacedOnHold event = placedOnHold(aCloseEndedHoldTillYesterday()) + when: + 2.times { readModel.handle(event) } + then: + readModel.queryForHoldsToExpireSheet().count() == currentNoOfExpiredHolds + 1 + } + + def 'should never find open-ended holds'() { + given: + int currentNoOfExpiredHolds = readModel.queryForHoldsToExpireSheet().count() + when: + readModel.handle(placedOnHold(anOpenEndedHold())) + then: + readModel.queryForHoldsToExpireSheet().count() == currentNoOfExpiredHolds + } + + def 'should never find canceled holds'() { + given: + int currentNoOfExpiredHolds = readModel.queryForHoldsToExpireSheet().count() + when: + readModel.handle(placedOnHold(aCloseEndedHoldTillYesterday())) + and: + readModel.handle(holdCanceled()) + then: + readModel.queryForHoldsToExpireSheet().count() == currentNoOfExpiredHolds + } + + def 'should never find already expired holds'() { + given: + int currentNoOfExpiredHolds = readModel.queryForHoldsToExpireSheet().count() + when: + readModel.handle(placedOnHold(anOpenEndedHold())) + and: + readModel.handle(holdExpired()) + then: + readModel.queryForHoldsToExpireSheet().count() == currentNoOfExpiredHolds + } + + def 'should never find already checkedOut holds'() { + given: + int currentNoOfExpiredHolds = readModel.queryForHoldsToExpireSheet().count() + when: + readModel.handle(placedOnHold(aCloseEndedHoldTillYesterday())) + and: + readModel.handle(bookCheckedOut()) + then: + readModel.queryForHoldsToExpireSheet().count() == currentNoOfExpiredHolds + } + + + Instant aCloseEndedHoldTillTomorrow() { + return TIME_OF_EXPIRE_CHECK.plus(Duration.ofDays(1)) + } + + Instant aCloseEndedHoldTillYesterday() { + return TIME_OF_EXPIRE_CHECK.minus(Duration.ofDays(1)) + } + + Instant anOpenEndedHold() { + return null + } + + PatronEvent.BookPlacedOnHold placedOnHold(Instant till) { + return new PatronEvent.BookPlacedOnHold( + now(), + patronId.getPatronId(), + bookId.getBookId(), + type, + libraryBranchId.getLibraryBranchId(), + TIME_OF_EXPIRE_CHECK.minusSeconds(60000), + till) + } + + PatronEvent.BookHoldCanceled holdCanceled() { + return new PatronEvent.BookHoldCanceled( + now(), + patronId.getPatronId(), + bookId.getBookId(), + libraryBranchId.getLibraryBranchId()) + } + + PatronEvent.BookHoldExpired holdExpired() { + return new PatronEvent.BookHoldExpired( + now(), + patronId.getPatronId(), + bookId.getBookId(), + libraryBranchId.getLibraryBranchId()) + } + + PatronEvent.BookCheckedOut bookCheckedOut() { + return new PatronEvent.BookCheckedOut( + now(), + patronId.getPatronId(), + bookId.getBookId(), + type, + libraryBranchId.getLibraryBranchId(), + now()) + } + + +} diff --git a/src/integration-test/groovy/io/pillopl/library/lending/dailysheet/infrastructure/FindingOverdueCheckoutsInDailySheetDatabaseIT.groovy b/src/integration-test/groovy/io/pillopl/library/lending/dailysheet/infrastructure/FindingOverdueCheckoutsInDailySheetDatabaseIT.groovy new file mode 100644 index 0000000..68b2abb --- /dev/null +++ b/src/integration-test/groovy/io/pillopl/library/lending/dailysheet/infrastructure/FindingOverdueCheckoutsInDailySheetDatabaseIT.groovy @@ -0,0 +1,137 @@ +package io.pillopl.library.lending.dailysheet.infrastructure + +import io.pillopl.library.catalogue.BookId +import io.pillopl.library.lending.LendingTestContext +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.PatronEvent +import io.pillopl.library.lending.patron.model.PatronId +import io.pillopl.library.lending.patron.model.PatronType +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import spock.lang.Specification + +import javax.sql.DataSource +import java.time.Duration +import java.time.Instant + +import static io.pillopl.library.catalogue.BookType.Restricted +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId +import static java.time.Clock.fixed +import static java.time.Instant.now +import static java.time.ZoneId.systemDefault + +@SpringBootTest(classes = LendingTestContext.class) +class FindingOverdueCheckoutsInDailySheetDatabaseIT extends Specification { + + PatronId patronId = anyPatronId() + PatronType regular = PatronType.Regular + LibraryBranchId libraryBranchId = anyBranch() + BookId bookId = anyBookId() + + static final Instant TIME_OF_EXPIRE_CHECK = now() + + @Autowired + DataSource dataSource + + SheetsReadModel readModel + + def setup() { + readModel = new SheetsReadModel(new JdbcTemplate(dataSource), fixed(TIME_OF_EXPIRE_CHECK, systemDefault())) + } + + def 'should find overdue checkouts'() { + given: + int currentNoOfOverdueCheckouts = readModel.queryForCheckoutsToOverdue().count() + when: + readModel.handle(bookCheckedOut(tillYesterday())) + and: + readModel.handle(bookCheckedOut(tillTomorrow())) + then: + readModel.queryForCheckoutsToOverdue().count() == currentNoOfOverdueCheckouts + 1 + } + + def 'handling bookCheckedOut should de idempotent'() { + given: + int currentNoOfOverdueCheckouts = readModel.queryForCheckoutsToOverdue().count() + and: + PatronEvent.BookCheckedOut event = bookCheckedOut(tillYesterday()) + when: + 2.times { readModel.handle(event) } + then: + readModel.queryForCheckoutsToOverdue().count() == currentNoOfOverdueCheckouts + 1 + } + + def 'should never find returned books'() { + given: + int currentNoOfOverdueCheckouts = readModel.queryForCheckoutsToOverdue().count() + and: + readModel.handle(bookCheckedOut(tillTomorrow())) + when: + readModel.handle(bookReturned()) + then: + readModel.queryForCheckoutsToOverdue().count() == currentNoOfOverdueCheckouts + } + + + Instant tillTomorrow() { + return TIME_OF_EXPIRE_CHECK.plus(Duration.ofDays(1)) + } + + Instant tillYesterday() { + return TIME_OF_EXPIRE_CHECK.minus(Duration.ofDays(1)) + } + + Instant anOpenEndedHold() { + return null + } + + PatronEvent.BookPlacedOnHold placedOnHold(Instant till) { + return new PatronEvent.BookPlacedOnHold( + now(), + patronId.getPatronId(), + bookId.getBookId(), + Restricted, + libraryBranchId.getLibraryBranchId(), + TIME_OF_EXPIRE_CHECK.minusSeconds(60000), + till) + } + + PatronEvent.BookHoldCanceled holdCanceled() { + return new PatronEvent.BookHoldCanceled( + now(), + patronId.getPatronId(), + bookId.getBookId(), + libraryBranchId.getLibraryBranchId()) + } + + PatronEvent.BookHoldExpired holdExpired() { + return new PatronEvent.BookHoldExpired( + now(), + patronId.getPatronId(), + bookId.getBookId(), + libraryBranchId.getLibraryBranchId()) + } + + PatronEvent.BookCheckedOut bookCheckedOut(Instant till) { + return new PatronEvent.BookCheckedOut( + now(), + patronId.getPatronId(), + bookId.getBookId(), + Restricted, + libraryBranchId.getLibraryBranchId(), + till) + } + + PatronEvent.BookReturned bookReturned() { + return new PatronEvent.BookReturned( + now(), + patronId.getPatronId(), + bookId.getBookId(), + Restricted, + libraryBranchId.getLibraryBranchId()) + } + +} \ No newline at end of file diff --git a/src/integration-test/groovy/io/pillopl/library/lending/eventspropagation/EventualConsistencyBetweenAggregatesAndReadModelsIT.groovy b/src/integration-test/groovy/io/pillopl/library/lending/eventspropagation/EventualConsistencyBetweenAggregatesAndReadModelsIT.groovy new file mode 100644 index 0000000..eded6e3 --- /dev/null +++ b/src/integration-test/groovy/io/pillopl/library/lending/eventspropagation/EventualConsistencyBetweenAggregatesAndReadModelsIT.groovy @@ -0,0 +1,112 @@ +package io.pillopl.library.lending.eventspropagation + +import io.pillopl.library.common.events.publisher.DomainEventsTestConfig +import io.pillopl.library.lending.LendingTestContext +import io.pillopl.library.lending.book.model.AvailableBook +import io.pillopl.library.lending.book.model.BookFixture +import io.pillopl.library.lending.book.model.BookOnHold +import io.pillopl.library.lending.book.model.BookRepository +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.HoldDuration +import io.pillopl.library.lending.patron.model.Patron +import io.pillopl.library.lending.patron.model.Patrons +import io.pillopl.library.lending.patron.model.PatronId +import io.vavr.control.Option +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.ColumnMapRowMapper +import org.springframework.jdbc.core.JdbcTemplate +import spock.lang.Specification +import spock.util.concurrent.PollingConditions + +import javax.sql.DataSource + +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHold.bookPlacedOnHoldNow +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents.events +import static io.pillopl.library.lending.patron.model.PatronEvent.PatronCreated +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId +import static io.pillopl.library.lending.patron.model.PatronFixture.regularPatron +import static io.pillopl.library.lending.patron.model.PatronType.Regular + +@SpringBootTest(classes = [LendingTestContext.class, DomainEventsTestConfig.class]) +class EventualConsistencyBetweenAggregatesAndReadModelsIT extends Specification { + + PatronId patronId = anyPatronId() + LibraryBranchId libraryBranchId = anyBranch() + AvailableBook book = BookFixture.circulatingBook() + + @Autowired + Patrons patronRepo + + @Autowired + BookRepository bookRepository + + @Autowired + DataSource datasource + + PollingConditions pollingConditions = new PollingConditions(timeout: 6) + + def 'should synchronize Patron, Book and DailySheet with events'() { + given: + bookRepository.save(book) + and: + patronRepo.publish(patronCreated()) + when: + patronRepo.publish(placedOnHold(book)) + then: + patronShouldBeFoundInDatabaseWithOneBookOnHold(patronId) + and: + bookReactedToPlacedOnHoldEvent() + and: + dailySheetIsUpdated() + } + + void bookReactedToPlacedOnHoldEvent() { + pollingConditions.eventually { + assert bookRepository.findBy(book.bookId).get() instanceof BookOnHold + } + } + + void dailySheetIsUpdated() { + pollingConditions.eventually { + assert countOfHoldsInDailySheet() == 1 + } + } + + private Object countOfHoldsInDailySheet() { + return new JdbcTemplate(datasource).query("select count(*) from holds_sheet s where s.hold_by_patron_id = ?", + [patronId.patronId] as Object[], + new ColumnMapRowMapper()).get(0) + .get("COUNT(*)") + } + + BookPlacedOnHoldEvents placedOnHold(AvailableBook book) { + return events(bookPlacedOnHoldNow( + book.getBookId(), + book.type(), + book.libraryBranch, + patronId, + HoldDuration.closeEnded(5))) + } + + PatronCreated patronCreated() { + return PatronCreated.now(patronId, Regular) + } + + void patronShouldBeFoundInDatabaseWithOneBookOnHold(PatronId patronId) { + Patron patron = loadPersistedPatron(patronId) + assert patron.numberOfHolds() == 1 + assert patron.equals(regularPatron(patronId)) + } + + + Patron loadPersistedPatron(PatronId patronId) { + Option loaded = patronRepo.findBy(patronId) + Patron patron = loaded.getOrElseThrow({ + new IllegalStateException("should have been persisted") + }) + return patron + } +} diff --git a/src/integration-test/groovy/io/pillopl/library/lending/eventspropagation/StrongConsistencyBetweenAggregatesAndReadModelsIT.groovy b/src/integration-test/groovy/io/pillopl/library/lending/eventspropagation/StrongConsistencyBetweenAggregatesAndReadModelsIT.groovy new file mode 100644 index 0000000..2fc68ef --- /dev/null +++ b/src/integration-test/groovy/io/pillopl/library/lending/eventspropagation/StrongConsistencyBetweenAggregatesAndReadModelsIT.groovy @@ -0,0 +1,100 @@ +package io.pillopl.library.lending.eventspropagation + +import io.pillopl.library.lending.LendingTestContext +import io.pillopl.library.lending.book.model.AvailableBook +import io.pillopl.library.lending.book.model.BookFixture +import io.pillopl.library.lending.book.model.BookOnHold +import io.pillopl.library.lending.book.model.BookRepository +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.HoldDuration +import io.pillopl.library.lending.patron.model.Patron +import io.pillopl.library.lending.patron.model.Patrons +import io.pillopl.library.lending.patron.model.PatronId +import io.vavr.control.Option +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.ColumnMapRowMapper +import org.springframework.jdbc.core.JdbcTemplate +import spock.lang.Specification + +import javax.sql.DataSource + +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHold.bookPlacedOnHoldNow +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents.events +import static io.pillopl.library.lending.patron.model.PatronEvent.PatronCreated +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId +import static io.pillopl.library.lending.patron.model.PatronFixture.regularPatron +import static io.pillopl.library.lending.patron.model.PatronType.Regular + +@SpringBootTest(classes = LendingTestContext.class) +class StrongConsistencyBetweenAggregatesAndReadModelsIT extends Specification { + + PatronId patronId = anyPatronId() + LibraryBranchId libraryBranchId = anyBranch() + AvailableBook book = BookFixture.circulatingBook() + + @Autowired + Patrons patronRepo + + @Autowired + BookRepository bookRepository + + @Autowired + DataSource datasource + + def 'should synchronize Patron, Book and DailySheet with events'() { + given: + bookRepository.save(book) + and: + patronRepo.publish(patronCreated()) + when: + patronRepo.publish(placedOnHold(book)) + then: + patronShouldBeFoundInDatabaseWithOneBookOnHold(patronId) + and: + bookReactedToPlacedOnHoldEvent() + and: + dailySheetIsUpdated() + } + + boolean bookReactedToPlacedOnHoldEvent() { + return bookRepository.findBy(book.bookId).get() instanceof BookOnHold + } + + boolean dailySheetIsUpdated() { + return new JdbcTemplate(datasource).query("select count(*) from holds_sheet s where s.hold_by_patron_id = ?", + [patronId.patronId] as Object[], + new ColumnMapRowMapper()).get(0) + .get("COUNT(*)") == 1 + } + + BookPlacedOnHoldEvents placedOnHold(AvailableBook book) { + return events(bookPlacedOnHoldNow( + book.bookId, + book.type(), + book.libraryBranch, + patronId, + HoldDuration.closeEnded(5))) + } + + PatronCreated patronCreated() { + return PatronCreated.now(patronId, Regular) + } + + void patronShouldBeFoundInDatabaseWithOneBookOnHold(PatronId patronId) { + Patron patron = loadPersistedPatron(patronId) + assert patron.numberOfHolds() == 1 + assert patron.equals(regularPatron(patronId)) + } + + + Patron loadPersistedPatron(PatronId patronId) { + Option loaded = patronRepo.findBy(patronId) + Patron patron = loaded.getOrElseThrow({ + new IllegalStateException("should have been persisted") + }) + return patron + } +} diff --git a/src/integration-test/groovy/io/pillopl/library/lending/patron/infrastructure/PatronDatabaseRepositoryIT.groovy b/src/integration-test/groovy/io/pillopl/library/lending/patron/infrastructure/PatronDatabaseRepositoryIT.groovy new file mode 100644 index 0000000..a325a7d --- /dev/null +++ b/src/integration-test/groovy/io/pillopl/library/lending/patron/infrastructure/PatronDatabaseRepositoryIT.groovy @@ -0,0 +1,85 @@ +package io.pillopl.library.lending.patron.infrastructure + + +import io.pillopl.library.lending.LendingTestContext +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.HoldDuration +import io.pillopl.library.lending.patron.model.Patron +import io.pillopl.library.lending.patron.model.Patrons +import io.pillopl.library.lending.patron.model.PatronId +import io.pillopl.library.lending.patron.model.PatronType +import io.vavr.control.Option +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import spock.lang.Specification + +import static io.pillopl.library.catalogue.BookType.Circulating +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHold.bookPlacedOnHoldNow +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents.events +import static io.pillopl.library.lending.patron.model.PatronEvent.PatronCreated +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId +import static io.pillopl.library.lending.patron.model.PatronFixture.regularPatron +import static io.pillopl.library.lending.patron.model.PatronType.Regular + +@SpringBootTest(classes = LendingTestContext.class) +class PatronDatabaseRepositoryIT extends Specification { + + PatronId patronId = anyPatronId() + PatronType regular = Regular + LibraryBranchId libraryBranchId = anyBranch() + + @Autowired + Patrons patronRepo + + def 'persistence in real database should work'() { + when: + patronRepo.publish(patronCreated()) + then: + patronShouldBeFoundInDatabaseWithZeroBooksOnHold(patronId) + when: + patronRepo.publish(placedOnHold()) + then: + patronShouldBeFoundInDatabaseWithOneBookOnHold(patronId) + } + + BookPlacedOnHoldEvents placedOnHold() { + return events(bookPlacedOnHoldNow( + anyBookId(), + Circulating, + libraryBranchId, + patronId, + HoldDuration.closeEnded(5))) + } + + PatronCreated patronCreated() { + return PatronCreated.now(patronId, Regular) + } + + void patronShouldBeFoundInDatabaseWithOneBookOnHold(PatronId patronId) { + Patron patron = loadPersistedPatron(patronId) + assert patron.numberOfHolds() == 1 + assertPatronInformation(patron, patronId) + } + + void patronShouldBeFoundInDatabaseWithZeroBooksOnHold(PatronId patronId) { + Patron patron = loadPersistedPatron(patronId) + assert patron.numberOfHolds() == 0 + assertPatronInformation(patron, patronId) + + } + + void assertPatronInformation(Patron patron, PatronId patronId) { + assert patron.equals(regularPatron(patronId)) + } + + Patron loadPersistedPatron(PatronId patronId) { + Option loaded = patronRepo.findBy(patronId) + Patron patron = loaded.getOrElseThrow({ + new IllegalStateException("should have been persisted") + }) + return patron + } +} diff --git a/src/integration-test/groovy/io/pillopl/library/lending/patronprofile/infrastructure/FindingPatronProfileInDatabaseIT.groovy b/src/integration-test/groovy/io/pillopl/library/lending/patronprofile/infrastructure/FindingPatronProfileInDatabaseIT.groovy new file mode 100644 index 0000000..2c237cd --- /dev/null +++ b/src/integration-test/groovy/io/pillopl/library/lending/patronprofile/infrastructure/FindingPatronProfileInDatabaseIT.groovy @@ -0,0 +1,135 @@ +package io.pillopl.library.lending.patronprofile.infrastructure + +import io.pillopl.library.catalogue.BookId +import io.pillopl.library.catalogue.BookType +import io.pillopl.library.lending.LendingTestContext +import io.pillopl.library.lending.dailysheet.model.DailySheet +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.PatronEvent +import io.pillopl.library.lending.patron.model.PatronId +import io.pillopl.library.lending.patron.model.PatronType +import io.pillopl.library.lending.patronprofile.model.Checkout +import io.pillopl.library.lending.patronprofile.model.Hold +import io.pillopl.library.lending.patronprofile.model.PatronProfile +import io.pillopl.library.lending.patronprofile.model.PatronProfiles +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import spock.lang.Specification + +import javax.sql.DataSource +import java.time.Duration +import java.time.Instant + +import static io.pillopl.library.catalogue.BookType.Restricted +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId +import static java.time.Instant.now + +@SpringBootTest(classes = LendingTestContext.class) +class FindingPatronProfileInDatabaseIT extends Specification { + + PatronId patronId = anyPatronId() + PatronType regular = PatronType.Regular + LibraryBranchId libraryBranchId = anyBranch() + BookId bookId = anyBookId() + BookType type = Restricted + + static final Instant TOMORROW = now().plus(Duration.ofDays(1)) + + @Autowired + DataSource dataSource + + @Autowired + DailySheet dailySheet + + @Autowired + DataSource jdbcTemplate + + PatronProfiles patronProfiles; + + def setup() { + patronProfiles = new PatronProfileReadModel(new JdbcTemplate(dataSource)) + } + + def 'should create patron profile'() { + when: + PatronProfile profile = createProfile() + then: + thereIsZeroHoldsAndZeroCheckouts(profile) + when: + dailySheet.handle(placedOnHoldTill(TOMORROW)) + profile = createProfile() + then: + thereIsOnlyOneHold(profile) + when: + dailySheet.handle(bookCheckedOutTill(TOMORROW)) + profile = createProfile() + then: + thereIsOnlyOneCheckout(profile) + when: + dailySheet.handle(bookReturned()) + profile = createProfile() + then: + thereIsZeroHoldsAndZeroCheckouts(profile) + + } + + private PatronProfile createProfile() { + PatronProfile profile + profile = patronProfiles.fetchFor(patronId) + profile + } + + void thereIsOnlyOneHold(PatronProfile profile) { + assert profile.holdsView.currentHolds.size() == 1 + assert profile.holdsView.currentHolds.get(0) == new Hold(bookId, TOMORROW) + assert profile.currentCheckouts.currentCheckouts.size() == 0 + } + + void thereIsOnlyOneCheckout(PatronProfile profile) { + assert profile.holdsView.currentHolds.size() == 0 + assert profile.currentCheckouts.currentCheckouts.size() == 1 + assert profile.currentCheckouts.currentCheckouts.get(0) == new Checkout(bookId, TOMORROW) + } + + void thereIsZeroHoldsAndZeroCheckouts(PatronProfile profile) { + assert profile.holdsView.currentHolds.size() == 0 + assert profile.currentCheckouts.currentCheckouts.size() == 0 + + } + + PatronEvent.BookCheckedOut bookCheckedOutTill(Instant till) { + return new PatronEvent.BookCheckedOut( + now(), + patronId.getPatronId(), + bookId.getBookId(), + Restricted, + libraryBranchId.getLibraryBranchId(), + till) + } + + + PatronEvent.BookPlacedOnHold placedOnHoldTill(Instant till) { + return new PatronEvent.BookPlacedOnHold( + now(), + patronId.getPatronId(), + bookId.getBookId(), + type, + libraryBranchId.getLibraryBranchId(), + now().minusSeconds(60000), + till) + } + + PatronEvent.BookReturned bookReturned() { + return new PatronEvent.BookReturned( + now(), + patronId.getPatronId(), + bookId.getBookId(), + Restricted, + libraryBranchId.getLibraryBranchId()) + } + + +} diff --git a/src/integration-test/groovy/io/pillopl/library/lending/patronprofile/web/PatronProfileControllerIT.java b/src/integration-test/groovy/io/pillopl/library/lending/patronprofile/web/PatronProfileControllerIT.java new file mode 100644 index 0000000..8e32273 --- /dev/null +++ b/src/integration-test/groovy/io/pillopl/library/lending/patronprofile/web/PatronProfileControllerIT.java @@ -0,0 +1,234 @@ +package io.pillopl.library.lending.patronprofile.web; + +import io.micrometer.core.instrument.MeterRegistry; +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.lending.LendingTestContext; +import io.pillopl.library.lending.book.model.BookFixture; +import io.pillopl.library.lending.patron.application.hold.CancelingHold; +import io.pillopl.library.lending.patron.application.hold.PlacingOnHold; +import io.pillopl.library.lending.patron.model.PatronFixture; +import io.pillopl.library.lending.patron.model.PatronId; +import io.pillopl.library.lending.patronprofile.model.Checkout; +import io.pillopl.library.lending.patronprofile.model.CheckoutsView; +import io.pillopl.library.lending.patronprofile.model.Hold; +import io.pillopl.library.lending.patronprofile.model.HoldsView; +import io.pillopl.library.lending.patronprofile.model.PatronProfile; +import io.pillopl.library.lending.patronprofile.model.PatronProfiles; +import io.vavr.control.Try; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.hateoas.MediaTypes; +import org.springframework.http.MediaType; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.UUID; + +import static io.pillopl.library.commons.commands.Result.Success; +import static io.vavr.collection.List.of; +import static java.time.Instant.now; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@RunWith(SpringRunner.class) +@WebMvcTest(PatronProfileController.class) +@ContextConfiguration(classes = {LendingTestContext.class}) +public class PatronProfileControllerIT { + + PatronId patronId = PatronFixture.anyPatronId(); + BookId bookId = BookFixture.anyBookId(); + BookId anotherBook = BookFixture.anyBookId(); + + Instant anyDate = now(); + Instant anotherDate = now().plusSeconds(100); + + + @Autowired + private MockMvc mvc; + + @MockBean + private PatronProfiles patronProfiles; + + @MockBean + private PlacingOnHold placingOnHold; + + @MockBean + private CancelingHold cancelingHold; + + @MockBean + private MeterRegistry meterRegistry; + + @Test + public void shouldContainPatronProfileResourceWithCorrectHeadersAndLinksToCheckoutsAndHolds() throws Exception { + given(patronProfiles.fetchFor(patronId)).willReturn(profiles()); + + //expect + mvc.perform(get("/profiles/" + patronId.getPatronId()) + .accept(MediaTypes.HAL_FORMS_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(header().string(CONTENT_TYPE, MediaTypes.HAL_FORMS_JSON_VALUE)) + .andExpect(jsonPath("$._links.self.href", containsString("profiles/" + patronId.getPatronId()))) + .andExpect(jsonPath("$.patronId", is(patronId.getPatronId().toString()))) + .andExpect(jsonPath("$._links.holds.href", containsString("/profiles/" + patronId.getPatronId() + "/holds"))) + .andExpect(jsonPath("$._links.checkouts.href", containsString("/profiles/" + patronId.getPatronId() + "/checkouts"))); + } + + @Test + public void shouldCreateLinksForHolds() throws Exception { + given(patronProfiles.fetchFor(patronId)).willReturn(profiles()); + + //expect + mvc.perform(get("/profiles/" + patronId.getPatronId() + "/holds/") + .accept(MediaTypes.HAL_FORMS_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(header().string(CONTENT_TYPE, MediaTypes.HAL_FORMS_JSON_VALUE)) + .andExpect(jsonPath("$._embedded.holdList[0].bookId", is(bookId.getBookId().toString()))) + .andExpect(jsonPath("$._embedded.holdList[0]._links.self.href", containsString("/profiles/" + patronId.getPatronId() + "/holds/" + bookId.getBookId()))) + .andExpect(jsonPath("$._embedded.holdList[0].till", is(anyDate.toString()))) + .andExpect(jsonPath("$._embedded.holdList[0]._templates.default.method", is("delete"))); + } + + @Test + public void shouldCreateLinksForCheckouts() throws Exception { + given(patronProfiles.fetchFor(patronId)).willReturn(profiles()); + + //expect + mvc.perform(get("/profiles/" + patronId.getPatronId() + "/checkouts/") + .accept(MediaTypes.HAL_FORMS_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(header().string(CONTENT_TYPE, MediaTypes.HAL_FORMS_JSON_VALUE)) + .andExpect(jsonPath("$._embedded.checkoutList[0].bookId", is(anotherBook.getBookId().toString()))) + .andExpect(jsonPath("$._embedded.checkoutList[0].till", is(anotherDate.toString()))) + .andExpect(jsonPath("$._embedded.checkoutList[0]._links.self.href", containsString("/profiles/" + patronId.getPatronId() + "/checkouts/" + anotherBook.getBookId()))); + + } + + @Test + public void shouldReturn404WhenThereIsNoHold() throws Exception { + given(patronProfiles.fetchFor(patronId)).willReturn(profiles()); + + //expect + mvc.perform(get("/profiles/" + patronId.getPatronId() + "/holds/" + UUID.randomUUID()) + .accept(MediaTypes.HAL_FORMS_JSON_VALUE)) + .andExpect(status().isNotFound()); + } + + @Test + public void shouldReturn404WhenThereIsNoCheckout() throws Exception { + given(patronProfiles.fetchFor(patronId)).willReturn(profiles()); + + //expect + mvc.perform(get("/profiles/" + patronId.getPatronId() + "/checkouts/" + UUID.randomUUID()) + .accept(MediaTypes.HAL_FORMS_JSON_VALUE)) + .andExpect(status().isNotFound()); + } + + @Test + public void shouldReturnResourceForHold() throws Exception { + given(patronProfiles.fetchFor(patronId)).willReturn(profiles()); + + //expect + mvc.perform(get("/profiles/" + patronId.getPatronId() + "/holds/" + bookId.getBookId()) + .accept(MediaTypes.HAL_FORMS_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(header().string(CONTENT_TYPE, MediaTypes.HAL_FORMS_JSON_VALUE)) + .andExpect(jsonPath("$.bookId", is(bookId.getBookId().toString()))) + .andExpect(jsonPath("$.till", is(anyDate.toString()))) + .andExpect(jsonPath("$._templates.default.method", is("delete"))) + .andExpect(jsonPath("$._links.self.href", containsString("profiles/" + patronId.getPatronId() + "/holds/" + bookId.getBookId()))); + } + + @Test + public void shouldReturnResourceForCheckout() throws Exception { + given(patronProfiles.fetchFor(patronId)).willReturn(profiles()); + + //expect + mvc.perform(get("/profiles/" + patronId.getPatronId() + "/checkouts/" + anotherBook.getBookId()) + .accept(MediaTypes.HAL_FORMS_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(header().string(CONTENT_TYPE, MediaTypes.HAL_FORMS_JSON_VALUE)) + .andExpect(jsonPath("$.bookId", is(anotherBook.getBookId().toString()))) + .andExpect(jsonPath("$.till", is(anotherDate.toString()))) + .andExpect(jsonPath("$._links.self.href", containsString("profiles/" + patronId.getPatronId() + "/checkouts/" + anotherBook.getBookId()))); + } + + @Test + public void shouldPlaceBookOnHold() throws Exception { + given(placingOnHold.placeOnHold(any())).willReturn(Try.success(Success)); + var request = "{\"bookId\":\"6e1dfec5-5cfe-487e-814e-d70114f5396e\", \"libraryBranchId\":\"a518e2ef-5f6c-43e3-a7fc-5d895e15be3a\",\"numberOfDays\":1}"; + + // expect + mvc.perform(post("/profiles/" + patronId.getPatronId() + "/holds") + .accept(MediaTypes.HAL_FORMS_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()); + } + + @Test + public void shouldReturn500IfSomethingFailedWhileDuringPlacingOnHold() throws Exception { + given(placingOnHold.placeOnHold(any())).willReturn(Try.failure(new IllegalArgumentException())); + var request = "{\"bookId\":\"6e1dfec5-5cfe-487e-814e-d70114f5396e\", \"libraryBranchId\":\"a518e2ef-5f6c-43e3-a7fc-5d895e15be3a\",\"numberOfDays\":1}"; + + // expect + mvc.perform(post("/profiles/" + patronId.getPatronId() + "/holds") + .accept(MediaTypes.HAL_FORMS_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isInternalServerError()); + } + + @Test + public void shouldCancelExistingHold() throws Exception { + given(patronProfiles.fetchFor(patronId)).willReturn(profiles()); + given(cancelingHold.cancelHold(any())).willReturn(Try.success(Success)); + + //expect + mvc.perform(delete("/profiles/" + patronId.getPatronId() + "/holds/" + bookId.getBookId()) + .accept(MediaTypes.HAL_FORMS_JSON_VALUE)) + .andExpect(status().isNoContent()); + } + + @Test + public void shouldNotCancelNotExistingHold() throws Exception { + given(patronProfiles.fetchFor(patronId)).willReturn(profiles()); + given(cancelingHold.cancelHold(any())).willReturn(Try.failure(new IllegalArgumentException())); + + //expect + mvc.perform(delete("/profiles/" + patronId.getPatronId() + "/holds/" + bookId.getBookId()) + .accept(MediaTypes.HAL_FORMS_JSON_VALUE)) + .andExpect(status().isNotFound()); + } + + @Test + public void shouldReturn500IfSomethingFailedWhileCanceling() throws Exception { + given(patronProfiles.fetchFor(patronId)).willReturn(profiles()); + given(cancelingHold.cancelHold(any())).willReturn(Try.failure(new IllegalStateException())); + + //expect + mvc.perform(delete("/profiles/" + patronId.getPatronId() + "/holds/" + bookId.getBookId()) + .accept(MediaTypes.HAL_FORMS_JSON_VALUE)) + .andExpect(status().is(500)); + } + + + PatronProfile profiles() { + return new PatronProfile( + new HoldsView(of(new Hold(bookId, anyDate))), + new CheckoutsView(of(new Checkout(anotherBook, anotherDate)))); + } +} diff --git a/src/main/java/io/pillopl/library/LibraryApplication.java b/src/main/java/io/pillopl/library/LibraryApplication.java new file mode 100644 index 0000000..c178dba --- /dev/null +++ b/src/main/java/io/pillopl/library/LibraryApplication.java @@ -0,0 +1,22 @@ +package io.pillopl.library; + +import io.pillopl.library.catalogue.CatalogueConfiguration; +import io.pillopl.library.lending.LendingConfig; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; + +@SpringBootConfiguration +@EnableAutoConfiguration(exclude = DataSourceAutoConfiguration.class) +public class LibraryApplication { + + public static void main(String[] args) { + new SpringApplicationBuilder() + .parent(LibraryApplication.class) + .child(LendingConfig.class).web(WebApplicationType.SERVLET) + .sibling(CatalogueConfiguration.class).web(WebApplicationType.NONE) + .run(args); + } +} diff --git a/src/main/java/io/pillopl/library/catalogue/Book.java b/src/main/java/io/pillopl/library/catalogue/Book.java new file mode 100644 index 0000000..a7b4261 --- /dev/null +++ b/src/main/java/io/pillopl/library/catalogue/Book.java @@ -0,0 +1,52 @@ +package io.pillopl.library.catalogue; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.NonNull; +import lombok.Value; + +@Value +@EqualsAndHashCode(of = "bookIsbn") +@AllArgsConstructor(access = AccessLevel.PACKAGE) +class Book { + + @NonNull + private ISBN bookIsbn; + @NonNull + private Title title; + @NonNull + private Author author; + + Book(String isbn, String author, String title) { + this(new ISBN(isbn), new Title(title), new Author(author)); + } +} + + +@Value +class Title { + + @NonNull String title; + + Title(String title) { + if (title.isEmpty()) { + throw new IllegalArgumentException("Title cannot be empty"); + } + this.title = title.trim(); + } + +} + +@Value +class Author { + + @NonNull String name; + + Author(String name) { + if (name.isEmpty()) { + throw new IllegalArgumentException("Author cannot be empty"); + } + this.name = name.trim(); + } +} \ No newline at end of file diff --git a/src/main/java/io/pillopl/library/catalogue/BookId.java b/src/main/java/io/pillopl/library/catalogue/BookId.java new file mode 100644 index 0000000..ba01fb6 --- /dev/null +++ b/src/main/java/io/pillopl/library/catalogue/BookId.java @@ -0,0 +1,13 @@ +package io.pillopl.library.catalogue; + +import lombok.NonNull; +import lombok.Value; + +import java.util.UUID; + +@Value +public class BookId { + + @NonNull + UUID bookId; +} diff --git a/src/main/java/io/pillopl/library/catalogue/BookInstance.java b/src/main/java/io/pillopl/library/catalogue/BookInstance.java new file mode 100644 index 0000000..49cefeb --- /dev/null +++ b/src/main/java/io/pillopl/library/catalogue/BookInstance.java @@ -0,0 +1,25 @@ +package io.pillopl.library.catalogue; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.NonNull; +import lombok.Value; + +import java.util.UUID; + +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +class BookInstance { + + @NonNull + ISBN bookIsbn; + @NonNull + BookId bookId; + @NonNull + BookType bookType; + + static BookInstance instanceOf(Book book, BookType bookType) { + return new BookInstance(book.getBookIsbn(), new BookId(UUID.randomUUID()), bookType); + + } +} diff --git a/src/main/java/io/pillopl/library/catalogue/BookInstanceAddedToCatalogue.java b/src/main/java/io/pillopl/library/catalogue/BookInstanceAddedToCatalogue.java new file mode 100644 index 0000000..d354903 --- /dev/null +++ b/src/main/java/io/pillopl/library/catalogue/BookInstanceAddedToCatalogue.java @@ -0,0 +1,29 @@ +package io.pillopl.library.catalogue; + +import io.pillopl.library.commons.events.DomainEvent; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Value; + +import java.time.Instant; +import java.util.UUID; + +@Value +@AllArgsConstructor(access = AccessLevel.PACKAGE) +public class BookInstanceAddedToCatalogue implements DomainEvent { + + UUID eventId = UUID.randomUUID(); + String isbn; + BookType type; + UUID bookId; + Instant when = Instant.now(); + + BookInstanceAddedToCatalogue(BookInstance bookInstance) { + this(bookInstance.getBookIsbn().getIsbn(), bookInstance.getBookType(), bookInstance.getBookId().getBookId()); + } + + @Override + public UUID getAggregateId() { + return bookId; + } +} diff --git a/src/main/java/io/pillopl/library/catalogue/BookType.java b/src/main/java/io/pillopl/library/catalogue/BookType.java new file mode 100644 index 0000000..f8811b1 --- /dev/null +++ b/src/main/java/io/pillopl/library/catalogue/BookType.java @@ -0,0 +1,6 @@ +package io.pillopl.library.catalogue; + +public enum BookType { + Restricted, Circulating +} + diff --git a/src/main/java/io/pillopl/library/catalogue/Catalogue.java b/src/main/java/io/pillopl/library/catalogue/Catalogue.java new file mode 100644 index 0000000..7ad61d3 --- /dev/null +++ b/src/main/java/io/pillopl/library/catalogue/Catalogue.java @@ -0,0 +1,43 @@ +package io.pillopl.library.catalogue; + +import io.pillopl.library.commons.commands.Result; +import io.pillopl.library.commons.events.DomainEvents; +import io.vavr.control.Try; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; + +import static io.pillopl.library.commons.commands.Result.Rejection; +import static io.pillopl.library.commons.commands.Result.Success; + +@AllArgsConstructor(access = AccessLevel.PACKAGE) +public class Catalogue { + + private final CatalogueDatabase database; + private final DomainEvents domainEvents; + + public Try addBook(String author, String title, String isbn) { + return Try.of(() -> { + Book book = new Book(isbn, author, title); + database.saveNew(book); + return Success; + }); + } + + public Try addBookInstance(String isbn, BookType bookType) { + return Try.of(() -> database + .findBy(new ISBN(isbn)) + .map(book -> BookInstance.instanceOf(book, bookType)) + .map(this::saveAndPublishEvent) + .map(savedInstance -> Success) + .getOrElse(Rejection)); + } + + private BookInstance saveAndPublishEvent(BookInstance bookInstance) { + database.saveNew(bookInstance); + domainEvents.publish(new BookInstanceAddedToCatalogue(bookInstance)); + return bookInstance; + } + + +} + diff --git a/src/main/java/io/pillopl/library/catalogue/CatalogueConfiguration.java b/src/main/java/io/pillopl/library/catalogue/CatalogueConfiguration.java new file mode 100644 index 0000000..78bd84c --- /dev/null +++ b/src/main/java/io/pillopl/library/catalogue/CatalogueConfiguration.java @@ -0,0 +1,36 @@ +package io.pillopl.library.catalogue; + +import io.pillopl.library.commons.events.DomainEvents; +import io.pillopl.library.commons.events.publisher.DomainEventsConfig; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.JdbcTemplate; + +@Configuration +@EnableAutoConfiguration +@Import({CatalogueDatabaseConfig.class, DomainEventsConfig.class}) +public class CatalogueConfiguration { + + @Bean + Catalogue catalogue(CatalogueDatabase catalogueDatabase, DomainEvents domainEvents) { + return new Catalogue(catalogueDatabase, domainEvents); + } + + @Bean + CatalogueDatabase catalogueDatabase(JdbcTemplate jdbcTemplate) { + return new CatalogueDatabase(jdbcTemplate); + } + + @Profile("local") + @Bean + CommandLineRunner init(Catalogue catalogue) { + return args -> { + catalogue.addBook("Joshua Bloch", "Effective Java", "0321125215").get(); + catalogue.addBookInstance("0321125215", BookType.Restricted).get(); + }; + } +} diff --git a/src/main/java/io/pillopl/library/catalogue/CatalogueDatabase.java b/src/main/java/io/pillopl/library/catalogue/CatalogueDatabase.java new file mode 100644 index 0000000..691db4b --- /dev/null +++ b/src/main/java/io/pillopl/library/catalogue/CatalogueDatabase.java @@ -0,0 +1,61 @@ +package io.pillopl.library.catalogue; + +import io.vavr.control.Option; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.BeanPropertyRowMapper; +import org.springframework.jdbc.core.JdbcTemplate; + +@AllArgsConstructor(access = AccessLevel.PACKAGE) +class CatalogueDatabase { + + private final JdbcTemplate jdbcTemplate; + + Book saveNew(Book book) { + jdbcTemplate.update("" + + "INSERT INTO catalogue_book " + + "(id, isbn, title, author) VALUES " + + "(catalogue_book_seq.nextval, ?, ?, ?)", + book.getBookIsbn().getIsbn(), book.getTitle().getTitle(), book.getAuthor().getName()); + return book; + } + + BookInstance saveNew(BookInstance bookInstance) { + jdbcTemplate.update("" + + "INSERT INTO catalogue_book_instance " + + "(id, isbn, book_id) VALUES " + + "(catalogue_book_instance_seq.nextval, ?, ?)", + bookInstance.getBookIsbn().getIsbn(), bookInstance.getBookId().getBookId()); + return bookInstance; + } + + Option findBy(ISBN isbn) { + try { + return Option.of( + jdbcTemplate.queryForObject( + "SELECT b.* FROM catalogue_book b WHERE b.isbn = ?", + new BeanPropertyRowMapper<>(BookDatabaseRow.class), + isbn.getIsbn()) + .toBook()); + } catch (EmptyResultDataAccessException e) { + return Option.none(); + + } + } + +} + +@Data +@NoArgsConstructor(access = AccessLevel.PACKAGE) +class BookDatabaseRow { + String isbn; + String author; + String title; + + Book toBook() { + return new Book(isbn, author, title); + } +} diff --git a/src/main/java/io/pillopl/library/catalogue/CatalogueDatabaseConfig.java b/src/main/java/io/pillopl/library/catalogue/CatalogueDatabaseConfig.java new file mode 100644 index 0000000..920a117 --- /dev/null +++ b/src/main/java/io/pillopl/library/catalogue/CatalogueDatabaseConfig.java @@ -0,0 +1,41 @@ +package io.pillopl.library.catalogue; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; + +@Configuration +class CatalogueDatabaseConfig { + + @Bean + JdbcTemplate jdbcTemplate() { + return new JdbcTemplate(dataSource()); + } + + @Bean + NamedParameterJdbcOperations operations() { + return new NamedParameterJdbcTemplate(dataSource()); + } + + @Bean + PlatformTransactionManager transactionManager() { + return new DataSourceTransactionManager(dataSource()); + } + + @Bean + DataSource dataSource() { + return new EmbeddedDatabaseBuilder() + .generateUniqueName(true) + .setType(EmbeddedDatabaseType.H2) + .addScript("create_catalogue_book.sql") + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/io/pillopl/library/catalogue/ISBN.java b/src/main/java/io/pillopl/library/catalogue/ISBN.java new file mode 100644 index 0000000..cee0cee --- /dev/null +++ b/src/main/java/io/pillopl/library/catalogue/ISBN.java @@ -0,0 +1,21 @@ +package io.pillopl.library.catalogue; + +import lombok.NonNull; +import lombok.Value; + +@Value +class ISBN { + + private static final String VERY_SIMPLE_ISBN_CHECK = "^\\d{9}[\\d|X]$"; + + @NonNull + String isbn; + + ISBN(String isbn) { + if (!isbn.trim().matches(VERY_SIMPLE_ISBN_CHECK)) { + throw new IllegalArgumentException("Wrong ISBN!"); + } + this.isbn = isbn.trim(); + + } +} diff --git a/src/main/java/io/pillopl/library/commons/aggregates/AggregateRootIsStale.java b/src/main/java/io/pillopl/library/commons/aggregates/AggregateRootIsStale.java new file mode 100644 index 0000000..f5382e9 --- /dev/null +++ b/src/main/java/io/pillopl/library/commons/aggregates/AggregateRootIsStale.java @@ -0,0 +1,8 @@ +package io.pillopl.library.commons.aggregates; + +public class AggregateRootIsStale extends RuntimeException { + + public AggregateRootIsStale(String msg) { + super(msg); + } +} diff --git a/src/main/java/io/pillopl/library/commons/aggregates/Version.java b/src/main/java/io/pillopl/library/commons/aggregates/Version.java new file mode 100644 index 0000000..ddbdd8b --- /dev/null +++ b/src/main/java/io/pillopl/library/commons/aggregates/Version.java @@ -0,0 +1,12 @@ +package io.pillopl.library.commons.aggregates; + +import lombok.Value; + +@Value +public class Version { + int version; + + public static Version zero() { + return new Version(0); + } +} diff --git a/src/main/java/io/pillopl/library/commons/commands/BatchResult.java b/src/main/java/io/pillopl/library/commons/commands/BatchResult.java new file mode 100644 index 0000000..2ac67bb --- /dev/null +++ b/src/main/java/io/pillopl/library/commons/commands/BatchResult.java @@ -0,0 +1,5 @@ +package io.pillopl.library.commons.commands; + +public enum BatchResult { + FullSuccess, SomeFailed +} \ No newline at end of file diff --git a/src/main/java/io/pillopl/library/commons/commands/Result.java b/src/main/java/io/pillopl/library/commons/commands/Result.java new file mode 100644 index 0000000..3273922 --- /dev/null +++ b/src/main/java/io/pillopl/library/commons/commands/Result.java @@ -0,0 +1,5 @@ +package io.pillopl.library.commons.commands; + +public enum Result { + Success, Rejection +} \ No newline at end of file diff --git a/src/main/java/io/pillopl/library/commons/events/DomainEvent.java b/src/main/java/io/pillopl/library/commons/events/DomainEvent.java new file mode 100644 index 0000000..4c9e366 --- /dev/null +++ b/src/main/java/io/pillopl/library/commons/events/DomainEvent.java @@ -0,0 +1,13 @@ +package io.pillopl.library.commons.events; + +import java.time.Instant; +import java.util.UUID; + +public interface DomainEvent { + + UUID getEventId(); + + UUID getAggregateId(); + + Instant getWhen(); +} diff --git a/src/main/java/io/pillopl/library/commons/events/DomainEvents.java b/src/main/java/io/pillopl/library/commons/events/DomainEvents.java new file mode 100644 index 0000000..f3b2af7 --- /dev/null +++ b/src/main/java/io/pillopl/library/commons/events/DomainEvents.java @@ -0,0 +1,12 @@ +package io.pillopl.library.commons.events; + +import io.vavr.collection.List; + +public interface DomainEvents { + + void publish(DomainEvent event); + + default void publish(List events) { + events.forEach(this::publish); + } +} diff --git a/src/main/java/io/pillopl/library/commons/events/EitherResult.java b/src/main/java/io/pillopl/library/commons/events/EitherResult.java new file mode 100644 index 0000000..5dbf2c7 --- /dev/null +++ b/src/main/java/io/pillopl/library/commons/events/EitherResult.java @@ -0,0 +1,17 @@ +package io.pillopl.library.commons.events; + +import io.vavr.control.Either; + +import static io.vavr.control.Either.left; +import static io.vavr.control.Either.right; + +public class EitherResult { + + public static Either announceFailure(L left) { + return left(left); + } + + public static Either announceSuccess(R right) { + return right(right); + } +} diff --git a/src/main/java/io/pillopl/library/commons/events/publisher/DomainEventsConfig.java b/src/main/java/io/pillopl/library/commons/events/publisher/DomainEventsConfig.java new file mode 100644 index 0000000..4c8e315 --- /dev/null +++ b/src/main/java/io/pillopl/library/commons/events/publisher/DomainEventsConfig.java @@ -0,0 +1,16 @@ +package io.pillopl.library.commons.events.publisher; + +import io.micrometer.core.instrument.MeterRegistry; +import io.pillopl.library.commons.events.DomainEvents; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class DomainEventsConfig { + + @Bean + DomainEvents domainEvents(ApplicationEventPublisher applicationEventPublisher, MeterRegistry meterRegistry) { + return new MeteredDomainEventPublisher(new JustForwardDomainEventPublisher(applicationEventPublisher), meterRegistry); + } +} diff --git a/src/main/java/io/pillopl/library/commons/events/publisher/EventsStorage.java b/src/main/java/io/pillopl/library/commons/events/publisher/EventsStorage.java new file mode 100644 index 0000000..b3cada5 --- /dev/null +++ b/src/main/java/io/pillopl/library/commons/events/publisher/EventsStorage.java @@ -0,0 +1,13 @@ +package io.pillopl.library.commons.events.publisher; + +import io.pillopl.library.commons.events.DomainEvent; +import io.vavr.collection.List; + +public interface EventsStorage { + + void save(DomainEvent event); + + List toPublish(); + + void published(List events); +} diff --git a/src/main/java/io/pillopl/library/commons/events/publisher/JustForwardDomainEventPublisher.java b/src/main/java/io/pillopl/library/commons/events/publisher/JustForwardDomainEventPublisher.java new file mode 100644 index 0000000..5be0b17 --- /dev/null +++ b/src/main/java/io/pillopl/library/commons/events/publisher/JustForwardDomainEventPublisher.java @@ -0,0 +1,17 @@ +package io.pillopl.library.commons.events.publisher; + +import io.pillopl.library.commons.events.DomainEvent; +import io.pillopl.library.commons.events.DomainEvents; +import lombok.AllArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; + +@AllArgsConstructor +public class JustForwardDomainEventPublisher implements DomainEvents { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(DomainEvent event) { + applicationEventPublisher.publishEvent(event); + } +} diff --git a/src/main/java/io/pillopl/library/commons/events/publisher/MeteredDomainEventPublisher.java b/src/main/java/io/pillopl/library/commons/events/publisher/MeteredDomainEventPublisher.java new file mode 100644 index 0000000..6bcb9dd --- /dev/null +++ b/src/main/java/io/pillopl/library/commons/events/publisher/MeteredDomainEventPublisher.java @@ -0,0 +1,23 @@ +package io.pillopl.library.commons.events.publisher; + +import io.micrometer.core.instrument.MeterRegistry; +import io.pillopl.library.commons.events.DomainEvent; +import io.pillopl.library.commons.events.DomainEvents; +import lombok.AllArgsConstructor; + + +@AllArgsConstructor +public class MeteredDomainEventPublisher implements DomainEvents { + + private static final String DOMAIN_EVENTS = "domain_events"; + private static final String TAG_NAME = "name"; + + private final DomainEvents delegate; + private final MeterRegistry metricsRegistry; + + @Override + public void publish(DomainEvent event) { + delegate.publish(event); + metricsRegistry.counter(DOMAIN_EVENTS, TAG_NAME, event.getClass().getSimpleName()).increment(); + } +} diff --git a/src/main/java/io/pillopl/library/commons/events/publisher/StoreAndForwardDomainEventPublisher.java b/src/main/java/io/pillopl/library/commons/events/publisher/StoreAndForwardDomainEventPublisher.java new file mode 100644 index 0000000..7db4fa5 --- /dev/null +++ b/src/main/java/io/pillopl/library/commons/events/publisher/StoreAndForwardDomainEventPublisher.java @@ -0,0 +1,29 @@ +package io.pillopl.library.commons.events.publisher; + +import io.pillopl.library.commons.events.DomainEvent; +import io.pillopl.library.commons.events.DomainEvents; +import io.vavr.collection.List; +import lombok.AllArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.transaction.annotation.Transactional; + + +@AllArgsConstructor +public class StoreAndForwardDomainEventPublisher implements DomainEvents { + + private final DomainEvents eventsPublisher; + private final EventsStorage eventsStorage; + + @Override + public void publish(DomainEvent event) { + eventsStorage.save(event); + } + + @Scheduled(fixedRate = 3000L) + @Transactional + public void publishAllPeriodically() { + List domainEvents = eventsStorage.toPublish(); + domainEvents.forEach(eventsPublisher::publish); + eventsStorage.published(domainEvents); + } +} diff --git a/src/main/java/io/pillopl/library/lending/LendingConfig.java b/src/main/java/io/pillopl/library/lending/LendingConfig.java new file mode 100644 index 0000000..04ae49f --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/LendingConfig.java @@ -0,0 +1,23 @@ +package io.pillopl.library.lending; + +import io.pillopl.library.commons.events.publisher.DomainEventsConfig; +import io.pillopl.library.lending.book.infrastructure.BookConfiguration; +import io.pillopl.library.lending.dailysheet.infrastructure.DailySheetConfiguration; +import io.pillopl.library.lending.patron.infrastructure.PatronConfiguration; +import io.pillopl.library.lending.patronprofile.infrastructure.PatronProfileConfiguration; +import io.pillopl.library.lending.patronprofile.web.WebConfiguration; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +@Import({LendingDatabaseConfig.class, + WebConfiguration.class, + PatronProfileConfiguration.class, + PatronConfiguration.class, + DailySheetConfiguration.class, + BookConfiguration.class, + DomainEventsConfig.class}) +public class LendingConfig { +} diff --git a/src/main/java/io/pillopl/library/lending/LendingDatabaseConfig.java b/src/main/java/io/pillopl/library/lending/LendingDatabaseConfig.java new file mode 100644 index 0000000..31b747f --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/LendingDatabaseConfig.java @@ -0,0 +1,79 @@ +package io.pillopl.library.lending; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.commons.aggregates.Version; +import io.pillopl.library.lending.book.model.AvailableBook; +import io.pillopl.library.lending.book.model.BookInformation; +import io.pillopl.library.lending.book.model.BookRepository; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import io.pillopl.library.lending.patron.model.PatronEvent.PatronCreated; +import io.pillopl.library.lending.patron.model.PatronId; +import io.pillopl.library.lending.patron.model.Patrons; +import java.util.UUID; +import javax.sql.DataSource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.transaction.PlatformTransactionManager; + + +import static io.pillopl.library.catalogue.BookType.Circulating; +import static io.pillopl.library.lending.patron.model.PatronType.Regular; + +@Configuration +@Slf4j +class LendingDatabaseConfig extends AbstractJdbcConfiguration { + + @Bean + JdbcTemplate jdbcTemplate() { + return new JdbcTemplate(dataSource()); + } + + @Bean + NamedParameterJdbcOperations operations() { + return new NamedParameterJdbcTemplate(dataSource()); + } + + @Bean + PlatformTransactionManager transactionManager() { + return new DataSourceTransactionManager(dataSource()); + } + + @Bean + DataSource dataSource() { + return new EmbeddedDatabaseBuilder() + .generateUniqueName(true) + .setType(EmbeddedDatabaseType.H2) + .addScript("create_patron_db.sql") + .addScript("create_lending_book_db.sql") + .addScript("create_sheets_db.sql") + .build(); + } + + @Profile("local") + @Bean + CommandLineRunner init(BookRepository bookRepository, Patrons patrons) { + return args -> { + UUID bookId = UUID.randomUUID(); + UUID libraryBranchId = UUID.randomUUID(); + UUID patronId = UUID.randomUUID(); + + AvailableBook availableBook = new AvailableBook(new BookInformation(new BookId(bookId), Circulating), new LibraryBranchId(libraryBranchId), new Version(0)); + bookRepository.save(availableBook); + patrons.publish(PatronCreated.now(new PatronId(patronId), Regular)); + + log.info("Created bookId: {}", bookId); + log.info("Created libraryBranchId: {}", libraryBranchId); + log.info("Created patronId: {}", patronId); + }; + } +} diff --git a/src/main/java/io/pillopl/library/lending/book/application/CreateAvailableBookOnInstanceAddedEventHandler.java b/src/main/java/io/pillopl/library/lending/book/application/CreateAvailableBookOnInstanceAddedEventHandler.java new file mode 100644 index 0000000..0c69717 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/book/application/CreateAvailableBookOnInstanceAddedEventHandler.java @@ -0,0 +1,30 @@ +package io.pillopl.library.lending.book.application; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.catalogue.BookInstanceAddedToCatalogue; +import io.pillopl.library.commons.aggregates.Version; +import io.pillopl.library.lending.book.model.AvailableBook; +import io.pillopl.library.lending.book.model.BookRepository; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import lombok.AllArgsConstructor; +import org.springframework.context.event.EventListener; + +import java.util.UUID; + +@AllArgsConstructor +public class CreateAvailableBookOnInstanceAddedEventHandler { + + private final BookRepository bookRepository; + + @EventListener + void handle(BookInstanceAddedToCatalogue event) { + bookRepository.save(new AvailableBook(new BookId(event.getBookId()), event.getType(), ourLibraryBranch(), Version.zero())); + } + + private LibraryBranchId ourLibraryBranch() { + //from properties + return new LibraryBranchId(UUID.randomUUID()); + } + + +} diff --git a/src/main/java/io/pillopl/library/lending/book/application/PatronEventsHandler.java b/src/main/java/io/pillopl/library/lending/book/application/PatronEventsHandler.java new file mode 100644 index 0000000..7e57ae3 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/book/application/PatronEventsHandler.java @@ -0,0 +1,116 @@ +package io.pillopl.library.lending.book.application; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.commons.events.DomainEvents; +import io.pillopl.library.lending.book.model.*; +import io.pillopl.library.lending.patron.model.PatronEvent.*; +import io.pillopl.library.lending.patron.model.PatronId; +import io.vavr.API; +import lombok.AllArgsConstructor; +import org.springframework.context.event.EventListener; + +import java.time.Instant; + +import static io.vavr.API.$; +import static io.vavr.API.Case; +import static io.vavr.Predicates.instanceOf; + +@AllArgsConstructor +public class PatronEventsHandler { + + private final BookRepository bookRepository; + private final DomainEvents domainEvents; + + @EventListener + void handle(BookPlacedOnHold bookPlacedOnHold) { + bookRepository.findBy(new BookId(bookPlacedOnHold.getBookId())) + .map(book -> handleBookPlacedOnHold(book, bookPlacedOnHold)) + .map(this::saveBook); + } + + @EventListener + void handle(BookCheckedOut bookCheckedOut) { + bookRepository.findBy(new BookId(bookCheckedOut.getBookId())) + .map(book -> handleBookCheckedOut(book, bookCheckedOut)) + .map(this::saveBook); + } + + @EventListener + void handle(BookHoldExpired holdExpired) { + bookRepository.findBy(new BookId(holdExpired.getBookId())) + .map(book -> handleBookHoldExpired(book, holdExpired)) + .map(this::saveBook); + } + + @EventListener + void handle(BookHoldCanceled holdCanceled) { + bookRepository.findBy(new BookId(holdCanceled.getBookId())) + .map(book -> handleBookHoldCanceled(book, holdCanceled)) + .map(this::saveBook); + } + + @EventListener + void handle(BookReturned bookReturned) { + bookRepository.findBy(new BookId(bookReturned.getBookId())) + .map(book -> handleBookReturned(book, bookReturned)) + .map(this::saveBook); + } + + + private Book handleBookPlacedOnHold(Book book, BookPlacedOnHold bookPlacedOnHold) { + return API.Match(book).of( + Case($(instanceOf(AvailableBook.class)), availableBook -> availableBook.handle(bookPlacedOnHold)), + Case($(instanceOf(BookOnHold.class)), bookOnHold -> raiseDuplicateHoldFoundEvent(bookOnHold, bookPlacedOnHold)), + Case($(), () -> book) + ); + } + + private BookOnHold raiseDuplicateHoldFoundEvent(BookOnHold onHold, BookPlacedOnHold bookPlacedOnHold) { + if(onHold.by(new PatronId(bookPlacedOnHold.getPatronId()))) { + return onHold; + } + domainEvents.publish( + new BookDuplicateHoldFound( + Instant.now(), + onHold.getByPatron().getPatronId(), + bookPlacedOnHold.getPatronId(), + bookPlacedOnHold.getLibraryBranchId(), + bookPlacedOnHold.getBookId())); + return onHold; + } + + + private Book handleBookHoldExpired(Book book, BookHoldExpired holdExpired) { + return API.Match(book).of( + Case($(instanceOf(BookOnHold.class)), onHold -> onHold.handle(holdExpired)), + Case($(), () -> book) + ); + } + + private Book handleBookHoldCanceled(Book book, BookHoldCanceled holdCanceled) { + return API.Match(book).of( + Case($(instanceOf(BookOnHold.class)), onHold -> onHold.handle(holdCanceled)), + Case($(), () -> book) + ); + } + + private Book handleBookCheckedOut(Book book, BookCheckedOut bookCheckedOut) { + return API.Match(book).of( + Case($(instanceOf(BookOnHold.class)), onHold -> onHold.handle(bookCheckedOut)), + Case($(), () -> book) + ); + } + + private Book handleBookReturned(Book book, BookReturned bookReturned) { + return API.Match(book).of( + Case($(instanceOf(CheckedOutBook.class)), checkedOut -> checkedOut.handle(bookReturned)), + Case($(), () -> book) + ); + } + + private Book saveBook(Book book) { + bookRepository.save(book); + return book; + } + +} diff --git a/src/main/java/io/pillopl/library/lending/book/infrastructure/BookConfiguration.java b/src/main/java/io/pillopl/library/lending/book/infrastructure/BookConfiguration.java new file mode 100644 index 0000000..7e2b71f --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/book/infrastructure/BookConfiguration.java @@ -0,0 +1,28 @@ +package io.pillopl.library.lending.book.infrastructure; + +import io.pillopl.library.commons.events.DomainEvents; +import io.pillopl.library.lending.book.application.CreateAvailableBookOnInstanceAddedEventHandler; +import io.pillopl.library.lending.book.application.PatronEventsHandler; +import io.pillopl.library.lending.book.model.BookRepository; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +@Configuration +public class BookConfiguration { + + @Bean + CreateAvailableBookOnInstanceAddedEventHandler createAvailableBookOnInstanceAddedEventHandler(BookRepository bookRepository) { + return new CreateAvailableBookOnInstanceAddedEventHandler(bookRepository); + } + + @Bean + PatronEventsHandler bookEventsHandler(BookRepository bookRepository, DomainEvents domainEvents) { + return new PatronEventsHandler(bookRepository, domainEvents); + } + + @Bean + BookDatabaseRepository bookDatabaseRepository(JdbcTemplate jdbcTemplate) { + return new BookDatabaseRepository(jdbcTemplate); + } +} diff --git a/src/main/java/io/pillopl/library/lending/book/infrastructure/BookDatabaseEntity.java b/src/main/java/io/pillopl/library/lending/book/infrastructure/BookDatabaseEntity.java new file mode 100644 index 0000000..7d3592b --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/book/infrastructure/BookDatabaseEntity.java @@ -0,0 +1,57 @@ +package io.pillopl.library.lending.book.infrastructure; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.catalogue.BookType; +import io.pillopl.library.commons.aggregates.Version; +import io.pillopl.library.lending.book.model.*; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import io.pillopl.library.lending.patron.model.PatronId; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +import static io.pillopl.library.lending.book.infrastructure.BookDatabaseEntity.BookState.*; +import static io.vavr.API.*; + +@NoArgsConstructor +@Data +class BookDatabaseEntity { + + enum BookState { + Available, OnHold, CheckedOut + } + + UUID book_id; + BookType book_type; + BookState book_state; + UUID available_at_branch; + UUID on_hold_at_branch; + UUID on_hold_by_patron; + Instant on_hold_till; + UUID checked_out_at_branch; + UUID checked_out_by_patron; + int version; + + Book toDomainModel() { + return Match(book_state).of( + Case($(Available), this::toAvailableBook), + Case($(OnHold), this::toBookOnHold), + Case($(CheckedOut), this::toCheckedOutBook) + ); + } + + private AvailableBook toAvailableBook() { + return new AvailableBook(new BookId(book_id), book_type, new LibraryBranchId(available_at_branch), new Version(version)); + } + + private BookOnHold toBookOnHold() { + return new BookOnHold(new BookId(book_id), book_type, new LibraryBranchId(on_hold_at_branch), new PatronId(on_hold_by_patron), on_hold_till, new Version(version)); + } + + private CheckedOutBook toCheckedOutBook() { + return new CheckedOutBook(new BookId(book_id), book_type, new LibraryBranchId(checked_out_at_branch), new PatronId(checked_out_by_patron), new Version(version)); + } +} + diff --git a/src/main/java/io/pillopl/library/lending/book/infrastructure/BookDatabaseRepository.java b/src/main/java/io/pillopl/library/lending/book/infrastructure/BookDatabaseRepository.java new file mode 100644 index 0000000..c5b7b61 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/book/infrastructure/BookDatabaseRepository.java @@ -0,0 +1,149 @@ +package io.pillopl.library.lending.book.infrastructure; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.catalogue.BookType; +import io.pillopl.library.commons.aggregates.AggregateRootIsStale; +import io.pillopl.library.lending.book.model.*; +import io.pillopl.library.lending.patron.application.hold.FindAvailableBook; +import io.pillopl.library.lending.patron.application.hold.FindBookOnHold; +import io.pillopl.library.lending.patron.model.PatronId; +import io.vavr.control.Option; +import io.vavr.control.Try; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import org.springframework.jdbc.core.BeanPropertyRowMapper; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Instant; +import java.util.UUID; + +import static io.pillopl.library.lending.book.infrastructure.BookDatabaseEntity.BookState.*; +import static io.vavr.API.*; +import static io.vavr.Patterns.$Some; +import static io.vavr.Predicates.instanceOf; +import static io.vavr.control.Option.none; +import static io.vavr.control.Option.of; + +@AllArgsConstructor(access = AccessLevel.PACKAGE) +class BookDatabaseRepository implements BookRepository, FindAvailableBook, FindBookOnHold { + + private final JdbcTemplate jdbcTemplate; + + @Override + public Option findBy(BookId bookId) { + return findBookById(bookId) + .map(BookDatabaseEntity::toDomainModel); + } + + private Option findBookById(BookId bookId) { + return Try + .ofSupplier(() -> of(jdbcTemplate.queryForObject("SELECT b.* FROM book_database_entity b WHERE b.book_id = ?", new BeanPropertyRowMapper<>(BookDatabaseEntity.class), bookId.getBookId()))) + .getOrElse(none()); + } + + @Override + public void save(Book book) { + findBy(book.bookId()) + .map(entity -> updateOptimistically(book)) + .onEmpty(() -> insertNew(book)); + } + + private int updateOptimistically(Book book) { + int result = Match(book).of( + Case($(instanceOf(AvailableBook.class)), this::update), + Case($(instanceOf(BookOnHold.class)), this::update), + Case($(instanceOf(CheckedOutBook.class)), this::update) + ); + if (result == 0) { + throw new AggregateRootIsStale("Someone has updated book in the meantime, book: " + book); + } + return result; + } + + private int update(AvailableBook availableBook) { + return jdbcTemplate.update("UPDATE book_database_entity b SET b.book_state = ?, b.available_at_branch = ?, b.version = ? WHERE book_id = ? AND version = ?", + Available.toString(), + availableBook.getLibraryBranch().getLibraryBranchId(), + availableBook.getVersion().getVersion() + 1, + availableBook.getBookId().getBookId(), + availableBook.getVersion().getVersion()); + } + + private int update(BookOnHold bookOnHold) { + return jdbcTemplate.update("UPDATE book_database_entity b SET b.book_state = ?, b.on_hold_at_branch = ?, b.on_hold_by_patron = ?, b.on_hold_till = ?, b.version = ? WHERE book_id = ? AND version = ?", + OnHold.toString(), + bookOnHold.getHoldPlacedAt().getLibraryBranchId(), + bookOnHold.getByPatron().getPatronId(), + bookOnHold.getHoldTill(), + bookOnHold.getVersion().getVersion() + 1, + bookOnHold.getBookId().getBookId(), + bookOnHold.getVersion().getVersion()); + } + + private int update(CheckedOutBook checkedoutBook) { + return jdbcTemplate.update("UPDATE book_database_entity b SET b.book_state = ?, b.checked_out_at_branch = ?, b.checked_out_by_patron = ?, b.version = ? WHERE book_id = ? AND version = ?", + CheckedOut.toString(), + checkedoutBook.getCheckedOutAt().getLibraryBranchId(), + checkedoutBook.getByPatron().getPatronId(), + checkedoutBook.getVersion().getVersion() + 1, + checkedoutBook.getBookId().getBookId(), + checkedoutBook.getVersion().getVersion()); + } + + private void insertNew(Book book) { + Match(book).of( + Case($(instanceOf(AvailableBook.class)), this::insert), + Case($(instanceOf(BookOnHold.class)), this::insert), + Case($(instanceOf(CheckedOutBook.class)), this::insert) + ); + } + + private int insert(AvailableBook availableBook) { + return insert(availableBook.getBookId(), availableBook.type(), Available, availableBook.getLibraryBranch().getLibraryBranchId(), null, null, null, null, null); + } + + private int insert(BookOnHold bookOnHold) { + return insert(bookOnHold.getBookId(), bookOnHold.type(), OnHold, null, bookOnHold.getHoldPlacedAt().getLibraryBranchId(), bookOnHold.getByPatron().getPatronId(), bookOnHold.getHoldTill(), null, null); + + } + + private int insert(CheckedOutBook checkedoutBook) { + return insert(checkedoutBook.getBookId(), checkedoutBook.type(), CheckedOut, null, null, null, null, checkedoutBook.getCheckedOutAt().getLibraryBranchId(), checkedoutBook.getByPatron().getPatronId()); + + } + + private int insert(BookId bookId, BookType bookType, BookDatabaseEntity.BookState state, UUID availableAt, UUID onHoldAt, UUID onHoldBy, Instant onHoldTill, UUID checkedOutAt, UUID checkedOutBy) { + return jdbcTemplate.update("INSERT INTO book_database_entity " + + "(id, " + + "book_id, " + + "book_type, " + + "book_state, " + + "available_at_branch," + + "on_hold_at_branch, " + + "on_hold_by_patron, " + + "on_hold_till, " + + "checked_out_at_branch, " + + "checked_out_by_patron, " + + "version) VALUES " + + "(book_database_entity_seq.nextval, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)", + bookId.getBookId(), bookType.toString(), state.toString(), availableAt, onHoldAt, onHoldBy, onHoldTill, checkedOutAt, checkedOutBy); + } + + @Override + public Option findAvailableBookBy(BookId bookId) { + return Match(findBy(bookId)).of( + Case($Some($(instanceOf(AvailableBook.class))), Option::of), + Case($(), Option::none) + ); + } + + @Override + public Option findBookOnHold(BookId bookId, PatronId patronId) { + return Match(findBy(bookId)).of( + Case($Some($(instanceOf(BookOnHold.class))), Option::of), + Case($(), Option::none) + ); + } + +} + diff --git a/src/main/java/io/pillopl/library/lending/book/model/AvailableBook.java b/src/main/java/io/pillopl/library/lending/book/model/AvailableBook.java new file mode 100644 index 0000000..8588db3 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/book/model/AvailableBook.java @@ -0,0 +1,49 @@ +package io.pillopl.library.lending.book.model; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.catalogue.BookType; +import io.pillopl.library.commons.aggregates.Version; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHold; +import io.pillopl.library.lending.patron.model.PatronId; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.NonNull; +import lombok.Value; + +@Value +@AllArgsConstructor +@EqualsAndHashCode(of = "bookInformation") +public class AvailableBook implements Book { + + @NonNull + BookInformation bookInformation; + + @NonNull + LibraryBranchId libraryBranch; + + @NonNull + Version version; + + public AvailableBook(BookId bookId, BookType type, LibraryBranchId libraryBranchId, Version version) { + this(new BookInformation(bookId, type), libraryBranchId, version); + } + + public boolean isRestricted() { + return bookInformation.getBookType().equals(BookType.Restricted); + } + + public BookId getBookId() { + return bookInformation.getBookId(); + } + + public BookOnHold handle(BookPlacedOnHold bookPlacedOnHold) { + return new BookOnHold( + bookInformation, + new LibraryBranchId(bookPlacedOnHold.getLibraryBranchId()), + new PatronId(bookPlacedOnHold.getPatronId()), + bookPlacedOnHold.getHoldTill(), + version); + } +} + diff --git a/src/main/java/io/pillopl/library/lending/book/model/Book.java b/src/main/java/io/pillopl/library/lending/book/model/Book.java new file mode 100644 index 0000000..a609122 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/book/model/Book.java @@ -0,0 +1,22 @@ +package io.pillopl.library.lending.book.model; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.catalogue.BookType; +import io.pillopl.library.commons.aggregates.Version; + +public interface Book { + + default BookId bookId() { + return getBookInformation().getBookId(); + } + + default BookType type() { + return getBookInformation().getBookType(); + } + + BookInformation getBookInformation(); + + Version getVersion(); + +} + diff --git a/src/main/java/io/pillopl/library/lending/book/model/BookDuplicateHoldFound.java b/src/main/java/io/pillopl/library/lending/book/model/BookDuplicateHoldFound.java new file mode 100644 index 0000000..93bf7fe --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/book/model/BookDuplicateHoldFound.java @@ -0,0 +1,24 @@ +package io.pillopl.library.lending.book.model; + +import io.pillopl.library.commons.events.DomainEvent; +import lombok.NonNull; +import lombok.Value; + +import java.time.Instant; +import java.util.UUID; + + +@Value +public class BookDuplicateHoldFound implements DomainEvent { + @NonNull UUID eventId = UUID.randomUUID(); + @NonNull Instant when; + @NonNull UUID firstPatronId; + @NonNull UUID secondPatronId; + @NonNull UUID libraryBranchId; + @NonNull UUID bookId; + + @Override + public UUID getAggregateId() { + return bookId; + } +} \ No newline at end of file diff --git a/src/main/java/io/pillopl/library/lending/book/model/BookInformation.java b/src/main/java/io/pillopl/library/lending/book/model/BookInformation.java new file mode 100644 index 0000000..b98791f --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/book/model/BookInformation.java @@ -0,0 +1,17 @@ +package io.pillopl.library.lending.book.model; + + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.catalogue.BookType; +import lombok.NonNull; +import lombok.Value; + +@Value +public class BookInformation { + + @NonNull + BookId bookId; + + @NonNull + BookType bookType; +} diff --git a/src/main/java/io/pillopl/library/lending/book/model/BookOnHold.java b/src/main/java/io/pillopl/library/lending/book/model/BookOnHold.java new file mode 100644 index 0000000..bb8f193 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/book/model/BookOnHold.java @@ -0,0 +1,80 @@ +package io.pillopl.library.lending.book.model; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.catalogue.BookType; +import io.pillopl.library.commons.aggregates.Version; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import io.pillopl.library.lending.patron.model.PatronEvent.BookCheckedOut; +import io.pillopl.library.lending.patron.model.PatronEvent.BookHoldCanceled; +import io.pillopl.library.lending.patron.model.PatronEvent.BookHoldExpired; +import io.pillopl.library.lending.patron.model.PatronEvent.BookReturned; +import io.pillopl.library.lending.patron.model.PatronId; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.NonNull; +import lombok.Value; + +import java.time.Instant; + +@Value +@AllArgsConstructor(access = AccessLevel.PACKAGE) +@EqualsAndHashCode(of = "bookInformation") +public class BookOnHold implements Book { + + @NonNull + BookInformation bookInformation; + + @NonNull + LibraryBranchId holdPlacedAt; + + @NonNull + PatronId byPatron; + + @NonNull + Instant holdTill; + + @NonNull + Version version; + + public BookOnHold(BookId bookId, BookType type, LibraryBranchId libraryBranchId, PatronId patronId, Instant holdTill, Version version) { + this(new BookInformation(bookId, type), libraryBranchId, patronId, holdTill, version); + } + + public AvailableBook handle(BookReturned bookReturned) { + return new AvailableBook( + bookInformation, new LibraryBranchId(bookReturned.getLibraryBranchId()), + version); + } + + public AvailableBook handle(BookHoldExpired bookHoldExpired) { + return new AvailableBook( + bookInformation, + new LibraryBranchId(bookHoldExpired.getLibraryBranchId()), + version); + } + + public CheckedOutBook handle(BookCheckedOut bookCheckedOut) { + return new CheckedOutBook( + bookInformation, + new LibraryBranchId(bookCheckedOut.getLibraryBranchId()), + new PatronId(bookCheckedOut.getPatronId()), + version); + } + + public AvailableBook handle(BookHoldCanceled bookHoldCanceled) { + return new AvailableBook( + bookInformation, new LibraryBranchId(bookHoldCanceled.getLibraryBranchId()), + version); + } + + + public BookId getBookId() { + return bookInformation.getBookId(); + } + + public boolean by(PatronId patronId) { + return byPatron.equals(patronId); + } +} + diff --git a/src/main/java/io/pillopl/library/lending/book/model/BookRepository.java b/src/main/java/io/pillopl/library/lending/book/model/BookRepository.java new file mode 100644 index 0000000..55a1294 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/book/model/BookRepository.java @@ -0,0 +1,11 @@ +package io.pillopl.library.lending.book.model; + +import io.pillopl.library.catalogue.BookId; +import io.vavr.control.Option; + +public interface BookRepository { + + Option findBy(BookId bookId); + + void save(Book book); +} diff --git a/src/main/java/io/pillopl/library/lending/book/model/CheckedOutBook.java b/src/main/java/io/pillopl/library/lending/book/model/CheckedOutBook.java new file mode 100644 index 0000000..ee2f206 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/book/model/CheckedOutBook.java @@ -0,0 +1,50 @@ +package io.pillopl.library.lending.book.model; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.catalogue.BookType; +import io.pillopl.library.commons.aggregates.Version; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import io.pillopl.library.lending.patron.model.PatronEvent; +import io.pillopl.library.lending.patron.model.PatronId; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.NonNull; +import lombok.Value; + +@Value +@AllArgsConstructor(access = AccessLevel.PACKAGE) +@EqualsAndHashCode(of = "bookInformation") +public class CheckedOutBook implements Book { + + @NonNull + BookInformation bookInformation; + + @NonNull + LibraryBranchId checkedOutAt; + + @NonNull + PatronId byPatron; + + @NonNull + Version version; + + public CheckedOutBook(BookId bookId, BookType type, LibraryBranchId libraryBranchId, PatronId patronId, Version version) { + this(new BookInformation(bookId, type), libraryBranchId, patronId, version); + } + + public BookId getBookId() { + return bookInformation.getBookId(); + } + + public AvailableBook handle(PatronEvent.BookReturned bookReturnedByPatron) { + return new AvailableBook( + bookInformation, + new LibraryBranchId(bookReturnedByPatron.getLibraryBranchId()), + version); + } + + + +} + diff --git a/src/main/java/io/pillopl/library/lending/dailysheet/infrastructure/DailySheetConfiguration.java b/src/main/java/io/pillopl/library/lending/dailysheet/infrastructure/DailySheetConfiguration.java new file mode 100644 index 0000000..9d50d56 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/dailysheet/infrastructure/DailySheetConfiguration.java @@ -0,0 +1,17 @@ +package io.pillopl.library.lending.dailysheet.infrastructure; + +import io.pillopl.library.lending.dailysheet.model.DailySheet; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Clock; + +@Configuration +public class DailySheetConfiguration { + + @Bean + DailySheet sheetsReadModel(JdbcTemplate jdbcTemplate) { + return new SheetsReadModel(jdbcTemplate, Clock.systemDefaultZone()); + } +} diff --git a/src/main/java/io/pillopl/library/lending/dailysheet/infrastructure/SheetsReadModel.java b/src/main/java/io/pillopl/library/lending/dailysheet/infrastructure/SheetsReadModel.java new file mode 100644 index 0000000..8121b98 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/dailysheet/infrastructure/SheetsReadModel.java @@ -0,0 +1,186 @@ +package io.pillopl.library.lending.dailysheet.infrastructure; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.lending.dailysheet.model.CheckoutsToOverdueSheet; +import io.pillopl.library.lending.dailysheet.model.DailySheet; +import io.pillopl.library.lending.dailysheet.model.ExpiredHold; +import io.pillopl.library.lending.dailysheet.model.HoldsToExpireSheet; +import io.pillopl.library.lending.dailysheet.model.OverdueCheckout; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import io.pillopl.library.lending.patron.model.PatronEvent.BookCheckedOut; +import io.pillopl.library.lending.patron.model.PatronEvent.BookHoldCanceled; +import io.pillopl.library.lending.patron.model.PatronEvent.BookHoldExpired; +import io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHold; +import io.pillopl.library.lending.patron.model.PatronEvent.BookReturned; +import io.pillopl.library.lending.patron.model.PatronId; +import io.vavr.control.Option; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.ColumnMapRowMapper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Timestamp; +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static io.vavr.collection.List.ofAll; +import static java.sql.Timestamp.from; +import static java.util.stream.Collectors.toList; + +@AllArgsConstructor(access = AccessLevel.PACKAGE) +class SheetsReadModel implements DailySheet { + + private final JdbcTemplate sheets; + private final Clock clock; + + @Override + public HoldsToExpireSheet queryForHoldsToExpireSheet() { + return new HoldsToExpireSheet(ofAll( + findHoldsToExpire() + .stream() + .map(this::toExpiredHold) + .collect(toList()))); + } + + private List> findHoldsToExpire() { + return sheets.query( + "SELECT h.book_id, h.hold_by_patron_id, h.hold_at_branch FROM holds_sheet h WHERE h.status = 'ACTIVE' and h.hold_till <= ?", + new Object[]{from(Instant.now(clock))}, + new ColumnMapRowMapper()); + } + + private ExpiredHold toExpiredHold(Map map) { + return new ExpiredHold( + new BookId((UUID) map.get("BOOK_ID")), + new PatronId((UUID) map.get("HOLD_BY_PATRON_ID")), + new LibraryBranchId((UUID) map.get("HOLD_AT_BRANCH"))); + } + + @Override + public CheckoutsToOverdueSheet queryForCheckoutsToOverdue() { + return new CheckoutsToOverdueSheet(ofAll( + findCheckoutsToOverdue() + .stream() + .map(this::toOverdueCheckout) + .collect(toList()))); + } + + private List> findCheckoutsToOverdue() { + return sheets.query( + "SELECT c.book_id, c.checked_out_by_patron_id, c.checked_out_at_branch FROM checkouts_sheet c WHERE c.status = 'CHECKEDOUT' and c.checkout_till <= ?", + new Object[]{from(Instant.now(clock))}, + new ColumnMapRowMapper()); + } + + private OverdueCheckout toOverdueCheckout(Map map) { + return new OverdueCheckout( + new BookId((UUID) map.get("BOOK_ID")), + new PatronId((UUID) map.get("CHECKED_OUT_BY_PATRON_ID")), + new LibraryBranchId((UUID) map.get("CHECKED_OUT_AT_BRANCH"))); + } + + @Override + @Transactional + @EventListener + public void handle(BookPlacedOnHold event) { + try { + createNewHold(event); + } catch (DuplicateKeyException ex) { + //idempotent operation + } + } + + private void createNewHold(BookPlacedOnHold event) { + sheets.update("INSERT INTO holds_sheet " + + "(id, book_id, status, hold_event_id, hold_by_patron_id, hold_at, hold_till, expired_at, canceled_at, hold_at_branch, checked_out_at) VALUES " + + "(holds_sheet_seq.nextval, ?, ?, ?, ?, ?, ?, null, null, ?, null)", + event.getBookId(), + "ACTIVE", + event.getEventId(), + event.getPatronId(), + from(event.getWhen()), + Option.of(event.getHoldTill()).map(Timestamp::from).getOrNull(), + event.getLibraryBranchId()); + } + + + @Override + public void handle(BookHoldCanceled event) { + sheets.update("UPDATE holds_sheet SET canceled_at = ?, status = 'CANCELED' WHERE canceled_at IS NULL AND book_id = ? AND hold_by_patron_id = ?", + from(event.getWhen()), + event.getBookId(), + event.getPatronId()); + } + + @Override + @EventListener + public void handle(BookHoldExpired event) { + sheets.update("UPDATE holds_sheet SET expired_at = ?, status = 'EXPIRED' WHERE expired_at IS NULL AND book_id = ? AND hold_by_patron_id = ?", + from(event.getWhen()), + event.getBookId(), + event.getPatronId()); + } + + @Override + @EventListener + public void handle(BookCheckedOut event) { + try { + createNewCheckout(event); + } catch (DuplicateKeyException ex) { + //idempotent operation + } + } + + private void createNewCheckout(BookCheckedOut event) { + sheets.update("INSERT INTO checkouts_sheet " + + "(id, book_id, status, checkout_event_id, checked_out_by_patron_id, checked_out_at, checkout_till, checked_out_at_branch, returned_at) VALUES " + + "(checkouts_sheet_seq.nextval, ?, ?, ?, ?, ?, ?, ?, null)", + event.getBookId(), + "CHECKEDOUT", + event.getEventId(), + event.getPatronId(), + from(event.getWhen()), + from(event.getTill()), + event.getLibraryBranchId()); + sheets.update("UPDATE holds_sheet SET checked_out_at = ?, status = 'CHECKEDOUT' WHERE checked_out_at IS NULL AND book_id = ? AND hold_by_patron_id = ?", + from(event.getWhen()), + event.getBookId(), + event.getPatronId()); + } + + @Override + @EventListener + public void handle(BookReturned event) { + int results = markAsReturned(event); + if (results == 0) { + insertAsReturnedWithCheckedOutEventMissing(event); + } + + } + + private int markAsReturned(BookReturned event) { + return sheets.update("UPDATE checkouts_sheet SET returned_at = ?, status = 'RETURNED' WHERE returned_at IS NULL AND book_id = ? AND checked_out_by_patron_id = ?", + from(event.getWhen()), + event.getBookId(), + event.getPatronId()); + } + + private void insertAsReturnedWithCheckedOutEventMissing(BookReturned event) { + sheets.update("INSERT INTO checkouts_sheet " + + "(id, book_id, status, checkout_event_id, checked_out_by_patron_id, checked_out_at, checked_out_till, returned_at) VALUES " + + "(checkouts_sheet_seq.nextval, ?, ?, ?, ?, null, null, ?)", + event.getBookId(), + "CHECKEDOUT", + event.getEventId(), + event.getPatronId(), + from(event.getWhen())); + } + +} + diff --git a/src/main/java/io/pillopl/library/lending/dailysheet/model/CheckoutsToOverdueSheet.java b/src/main/java/io/pillopl/library/lending/dailysheet/model/CheckoutsToOverdueSheet.java new file mode 100644 index 0000000..b055f09 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/dailysheet/model/CheckoutsToOverdueSheet.java @@ -0,0 +1,24 @@ +package io.pillopl.library.lending.dailysheet.model; + +import io.pillopl.library.lending.patron.model.PatronEvent.OverdueCheckoutRegistered; +import io.vavr.collection.List; +import io.vavr.collection.Stream; +import lombok.NonNull; +import lombok.Value; + +@Value +public class CheckoutsToOverdueSheet { + + @NonNull + List checkouts; + + public Stream toStreamOfEvents() { + return checkouts.toStream() + .map(OverdueCheckout::toEvent); + } + + public int count() { + return checkouts.size(); + } + +} diff --git a/src/main/java/io/pillopl/library/lending/dailysheet/model/DailySheet.java b/src/main/java/io/pillopl/library/lending/dailysheet/model/DailySheet.java new file mode 100644 index 0000000..97b5af8 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/dailysheet/model/DailySheet.java @@ -0,0 +1,22 @@ +package io.pillopl.library.lending.dailysheet.model; + +import io.pillopl.library.lending.patron.model.PatronEvent.*; + +public interface DailySheet { + + CheckoutsToOverdueSheet queryForCheckoutsToOverdue(); + + HoldsToExpireSheet queryForHoldsToExpireSheet(); + + void handle(BookPlacedOnHold event); + + void handle(BookHoldCanceled event); + + void handle(BookHoldExpired event); + + void handle(BookCheckedOut event); + + void handle(BookReturned event); + + +} diff --git a/src/main/java/io/pillopl/library/lending/dailysheet/model/ExpiredHold.java b/src/main/java/io/pillopl/library/lending/dailysheet/model/ExpiredHold.java new file mode 100644 index 0000000..728253f --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/dailysheet/model/ExpiredHold.java @@ -0,0 +1,18 @@ +package io.pillopl.library.lending.dailysheet.model; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import io.pillopl.library.lending.patron.model.PatronEvent.BookHoldExpired; +import io.pillopl.library.lending.patron.model.PatronId; +import lombok.Value; + +@Value +public class ExpiredHold { + private final BookId heldBook; + private final PatronId patron; + private final LibraryBranchId library; + + BookHoldExpired toEvent() { + return BookHoldExpired.now(this.heldBook, this.patron, this.library); + } +} diff --git a/src/main/java/io/pillopl/library/lending/dailysheet/model/HoldsToExpireSheet.java b/src/main/java/io/pillopl/library/lending/dailysheet/model/HoldsToExpireSheet.java new file mode 100644 index 0000000..0ac556a --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/dailysheet/model/HoldsToExpireSheet.java @@ -0,0 +1,27 @@ +package io.pillopl.library.lending.dailysheet.model; + +import io.pillopl.library.lending.patron.model.PatronEvent; +import io.vavr.collection.List; +import io.vavr.collection.Stream; +import lombok.NonNull; +import lombok.Value; +import org.springframework.context.event.EventListener; + +@Value +public class HoldsToExpireSheet { + + @NonNull + List expiredHolds; + + @EventListener + public Stream toStreamOfEvents() { + return expiredHolds + .toStream() + .map(ExpiredHold::toEvent); + } + + public int count() { + return expiredHolds.size(); + } + +} diff --git a/src/main/java/io/pillopl/library/lending/dailysheet/model/OverdueCheckout.java b/src/main/java/io/pillopl/library/lending/dailysheet/model/OverdueCheckout.java new file mode 100644 index 0000000..244907c --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/dailysheet/model/OverdueCheckout.java @@ -0,0 +1,18 @@ +package io.pillopl.library.lending.dailysheet.model; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import io.pillopl.library.lending.patron.model.PatronEvent.OverdueCheckoutRegistered; +import io.pillopl.library.lending.patron.model.PatronId; +import lombok.Value; + +@Value +public class OverdueCheckout { + private final BookId checkedOutBook; + private final PatronId patron; + private final LibraryBranchId library; + + OverdueCheckoutRegistered toEvent() { + return OverdueCheckoutRegistered.now(this.patron, this.checkedOutBook, this.library); + } +} diff --git a/src/main/java/io/pillopl/library/lending/librarybranch/model/LibraryBranchId.java b/src/main/java/io/pillopl/library/lending/librarybranch/model/LibraryBranchId.java new file mode 100644 index 0000000..81d5581 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/librarybranch/model/LibraryBranchId.java @@ -0,0 +1,12 @@ +package io.pillopl.library.lending.librarybranch.model; + +import lombok.NonNull; +import lombok.Value; + +import java.util.UUID; + +@Value +public class LibraryBranchId { + + @NonNull UUID libraryBranchId; +} diff --git a/src/main/java/io/pillopl/library/lending/patron/application/checkout/CheckOutBookCommand.java b/src/main/java/io/pillopl/library/lending/patron/application/checkout/CheckOutBookCommand.java new file mode 100644 index 0000000..74134a6 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/application/checkout/CheckOutBookCommand.java @@ -0,0 +1,26 @@ +package io.pillopl.library.lending.patron.application.checkout; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import io.pillopl.library.lending.patron.model.CheckoutDuration; +import io.pillopl.library.lending.patron.model.PatronId; +import java.time.Instant; +import lombok.NonNull; +import lombok.Value; + +@Value +public class CheckOutBookCommand { + @NonNull Instant timestamp; + @NonNull PatronId patronId; + @NonNull LibraryBranchId libraryId; + @NonNull BookId bookId; + @NonNull Integer noOfDays; + + public static CheckOutBookCommand create(PatronId patronId, LibraryBranchId libraryBranchId, BookId bookId, int noOfDays) { + return new CheckOutBookCommand(Instant.now(), patronId, libraryBranchId, bookId, noOfDays); + } + + CheckoutDuration getCheckoutDuration() { + return CheckoutDuration.forNoOfDays(noOfDays); + } +} diff --git a/src/main/java/io/pillopl/library/lending/patron/application/checkout/CheckingOutBookOnHold.java b/src/main/java/io/pillopl/library/lending/patron/application/checkout/CheckingOutBookOnHold.java new file mode 100644 index 0000000..a99186e --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/application/checkout/CheckingOutBookOnHold.java @@ -0,0 +1,67 @@ +package io.pillopl.library.lending.patron.application.checkout; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.commons.commands.Result; +import io.pillopl.library.lending.book.model.BookOnHold; +import io.pillopl.library.lending.patron.application.hold.FindBookOnHold; +import io.pillopl.library.lending.patron.model.Patron; +import io.pillopl.library.lending.patron.model.PatronEvent.BookCheckedOut; +import io.pillopl.library.lending.patron.model.PatronEvent.BookCheckingOutFailed; +import io.pillopl.library.lending.patron.model.PatronId; +import io.pillopl.library.lending.patron.model.Patrons; +import io.vavr.control.Either; +import io.vavr.control.Try; +import lombok.AllArgsConstructor; +import lombok.NonNull; + + +import static io.pillopl.library.commons.commands.Result.Rejection; +import static io.pillopl.library.commons.commands.Result.Success; +import static io.vavr.API.$; +import static io.vavr.API.Case; +import static io.vavr.API.Match; +import static io.vavr.Patterns.$Left; +import static io.vavr.Patterns.$Right; + +@AllArgsConstructor +public class CheckingOutBookOnHold { + + private final FindBookOnHold findBookOnHold; + private final Patrons patronRepository; + + public Try checkOut(@NonNull CheckOutBookCommand command) { + return Try.of(() -> { + BookOnHold bookOnHold = find(command.getBookId(), command.getPatronId()); + Patron patron = find(command.getPatronId()); + Either result = patron.checkOut(bookOnHold, command.getCheckoutDuration()); + return Match(result).of( + Case($Left($()), this::publishEvents), + Case($Right($()), this::publishEvents)); + }); + } + + private Result publishEvents(BookCheckedOut bookCheckedOut) { + patronRepository + .publish(bookCheckedOut); + return Success; + } + + private Result publishEvents(BookCheckingOutFailed bookCheckingOutFailed) { + patronRepository + .publish(bookCheckingOutFailed); + return Rejection; + } + + private BookOnHold find(BookId id, PatronId patronId) { + return findBookOnHold + .findBookOnHold(id, patronId) + .getOrElseThrow(() -> new IllegalArgumentException("Cannot find book on hold with Id: " + id.getBookId())); + } + + private Patron find(PatronId patronId) { + return patronRepository + .findBy(patronId) + .getOrElseThrow(() -> new IllegalArgumentException("Patron with given Id does not exists: " + patronId.getPatronId())); + } + +} diff --git a/src/main/java/io/pillopl/library/lending/patron/application/checkout/RegisteringOverdueCheckout.java b/src/main/java/io/pillopl/library/lending/patron/application/checkout/RegisteringOverdueCheckout.java new file mode 100644 index 0000000..db38b44 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/application/checkout/RegisteringOverdueCheckout.java @@ -0,0 +1,30 @@ +package io.pillopl.library.lending.patron.application.checkout; + +import io.pillopl.library.commons.commands.BatchResult; +import io.pillopl.library.lending.dailysheet.model.DailySheet; +import io.pillopl.library.lending.patron.model.PatronEvent.OverdueCheckoutRegistered; +import io.pillopl.library.lending.patron.model.Patrons; +import io.vavr.control.Try; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public class RegisteringOverdueCheckout { + + private final DailySheet find; + private final Patrons patronRepository; + + public Try registerOverdueCheckouts() { + return Try.of(() -> + find.queryForCheckoutsToOverdue() + .toStreamOfEvents() + .map(this::publish) + .find(Try::isFailure) + .map(handleEventError -> BatchResult.SomeFailed) + .getOrElse(BatchResult.FullSuccess)); + } + + private Try publish(OverdueCheckoutRegistered event) { + return Try.run(() -> patronRepository.publish(event)); + } + +} diff --git a/src/main/java/io/pillopl/library/lending/patron/application/hold/CancelHoldCommand.java b/src/main/java/io/pillopl/library/lending/patron/application/hold/CancelHoldCommand.java new file mode 100644 index 0000000..2ad4ff6 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/application/hold/CancelHoldCommand.java @@ -0,0 +1,16 @@ +package io.pillopl.library.lending.patron.application.hold; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.lending.patron.model.PatronId; +import lombok.NonNull; +import lombok.Value; + +import java.time.Instant; + +@Value +public class CancelHoldCommand { + @NonNull Instant timestamp; + @NonNull PatronId patronId; + @NonNull BookId bookId; + +} diff --git a/src/main/java/io/pillopl/library/lending/patron/application/hold/CancelingHold.java b/src/main/java/io/pillopl/library/lending/patron/application/hold/CancelingHold.java new file mode 100644 index 0000000..1fa6c32 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/application/hold/CancelingHold.java @@ -0,0 +1,62 @@ +package io.pillopl.library.lending.patron.application.hold; + +import io.pillopl.library.commons.commands.Result; +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.lending.book.model.BookOnHold; +import io.pillopl.library.lending.patron.model.Patron; +import io.pillopl.library.lending.patron.model.PatronEvent.BookHoldCanceled; +import io.pillopl.library.lending.patron.model.PatronEvent.BookHoldCancelingFailed; +import io.pillopl.library.lending.patron.model.Patrons; +import io.pillopl.library.lending.patron.model.PatronId; +import io.vavr.control.Either; +import io.vavr.control.Try; +import lombok.AllArgsConstructor; +import lombok.NonNull; + +import static io.pillopl.library.commons.commands.Result.Rejection; +import static io.pillopl.library.commons.commands.Result.Success; +import static io.vavr.API.*; +import static io.vavr.Patterns.$Left; +import static io.vavr.Patterns.$Right; + +@AllArgsConstructor +public class CancelingHold { + + private final FindBookOnHold findBookOnHold; + private final Patrons patronRepository; + + public Try cancelHold(@NonNull CancelHoldCommand command) { + return Try.of(() -> { + BookOnHold bookOnHold = find(command.getBookId(), command.getPatronId()); + Patron patron = find(command.getPatronId()); + Either result = patron.cancelHold(bookOnHold); + return Match(result).of( + Case($Left($()), this::publishEvents), + Case($Right($()), this::publishEvents) + ); + }); + } + + private Result publishEvents(BookHoldCanceled bookHoldCanceled) { + patronRepository.publish(bookHoldCanceled); + return Success; + } + + private Result publishEvents(BookHoldCancelingFailed bookHoldCancelingFailed) { + patronRepository.publish(bookHoldCancelingFailed); + return Rejection; + } + + private BookOnHold find(BookId bookId, PatronId patronId) { + return findBookOnHold + .findBookOnHold(bookId, patronId) + .getOrElseThrow(() -> new IllegalArgumentException("Cannot find book on hold with Id: " + bookId.getBookId())); + } + + private Patron find(PatronId patronId) { + return patronRepository + .findBy(patronId) + .getOrElseThrow(() -> new IllegalArgumentException("Patron with given Id does not exists: " + patronId.getPatronId())); + } +} + diff --git a/src/main/java/io/pillopl/library/lending/patron/application/hold/ExpiringHolds.java b/src/main/java/io/pillopl/library/lending/patron/application/hold/ExpiringHolds.java new file mode 100644 index 0000000..ddd3d12 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/application/hold/ExpiringHolds.java @@ -0,0 +1,31 @@ +package io.pillopl.library.lending.patron.application.hold; + +import io.pillopl.library.commons.commands.BatchResult; +import io.pillopl.library.lending.dailysheet.model.DailySheet; +import io.pillopl.library.lending.patron.model.PatronEvent; +import io.pillopl.library.lending.patron.model.Patrons; +import io.vavr.control.Try; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public class ExpiringHolds { + + private final DailySheet find; + private final Patrons patronRepository; + + public Try expireHolds() { + return Try.of(() -> + find.queryForHoldsToExpireSheet() + .toStreamOfEvents() + .map(this::publish) + .find(Try::isFailure) + .map(handleEventError -> BatchResult.SomeFailed) + .getOrElse(BatchResult.FullSuccess)); + } + + private Try publish(PatronEvent.BookHoldExpired event) { + return Try.run(() -> patronRepository.publish(event)); + } + + +} diff --git a/src/main/java/io/pillopl/library/lending/patron/application/hold/FindAvailableBook.java b/src/main/java/io/pillopl/library/lending/patron/application/hold/FindAvailableBook.java new file mode 100644 index 0000000..998c25c --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/application/hold/FindAvailableBook.java @@ -0,0 +1,12 @@ +package io.pillopl.library.lending.patron.application.hold; + + +import io.pillopl.library.lending.book.model.AvailableBook; +import io.pillopl.library.catalogue.BookId; +import io.vavr.control.Option; + +@FunctionalInterface +public interface FindAvailableBook { + + Option findAvailableBookBy(BookId bookId); +} diff --git a/src/main/java/io/pillopl/library/lending/patron/application/hold/FindBookOnHold.java b/src/main/java/io/pillopl/library/lending/patron/application/hold/FindBookOnHold.java new file mode 100644 index 0000000..da0962d --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/application/hold/FindBookOnHold.java @@ -0,0 +1,13 @@ +package io.pillopl.library.lending.patron.application.hold; + + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.lending.book.model.BookOnHold; +import io.pillopl.library.lending.patron.model.PatronId; +import io.vavr.control.Option; + +@FunctionalInterface +public interface FindBookOnHold { + + Option findBookOnHold(BookId bookId, PatronId patronId); +} diff --git a/src/main/java/io/pillopl/library/lending/patron/application/hold/HandleDuplicateHold.java b/src/main/java/io/pillopl/library/lending/patron/application/hold/HandleDuplicateHold.java new file mode 100644 index 0000000..5f9f2e3 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/application/hold/HandleDuplicateHold.java @@ -0,0 +1,41 @@ +package io.pillopl.library.lending.patron.application.hold; + +import io.pillopl.library.commons.commands.Result; +import io.pillopl.library.lending.book.model.BookDuplicateHoldFound; +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.lending.patron.model.PatronId; +import io.vavr.control.Try; +import org.springframework.context.event.EventListener; + +import java.time.Clock; + +import static java.time.Instant.now; + +public class HandleDuplicateHold { + + private final CancelingHold cancelingHold; + private final Clock clock; + + public HandleDuplicateHold(CancelingHold cancelingHold) { + this(cancelingHold, Clock.systemUTC()); + } + + public HandleDuplicateHold(CancelingHold cancelingHold, Clock clock) { + this.cancelingHold = cancelingHold; + this.clock = clock; + } + + @EventListener + public Try handle(BookDuplicateHoldFound event) { + return cancelingHold.cancelHold(cancelHoldCommandFrom(event)); + } + + private CancelHoldCommand cancelHoldCommandFrom(BookDuplicateHoldFound event) { + return new CancelHoldCommand( + now(clock), + new PatronId(event.getSecondPatronId()), + new BookId(event.getBookId())); + } + +} + diff --git a/src/main/java/io/pillopl/library/lending/patron/application/hold/PlaceOnHoldCommand.java b/src/main/java/io/pillopl/library/lending/patron/application/hold/PlaceOnHoldCommand.java new file mode 100644 index 0000000..4b550ce --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/application/hold/PlaceOnHoldCommand.java @@ -0,0 +1,35 @@ +package io.pillopl.library.lending.patron.application.hold; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import io.pillopl.library.lending.patron.model.HoldDuration; +import io.pillopl.library.lending.patron.model.NumberOfDays; +import io.pillopl.library.lending.patron.model.PatronId; +import io.vavr.control.Option; +import java.time.Instant; +import lombok.NonNull; +import lombok.Value; + +@Value +public class PlaceOnHoldCommand { + @NonNull Instant timestamp; + @NonNull PatronId patronId; + @NonNull LibraryBranchId libraryId; + @NonNull BookId bookId; + Option noOfDays; + + static PlaceOnHoldCommand closeEnded(PatronId patronId, LibraryBranchId libraryBranchId, BookId bookId, int forDays) { + return new PlaceOnHoldCommand(Instant.now(), patronId, libraryBranchId, bookId, Option.of(forDays)); + } + + static PlaceOnHoldCommand openEnded(PatronId patronId, LibraryBranchId libraryBranchId, BookId bookId) { + return new PlaceOnHoldCommand(Instant.now(), patronId, libraryBranchId, bookId, Option.none()); + } + + HoldDuration getHoldDuration() { + return noOfDays + .map(NumberOfDays::of) + .map(HoldDuration::closeEnded) + .getOrElse(HoldDuration.openEnded()); + } +} diff --git a/src/main/java/io/pillopl/library/lending/patron/application/hold/PlacingOnHold.java b/src/main/java/io/pillopl/library/lending/patron/application/hold/PlacingOnHold.java new file mode 100644 index 0000000..7cd0ca6 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/application/hold/PlacingOnHold.java @@ -0,0 +1,61 @@ +package io.pillopl.library.lending.patron.application.hold; + +import io.pillopl.library.commons.commands.Result; +import io.pillopl.library.lending.book.model.AvailableBook; +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.lending.patron.model.*; +import io.pillopl.library.lending.patron.model.PatronEvent.BookHoldFailed; +import io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents; +import io.vavr.control.Either; +import io.vavr.control.Try; +import lombok.AllArgsConstructor; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + + +import static io.pillopl.library.commons.commands.Result.Success; +import static io.vavr.API.*; +import static io.vavr.Patterns.$Left; +import static io.vavr.Patterns.$Right; + +@AllArgsConstructor +@Slf4j +public class PlacingOnHold { + + private final FindAvailableBook findAvailableBook; + private final Patrons patronRepository; + + public Try placeOnHold(@NonNull PlaceOnHoldCommand command) { + return Try.of(() -> { + AvailableBook availableBook = find(command.getBookId()); + Patron patron = find(command.getPatronId()); + Either result = patron.placeOnHold(availableBook, command.getHoldDuration()); + return Match(result).of( + Case($Left($()), this::publishEvents), + Case($Right($()), this::publishEvents) + ); + }).onFailure(t -> log.error("Failed to place a hold", t)); + } + + private Result publishEvents(BookPlacedOnHoldEvents placedOnHold) { + patronRepository.publish(placedOnHold); + return Success; + } + + private Result publishEvents(BookHoldFailed bookHoldFailed) { + patronRepository.publish(bookHoldFailed); + return Result.Rejection; + } + + private AvailableBook find(BookId id) { + return findAvailableBook + .findAvailableBookBy(id) + .getOrElseThrow(() -> new IllegalArgumentException("Cannot find available book with Id: " + id.getBookId())); + } + + private Patron find(PatronId patronId) { + return patronRepository + .findBy(patronId) + .getOrElseThrow(() -> new IllegalArgumentException("Patron with given Id does not exists: " + patronId.getPatronId())); + } +} diff --git a/src/main/java/io/pillopl/library/lending/patron/infrastructure/HoldDatabaseEntity.java b/src/main/java/io/pillopl/library/lending/patron/infrastructure/HoldDatabaseEntity.java new file mode 100644 index 0000000..7b221cf --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/infrastructure/HoldDatabaseEntity.java @@ -0,0 +1,35 @@ +package io.pillopl.library.lending.patron.infrastructure; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; + +import java.time.Instant; +import java.util.UUID; + +@NoArgsConstructor(access = AccessLevel.PACKAGE) +@Getter +class HoldDatabaseEntity { + + @Id + Long id; + UUID patronId; + UUID bookId; + UUID libraryBranchId; + Instant till; + + HoldDatabaseEntity(UUID bookId, UUID patronId, UUID libraryBranchId, Instant till) { + this.bookId = bookId; + this.patronId = patronId; + this.libraryBranchId = libraryBranchId; + this.till = till; + } + + boolean is(UUID patronId, UUID bookId, UUID libraryBranchId) { + return this.patronId.equals(patronId) && + this.bookId.equals(bookId) && + this.libraryBranchId.equals(libraryBranchId); + } + +} diff --git a/src/main/java/io/pillopl/library/lending/patron/infrastructure/OverdueCheckoutDatabaseEntity.java b/src/main/java/io/pillopl/library/lending/patron/infrastructure/OverdueCheckoutDatabaseEntity.java new file mode 100644 index 0000000..453aa12 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/infrastructure/OverdueCheckoutDatabaseEntity.java @@ -0,0 +1,33 @@ +package io.pillopl.library.lending.patron.infrastructure; + +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; + +import java.util.UUID; + +@NoArgsConstructor(access = AccessLevel.PACKAGE) +@EqualsAndHashCode +@Getter +class OverdueCheckoutDatabaseEntity { + + @Id + Long id; + UUID patronId; + UUID bookId; + UUID libraryBranchId; + + OverdueCheckoutDatabaseEntity(UUID bookId, UUID patronId, UUID libraryBranchId) { + this.bookId = bookId; + this.patronId = patronId; + this.libraryBranchId = libraryBranchId; + } + + boolean is(UUID patronId, UUID bookId, UUID libraryBranchId) { + return this.patronId.equals(patronId) && + this.bookId.equals(bookId) && + this.libraryBranchId.equals(libraryBranchId); + } +} diff --git a/src/main/java/io/pillopl/library/lending/patron/infrastructure/PatronConfiguration.java b/src/main/java/io/pillopl/library/lending/patron/infrastructure/PatronConfiguration.java new file mode 100644 index 0000000..f1fd430 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/infrastructure/PatronConfiguration.java @@ -0,0 +1,61 @@ +package io.pillopl.library.lending.patron.infrastructure; + +import io.pillopl.library.commons.events.DomainEvents; +import io.pillopl.library.lending.dailysheet.model.DailySheet; +import io.pillopl.library.lending.patron.application.checkout.CheckingOutBookOnHold; +import io.pillopl.library.lending.patron.application.checkout.RegisteringOverdueCheckout; +import io.pillopl.library.lending.patron.application.hold.CancelingHold; +import io.pillopl.library.lending.patron.application.hold.ExpiringHolds; +import io.pillopl.library.lending.patron.application.hold.FindAvailableBook; +import io.pillopl.library.lending.patron.application.hold.FindBookOnHold; +import io.pillopl.library.lending.patron.application.hold.HandleDuplicateHold; +import io.pillopl.library.lending.patron.application.hold.PlacingOnHold; +import io.pillopl.library.lending.patron.model.PatronFactory; +import io.pillopl.library.lending.patron.model.Patrons; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories; + +@Configuration +@EnableJdbcRepositories +public class PatronConfiguration { + + @Bean + CheckingOutBookOnHold checkingOutBookOnHold(FindBookOnHold findBookOnHold, Patrons patronRepository) { + return new CheckingOutBookOnHold(findBookOnHold, patronRepository); + } + + @Bean + RegisteringOverdueCheckout registeringOverdueCheckout(DailySheet dailySheet, Patrons patronRepository) { + return new RegisteringOverdueCheckout(dailySheet, patronRepository); + } + + @Bean + CancelingHold cancelingHold(FindBookOnHold findBookOnHold, Patrons patronRepository) { + return new CancelingHold(findBookOnHold, patronRepository); + } + + @Bean + ExpiringHolds expiringHolds(DailySheet dailySheet, Patrons patronRepository) { + return new ExpiringHolds(dailySheet, patronRepository); + } + + @Bean + HandleDuplicateHold handleDuplicateHold(CancelingHold cancelingHold) { + return new HandleDuplicateHold(cancelingHold); + } + + @Bean + PlacingOnHold placingOnHold(FindAvailableBook findAvailableBook, Patrons patronRepository) { + return new PlacingOnHold(findAvailableBook, patronRepository); + } + + @Bean + Patrons patronRepository(PatronEntityRepository patronEntityRepository, + DomainEvents domainEvents) { + return new PatronsDatabaseRepository( + patronEntityRepository, + new DomainModelMapper(new PatronFactory()), + domainEvents); + } +} diff --git a/src/main/java/io/pillopl/library/lending/patron/infrastructure/PatronDatabaseEntity.java b/src/main/java/io/pillopl/library/lending/patron/infrastructure/PatronDatabaseEntity.java new file mode 100644 index 0000000..1d11c02 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/infrastructure/PatronDatabaseEntity.java @@ -0,0 +1,103 @@ +package io.pillopl.library.lending.patron.infrastructure; + + +import io.pillopl.library.lending.patron.model.PatronEvent; +import io.pillopl.library.lending.patron.model.PatronEvent.*; +import io.pillopl.library.lending.patron.model.PatronId; +import io.pillopl.library.lending.patron.model.PatronType; +import io.vavr.API; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +import static io.vavr.API.$; +import static io.vavr.API.Case; +import static io.vavr.Predicates.instanceOf; + +@NoArgsConstructor(access = AccessLevel.PACKAGE) +class PatronDatabaseEntity { + + @Id + Long id; + UUID patronId; + PatronType patronType; + Set booksOnHold; + Set checkouts; + + PatronDatabaseEntity(PatronId patronId, PatronType patronType) { + this.patronId = patronId.getPatronId(); + this.patronType = patronType; + this.booksOnHold = new HashSet<>(); + this.checkouts = new HashSet<>(); + } + + PatronDatabaseEntity handle(PatronEvent event) { + return API.Match(event).of( + Case($(instanceOf(BookPlacedOnHoldEvents.class)), this::handle), + Case($(instanceOf(BookPlacedOnHold.class)), this::handle), + Case($(instanceOf(BookCheckedOut.class)), this::handle), + Case($(instanceOf(BookHoldCanceled.class)), this::handle), + Case($(instanceOf(BookHoldExpired.class)), this::handle), + Case($(instanceOf(OverdueCheckoutRegistered.class)), this::handle), + Case($(instanceOf(BookReturned.class)), this::handle) + + ); + } + + private PatronDatabaseEntity handle(BookPlacedOnHoldEvents placedOnHoldEvents) { + BookPlacedOnHold event = placedOnHoldEvents.getBookPlacedOnHold(); + return handle(event); + } + + private PatronDatabaseEntity handle(BookPlacedOnHold event) { + booksOnHold.add(new HoldDatabaseEntity(event.getBookId(), event.getPatronId(), event.getLibraryBranchId(), event.getHoldTill())); + return this; + } + + private PatronDatabaseEntity handle(BookHoldCanceled event) { + return removeHoldIfPresent(event.getPatronId(), event.getBookId(), event.getLibraryBranchId()); + } + + + private PatronDatabaseEntity handle(BookCheckedOut event) { + return removeHoldIfPresent(event.getPatronId(), event.getBookId(), event.getLibraryBranchId()); + } + + private PatronDatabaseEntity handle(BookHoldExpired event) { + return removeHoldIfPresent(event.getPatronId(), event.getBookId(), event.getLibraryBranchId()); + } + + private PatronDatabaseEntity handle(OverdueCheckoutRegistered event) { + checkouts.add(new OverdueCheckoutDatabaseEntity(event.getBookId(), event.getPatronId(), event.getLibraryBranchId())); + return this; + } + + private PatronDatabaseEntity handle(BookReturned event) { + return removeOverdueCheckoutIfPresent(event.getPatronId(), event.getBookId(), event.getLibraryBranchId()); + } + + private PatronDatabaseEntity removeHoldIfPresent(UUID patronId, UUID bookId, UUID libraryBranchId) { + booksOnHold + .stream() + .filter(entity -> entity.is(patronId, bookId, libraryBranchId)) + .findAny() + .ifPresent(entity -> booksOnHold.remove(entity)); + return this; + } + + private PatronDatabaseEntity removeOverdueCheckoutIfPresent(UUID patronId, UUID bookId, UUID libraryBranchId) { + checkouts + .stream() + .filter(entity -> entity.is(patronId, bookId, libraryBranchId)) + .findAny() + .ifPresent(entity -> checkouts.remove(entity)); + return this; + } + +} + + diff --git a/src/main/java/io/pillopl/library/lending/patron/infrastructure/PatronsDatabaseRepository.java b/src/main/java/io/pillopl/library/lending/patron/infrastructure/PatronsDatabaseRepository.java new file mode 100644 index 0000000..ebb94e4 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/infrastructure/PatronsDatabaseRepository.java @@ -0,0 +1,109 @@ +package io.pillopl.library.lending.patron.infrastructure; + +import io.pillopl.library.commons.events.DomainEvents; +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import io.pillopl.library.lending.patron.model.*; +import io.pillopl.library.lending.patron.model.PatronEvent.PatronCreated; +import io.vavr.Tuple; +import io.vavr.Tuple2; +import io.vavr.control.Option; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import org.springframework.data.jdbc.repository.query.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; + +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.UUID; + +import static io.vavr.API.*; +import static io.vavr.Predicates.instanceOf; +import static java.util.stream.Collectors.*; + +@AllArgsConstructor(access = AccessLevel.PACKAGE) +class PatronsDatabaseRepository implements Patrons { + + private final PatronEntityRepository patronEntityRepository; + private final DomainModelMapper domainModelMapper; + private final DomainEvents domainEvents; + + @Override + public Option findBy(PatronId patronId) { + return Option.of(patronEntityRepository + .findByPatronId(patronId.getPatronId())) + .map(domainModelMapper::map); + } + + @Override + public Patron publish(PatronEvent domainEvent) { + Patron result = Match(domainEvent).of( + Case($(instanceOf(PatronCreated.class)), this::createNewPatron), + Case($(), this::handleNextEvent)); + domainEvents.publish(domainEvent.normalize()); + return result; + } + + private Patron createNewPatron(PatronCreated domainEvent) { + PatronDatabaseEntity entity = patronEntityRepository + .save(new PatronDatabaseEntity(domainEvent.patronId(), domainEvent.getPatronType())); + return domainModelMapper.map(entity); + } + + private Patron handleNextEvent(PatronEvent domainEvent) { + PatronDatabaseEntity entity = patronEntityRepository.findByPatronId(domainEvent.patronId().getPatronId()); + entity = entity.handle(domainEvent); + entity = patronEntityRepository.save(entity); + return domainModelMapper.map(entity); + } + +} + +interface PatronEntityRepository extends CrudRepository { + + @Query("SELECT p.* FROM patron_database_entity p where p.patron_id = :patronId") + PatronDatabaseEntity findByPatronId(@Param("patronId") UUID patronId); + +} + +@AllArgsConstructor +class DomainModelMapper { + + private final PatronFactory patronFactory; + + Patron map(PatronDatabaseEntity entity) { + return patronFactory.create( + entity.patronType, + new PatronId(entity.patronId), + mapPatronHolds(entity), + mapPatronOverdueCheckouts(entity) + ); + } + + Map> mapPatronOverdueCheckouts(PatronDatabaseEntity patronDatabaseEntity) { + return + patronDatabaseEntity + .checkouts + .stream() + .collect(groupingBy(OverdueCheckoutDatabaseEntity::getLibraryBranchId, toSet())) + .entrySet() + .stream() + .collect(toMap( + (Entry> entry) -> new LibraryBranchId(entry.getKey()), entry -> entry + .getValue() + .stream() + .map(entity -> (new BookId(entity.bookId))) + .collect(toSet()))); + } + + Set> mapPatronHolds(PatronDatabaseEntity patronDatabaseEntity) { + return patronDatabaseEntity + .booksOnHold + .stream() + .map(entity -> Tuple.of((new BookId(entity.bookId)), new LibraryBranchId(entity.libraryBranchId))) + .collect(toSet()); + } + +} diff --git a/src/main/java/io/pillopl/library/lending/patron/model/CheckoutDuration.java b/src/main/java/io/pillopl/library/lending/patron/model/CheckoutDuration.java new file mode 100644 index 0000000..19365eb --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/model/CheckoutDuration.java @@ -0,0 +1,43 @@ +package io.pillopl.library.lending.patron.model; + +import lombok.NonNull; +import lombok.Value; + +import java.time.Duration; +import java.time.Instant; + +import static io.pillopl.library.lending.patron.model.NumberOfDays.of; +import static java.time.Instant.now; + +@Value +public class CheckoutDuration { + + static final int MAX_CHECKOUT_DURATION = 60; + + @NonNull NumberOfDays noOfDays; + @NonNull Instant from; + + private CheckoutDuration(Instant from, NumberOfDays noOfDays) { + if(noOfDays.isGreaterThan(MAX_CHECKOUT_DURATION)) { + throw new IllegalArgumentException("Cannot checkout for more than " + MAX_CHECKOUT_DURATION + " days!"); + } + this.noOfDays = noOfDays; + this.from = from; + } + + public static CheckoutDuration forNoOfDays(int noOfDays) { + return forNoOfDays(now(), noOfDays); + } + + static CheckoutDuration forNoOfDays(Instant from, int noOfDays) { + return new CheckoutDuration(from, of(noOfDays)); + } + + public static CheckoutDuration maxDuration() { + return forNoOfDays(MAX_CHECKOUT_DURATION); + } + + Instant to() { + return from.plus(Duration.ofDays(noOfDays.getDays())); + } +} diff --git a/src/main/java/io/pillopl/library/lending/patron/model/Hold.java b/src/main/java/io/pillopl/library/lending/patron/model/Hold.java new file mode 100644 index 0000000..8d33f12 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/model/Hold.java @@ -0,0 +1,14 @@ +package io.pillopl.library.lending.patron.model; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import lombok.NonNull; +import lombok.Value; + +@Value +class Hold { + + @NonNull BookId bookId; + @NonNull LibraryBranchId libraryBranchId; + +} diff --git a/src/main/java/io/pillopl/library/lending/patron/model/HoldDuration.java b/src/main/java/io/pillopl/library/lending/patron/model/HoldDuration.java new file mode 100644 index 0000000..192ee3b --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/model/HoldDuration.java @@ -0,0 +1,54 @@ +package io.pillopl.library.lending.patron.model; + +import io.vavr.control.Option; +import lombok.Value; + +import java.time.Duration; +import java.time.Instant; + +@Value +public class HoldDuration { + + Instant from; + Instant to; + + private HoldDuration(Instant from, Instant to) { + if (to != null && to.isBefore(from)) { + throw new IllegalStateException("Close-ended duration must be valid"); + } + this.from = from; + this.to = to; + } + + boolean isOpenEnded() { + return getTo().isEmpty(); + } + + Option getTo() { + return Option.of(to); + } + + public static HoldDuration openEnded() { + return openEnded(Instant.now()); + } + + public static HoldDuration openEnded(Instant from) { + return new HoldDuration(from, null); + } + + public static HoldDuration closeEnded(NumberOfDays days) { + return closeEnded(Instant.now(), days); + } + + public static HoldDuration closeEnded(int days) { + return closeEnded(Instant.now(), NumberOfDays.of(days)); + } + + public static HoldDuration closeEnded(Instant from, NumberOfDays days) { + Instant till = from.plus(Duration.ofDays(days.getDays())); + return new HoldDuration(from, till); + } + +} + + diff --git a/src/main/java/io/pillopl/library/lending/patron/model/NumberOfDays.java b/src/main/java/io/pillopl/library/lending/patron/model/NumberOfDays.java new file mode 100644 index 0000000..392408d --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/model/NumberOfDays.java @@ -0,0 +1,24 @@ +package io.pillopl.library.lending.patron.model; + +import lombok.Value; + +@Value +public class NumberOfDays { + + int days; + + private NumberOfDays(int days) { + if(days <= 0) { + throw new IllegalArgumentException("Cannot use negative integer or zero as number of days"); + } + this.days = days; + } + + public static NumberOfDays of(int days) { + return new NumberOfDays(days); + } + + boolean isGreaterThan(int days) { + return this.days > days; + } +} diff --git a/src/main/java/io/pillopl/library/lending/patron/model/OverdueCheckouts.java b/src/main/java/io/pillopl/library/lending/patron/model/OverdueCheckouts.java new file mode 100644 index 0000000..ff1c49c --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/model/OverdueCheckouts.java @@ -0,0 +1,27 @@ +package io.pillopl.library.lending.patron.model; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import lombok.NonNull; +import lombok.Value; + +import java.util.Map; +import java.util.Set; + +import static java.util.Collections.emptySet; + +@Value +class OverdueCheckouts { + + static int MAX_COUNT_OF_OVERDUE_RESOURCES = 2; + + @NonNull Map> overdueCheckouts; + + int countAt(@NonNull LibraryBranchId libraryBranchId) { + return overdueCheckouts.getOrDefault(libraryBranchId, emptySet()).size(); + } + +} + + + diff --git a/src/main/java/io/pillopl/library/lending/patron/model/Patron.java b/src/main/java/io/pillopl/library/lending/patron/model/Patron.java new file mode 100644 index 0000000..befd23d --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/model/Patron.java @@ -0,0 +1,98 @@ +package io.pillopl.library.lending.patron.model; + + +import io.pillopl.library.lending.book.model.AvailableBook; +import io.pillopl.library.lending.book.model.BookOnHold; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import io.pillopl.library.lending.patron.model.PatronEvent.*; +import io.vavr.collection.List; +import io.vavr.control.Either; +import io.vavr.control.Option; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.NonNull; + +import static io.pillopl.library.commons.events.EitherResult.announceFailure; +import static io.pillopl.library.commons.events.EitherResult.announceSuccess; +import static io.pillopl.library.lending.patron.model.PatronEvent.BookCheckedOut.bookCheckedOutNow; +import static io.pillopl.library.lending.patron.model.PatronEvent.BookCheckingOutFailed.bookCheckingOutFailedNow; +import static io.pillopl.library.lending.patron.model.PatronEvent.BookHoldCanceled.holdCanceledNow; +import static io.pillopl.library.lending.patron.model.PatronEvent.BookHoldCancelingFailed.holdCancelingFailedNow; +import static io.pillopl.library.lending.patron.model.PatronEvent.BookHoldFailed.bookHoldFailedNow; +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHold.bookPlacedOnHoldNow; +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents.events; +import static io.pillopl.library.lending.patron.model.PatronHolds.MAX_NUMBER_OF_HOLDS; +import static io.pillopl.library.lending.patron.model.Rejection.withReason; + +@AllArgsConstructor(access = AccessLevel.PACKAGE) +@EqualsAndHashCode(of = "patron") +public class Patron { + + @NonNull + private final PatronInformation patron; + + @NonNull + private final List placingOnHoldPolicies; + + @NonNull + private final OverdueCheckouts overdueCheckouts; + + @NonNull + private final PatronHolds patronHolds; + + public Either placeOnHold(AvailableBook book) { + return placeOnHold(book, HoldDuration.openEnded()); + } + + public Either placeOnHold(AvailableBook aBook, HoldDuration duration) { + Option rejection = patronCanHold(aBook, duration); + if (rejection.isEmpty()) { + BookPlacedOnHold bookPlacedOnHold = bookPlacedOnHoldNow(aBook.getBookId(), aBook.type(), aBook.getLibraryBranch(), patron.getPatronId(), duration); + if (patronHolds.maximumHoldsAfterHolding(aBook)) { + return announceSuccess(events(bookPlacedOnHold, MaximumNumberOhHoldsReached.now(patron, MAX_NUMBER_OF_HOLDS))); + } + return announceSuccess(events(bookPlacedOnHold)); + } + return announceFailure(bookHoldFailedNow(rejection.get(), aBook.getBookId(), aBook.getLibraryBranch(), patron)); + } + + public Either cancelHold(BookOnHold book) { + if (patronHolds.a(book)) { + return announceSuccess(holdCanceledNow(book.getBookId(), book.getHoldPlacedAt(), patron.getPatronId())); + } + return announceFailure(holdCancelingFailedNow(book.getBookId(), book.getHoldPlacedAt(), patron.getPatronId())); + } + + public Either checkOut(BookOnHold book, CheckoutDuration duration) { + if (patronHolds.a(book)) { + return announceSuccess(bookCheckedOutNow(book.getBookId(), book.type(), book.getHoldPlacedAt(), patron.getPatronId(), duration)); + } + return announceFailure(bookCheckingOutFailedNow(withReason("book is not on hold by patron"), book.getBookId(), book.getHoldPlacedAt(), patron)); + } + + private Option patronCanHold(AvailableBook aBook, HoldDuration forDuration) { + return placingOnHoldPolicies + .toStream() + .map(policy -> policy.apply(aBook, this, forDuration)) + .find(Either::isLeft) + .map(Either::getLeft); + } + + boolean isRegular() { + return patron.isRegular(); + } + + int overdueCheckoutsAt(LibraryBranchId libraryBranch) { + return overdueCheckouts.countAt(libraryBranch); + } + + public int numberOfHolds() { + return patronHolds.count(); + } + + + +} + + diff --git a/src/main/java/io/pillopl/library/lending/patron/model/PatronEvent.java b/src/main/java/io/pillopl/library/lending/patron/model/PatronEvent.java new file mode 100644 index 0000000..c61903b --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/model/PatronEvent.java @@ -0,0 +1,246 @@ +package io.pillopl.library.lending.patron.model; + +import io.pillopl.library.commons.events.DomainEvent; +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.catalogue.BookType; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import io.vavr.collection.List; +import io.vavr.control.Option; +import lombok.NonNull; +import lombok.Value; + +import java.time.Instant; +import java.util.UUID; + +public interface PatronEvent extends DomainEvent { + + default PatronId patronId() { + return new PatronId(getPatronId()); + } + + UUID getPatronId(); + + default UUID getAggregateId() { + return getPatronId(); + } + + default List normalize() { + return List.of(this); + } + + @Value + class PatronCreated implements PatronEvent { + @NonNull UUID eventId = UUID.randomUUID(); + @NonNull Instant when; + @NonNull UUID patronId; + @NonNull PatronType patronType; + + public static PatronCreated now(PatronId patronId, PatronType type) { + return new PatronCreated(Instant.now(), patronId.getPatronId(), type); + } + } + + @Value + class BookPlacedOnHold implements PatronEvent { + @NonNull UUID eventId = UUID.randomUUID(); + @NonNull Instant when; + @NonNull UUID patronId; + @NonNull UUID bookId; + @NonNull BookType bookType; + @NonNull UUID libraryBranchId; + @NonNull Instant holdFrom; + Instant holdTill; + + public static BookPlacedOnHold bookPlacedOnHoldNow(BookId bookId, BookType bookType, LibraryBranchId libraryBranchId, PatronId patronId, HoldDuration holdDuration) { + return new BookPlacedOnHold( + Instant.now(), + patronId.getPatronId(), + bookId.getBookId(), + bookType, + libraryBranchId.getLibraryBranchId(), + holdDuration.getFrom(), + holdDuration.getTo().getOrNull()); + } + } + + @Value + class BookPlacedOnHoldEvents implements PatronEvent { + @NonNull UUID eventId = UUID.randomUUID(); + @NonNull UUID patronId; + @NonNull BookPlacedOnHold bookPlacedOnHold; + @NonNull Option maximumNumberOhHoldsReached; + + @Override + public Instant getWhen() { + return bookPlacedOnHold.when; + } + + public static BookPlacedOnHoldEvents events(BookPlacedOnHold bookPlacedOnHold) { + return new BookPlacedOnHoldEvents(bookPlacedOnHold.getPatronId(), bookPlacedOnHold, Option.none()); + } + + public static BookPlacedOnHoldEvents events(BookPlacedOnHold bookPlacedOnHold, MaximumNumberOhHoldsReached maximumNumberOhHoldsReached) { + return new BookPlacedOnHoldEvents(bookPlacedOnHold.patronId, bookPlacedOnHold, Option.of(maximumNumberOhHoldsReached)); + } + + public List normalize() { + return List.of(bookPlacedOnHold).appendAll(maximumNumberOhHoldsReached.toList()); + } + } + + @Value + class MaximumNumberOhHoldsReached implements PatronEvent { + @NonNull UUID eventId = UUID.randomUUID(); + @NonNull Instant when; + @NonNull UUID patronId; + int numberOfHolds; + + public static MaximumNumberOhHoldsReached now(PatronInformation patronInformation, int numberOfHolds) { + return new MaximumNumberOhHoldsReached( + Instant.now(), + patronInformation.getPatronId().getPatronId(), + numberOfHolds); + } + } + + @Value + class BookCheckedOut implements PatronEvent { + @NonNull UUID eventId = UUID.randomUUID(); + @NonNull Instant when; + @NonNull UUID patronId; + @NonNull UUID bookId; + @NonNull BookType bookType; + @NonNull UUID libraryBranchId; + @NonNull Instant till; + + public static BookCheckedOut bookCheckedOutNow(BookId bookId, BookType bookType, LibraryBranchId libraryBranchId, PatronId patronId, CheckoutDuration checkoutDuration) { + return new BookCheckedOut( + Instant.now(), + patronId.getPatronId(), + bookId.getBookId(), + bookType, + libraryBranchId.getLibraryBranchId(), + checkoutDuration.to()); + } + } + + @Value + class BookReturned implements PatronEvent { + @NonNull UUID eventId = UUID.randomUUID(); + @NonNull Instant when; + @NonNull UUID patronId; + @NonNull UUID bookId; + @NonNull BookType bookType; + @NonNull UUID libraryBranchId; + } + + @Value + class BookHoldFailed implements PatronEvent { + @NonNull UUID eventId = UUID.randomUUID(); + @NonNull String reason; + @NonNull Instant when; + @NonNull UUID patronId; + @NonNull UUID bookId; + @NonNull UUID libraryBranchId; + + static BookHoldFailed bookHoldFailedNow(Rejection rejection, BookId bookId, LibraryBranchId libraryBranchId, PatronInformation patronInformation) { + return new BookHoldFailed( + rejection.getReason().getReason(), + Instant.now(), + patronInformation.getPatronId().getPatronId(), + bookId.getBookId(), + libraryBranchId.getLibraryBranchId()); + } + } + + @Value + class BookCheckingOutFailed implements PatronEvent { + @NonNull UUID eventId = UUID.randomUUID(); + @NonNull String reason; + @NonNull Instant when; + @NonNull UUID patronId; + @NonNull UUID bookId; + @NonNull UUID libraryBranchId; + + static BookCheckingOutFailed bookCheckingOutFailedNow(Rejection rejection, BookId bookId, LibraryBranchId libraryBranchId, PatronInformation patronInformation) { + return new BookCheckingOutFailed( + rejection.getReason().getReason(), + Instant.now(), + patronInformation.getPatronId().getPatronId(), + bookId.getBookId(), + libraryBranchId.getLibraryBranchId()); + } + } + + @Value + class BookHoldCanceled implements PatronEvent { + @NonNull UUID eventId = UUID.randomUUID(); + @NonNull Instant when; + @NonNull UUID patronId; + @NonNull UUID bookId; + @NonNull UUID libraryBranchId; + + public static BookHoldCanceled holdCanceledNow(BookId bookId, LibraryBranchId libraryBranchId, PatronId patronId) { + return new BookHoldCanceled( + Instant.now(), + patronId.getPatronId(), + bookId.getBookId(), + libraryBranchId.getLibraryBranchId()); + } + } + + @Value + class BookHoldCancelingFailed implements PatronEvent { + @NonNull UUID eventId = UUID.randomUUID(); + @NonNull Instant when; + @NonNull UUID patronId; + @NonNull UUID bookId; + @NonNull UUID libraryBranchId; + + static BookHoldCancelingFailed holdCancelingFailedNow(BookId bookId, LibraryBranchId libraryBranchId, PatronId patronId) { + return new BookHoldCancelingFailed( + Instant.now(), + patronId.getPatronId(), + bookId.getBookId(), + libraryBranchId.getLibraryBranchId()); + } + } + + @Value + class BookHoldExpired implements PatronEvent { + @NonNull UUID eventId = UUID.randomUUID(); + @NonNull Instant when; + @NonNull UUID patronId; + @NonNull UUID bookId; + @NonNull UUID libraryBranchId; + + public static BookHoldExpired now(BookId bookId, PatronId patronId, LibraryBranchId libraryBranchId) { + return new BookHoldExpired( + Instant.now(), + patronId.getPatronId(), + bookId.getBookId(), + libraryBranchId.getLibraryBranchId()); + } + } + + @Value + class OverdueCheckoutRegistered implements PatronEvent { + @NonNull UUID eventId = UUID.randomUUID(); + @NonNull Instant when; + @NonNull UUID patronId; + @NonNull UUID bookId; + @NonNull UUID libraryBranchId; + + public static OverdueCheckoutRegistered now(PatronId patronId, BookId bookId, LibraryBranchId libraryBranchId) { + return new OverdueCheckoutRegistered( + Instant.now(), + patronId.getPatronId(), + bookId.getBookId(), + libraryBranchId.getLibraryBranchId()); + } + } + +} + + + diff --git a/src/main/java/io/pillopl/library/lending/patron/model/PatronFactory.java b/src/main/java/io/pillopl/library/lending/patron/model/PatronFactory.java new file mode 100644 index 0000000..f4b30a2 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/model/PatronFactory.java @@ -0,0 +1,26 @@ +package io.pillopl.library.lending.patron.model; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import io.vavr.Tuple2; + +import java.util.Map; +import java.util.Set; + +import static io.pillopl.library.lending.patron.model.PlacingOnHoldPolicy.allCurrentPolicies; +import static java.util.stream.Collectors.toSet; + +public class PatronFactory { + + public Patron create(PatronType patronType, PatronId patronId, Set> patronHolds, Map> overdueCheckouts) { + return new Patron(new PatronInformation(patronId, patronType), + allCurrentPolicies(), + new OverdueCheckouts(overdueCheckouts), + new PatronHolds( + patronHolds + .stream() + .map(tuple -> new Hold(tuple._1, tuple._2)) + .collect(toSet()))); + } + +} diff --git a/src/main/java/io/pillopl/library/lending/patron/model/PatronHolds.java b/src/main/java/io/pillopl/library/lending/patron/model/PatronHolds.java new file mode 100644 index 0000000..5874576 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/model/PatronHolds.java @@ -0,0 +1,29 @@ +package io.pillopl.library.lending.patron.model; + +import io.pillopl.library.lending.book.model.AvailableBook; +import io.pillopl.library.lending.book.model.BookOnHold; +import lombok.NonNull; +import lombok.Value; + +import java.util.Set; + +@Value +class PatronHolds { + + static int MAX_NUMBER_OF_HOLDS = 5; + + Set resourcesOnHold; + + boolean a(@NonNull BookOnHold bookOnHold) { + Hold hold = new Hold(bookOnHold.getBookId(), bookOnHold.getHoldPlacedAt()); + return resourcesOnHold.contains(hold); + } + + int count() { + return resourcesOnHold.size(); + } + + boolean maximumHoldsAfterHolding(AvailableBook book) { + return count() + 1 == MAX_NUMBER_OF_HOLDS; + } +} diff --git a/src/main/java/io/pillopl/library/lending/patron/model/PatronId.java b/src/main/java/io/pillopl/library/lending/patron/model/PatronId.java new file mode 100644 index 0000000..54862b7 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/model/PatronId.java @@ -0,0 +1,11 @@ +package io.pillopl.library.lending.patron.model; + +import lombok.NonNull; +import lombok.Value; + +import java.util.UUID; + +@Value +public class PatronId { + @NonNull UUID patronId; +} diff --git a/src/main/java/io/pillopl/library/lending/patron/model/PatronInformation.java b/src/main/java/io/pillopl/library/lending/patron/model/PatronInformation.java new file mode 100644 index 0000000..c176c8e --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/model/PatronInformation.java @@ -0,0 +1,19 @@ +package io.pillopl.library.lending.patron.model; + +import lombok.NonNull; +import lombok.Value; + +import static io.pillopl.library.lending.patron.model.PatronType.Regular; + +@Value +class PatronInformation { + + @NonNull PatronId patronId; + + @NonNull PatronType type; + + boolean isRegular() { + return type.equals(Regular); + } +} + diff --git a/src/main/java/io/pillopl/library/lending/patron/model/PatronType.java b/src/main/java/io/pillopl/library/lending/patron/model/PatronType.java new file mode 100644 index 0000000..9f45989 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/model/PatronType.java @@ -0,0 +1,3 @@ +package io.pillopl.library.lending.patron.model; + +public enum PatronType {Researcher, Regular} diff --git a/src/main/java/io/pillopl/library/lending/patron/model/Patrons.java b/src/main/java/io/pillopl/library/lending/patron/model/Patrons.java new file mode 100644 index 0000000..1720338 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/model/Patrons.java @@ -0,0 +1,10 @@ +package io.pillopl.library.lending.patron.model; + +import io.vavr.control.Option; + +public interface Patrons { + + Option findBy(PatronId patronId); + + Patron publish(PatronEvent event); +} diff --git a/src/main/java/io/pillopl/library/lending/patron/model/PlacingOnHoldPolicy.java b/src/main/java/io/pillopl/library/lending/patron/model/PlacingOnHoldPolicy.java new file mode 100644 index 0000000..8d24a90 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patron/model/PlacingOnHoldPolicy.java @@ -0,0 +1,72 @@ +package io.pillopl.library.lending.patron.model; + +import io.pillopl.library.lending.book.model.AvailableBook; +import io.vavr.Function3; +import io.vavr.collection.List; +import io.vavr.control.Either; +import lombok.NonNull; +import lombok.Value; + +import static io.vavr.control.Either.left; +import static io.vavr.control.Either.right; + +interface PlacingOnHoldPolicy extends Function3> { + + PlacingOnHoldPolicy onlyResearcherPatronsCanHoldRestrictedBooksPolicy = (AvailableBook toHold, Patron patron, HoldDuration holdDuration) -> { + if (toHold.isRestricted() && patron.isRegular()) { + return left(Rejection.withReason("Regular patrons cannot hold restricted books")); + } + return right(new Allowance()); + }; + + PlacingOnHoldPolicy overdueCheckoutsRejectionPolicy = (AvailableBook toHold, Patron patron, HoldDuration holdDuration) -> { + if (patron.overdueCheckoutsAt(toHold.getLibraryBranch()) >= OverdueCheckouts.MAX_COUNT_OF_OVERDUE_RESOURCES) { + return left(Rejection.withReason("cannot place on hold when there are overdue checkouts")); + } + return right(new Allowance()); + }; + + PlacingOnHoldPolicy regularPatronMaximumNumberOfHoldsPolicy = (AvailableBook toHold, Patron patron, HoldDuration holdDuration) -> { + if (patron.isRegular() && patron.numberOfHolds() >= PatronHolds.MAX_NUMBER_OF_HOLDS) { + return left(Rejection.withReason("patron cannot hold more books")); + } + return right(new Allowance()); + }; + + PlacingOnHoldPolicy onlyResearcherPatronsCanPlaceOpenEndedHolds = (AvailableBook toHold, Patron patron, HoldDuration holdDuration) -> { + if (patron.isRegular() && holdDuration.isOpenEnded()) { + return left(Rejection.withReason("regular patron cannot place open ended holds")); + } + return right(new Allowance()); + }; + + static List allCurrentPolicies() { + return List.of( + onlyResearcherPatronsCanHoldRestrictedBooksPolicy, + overdueCheckoutsRejectionPolicy, + regularPatronMaximumNumberOfHoldsPolicy, + onlyResearcherPatronsCanPlaceOpenEndedHolds); + } + +} + +@Value +class Allowance { } + +@Value +class Rejection { + + @Value + static class Reason { + @NonNull + String reason; + } + + @NonNull + Reason reason; + + static Rejection withReason(String reason) { + return new Rejection(new Reason(reason)); + } +} + diff --git a/src/main/java/io/pillopl/library/lending/patronprofile/infrastructure/PatronProfileConfiguration.java b/src/main/java/io/pillopl/library/lending/patronprofile/infrastructure/PatronProfileConfiguration.java new file mode 100644 index 0000000..ef51454 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patronprofile/infrastructure/PatronProfileConfiguration.java @@ -0,0 +1,15 @@ +package io.pillopl.library.lending.patronprofile.infrastructure; + +import io.pillopl.library.lending.patronprofile.model.PatronProfiles; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +@Configuration +public class PatronProfileConfiguration { + + @Bean + public PatronProfiles patronProfilesReadModel(JdbcTemplate jdbcTemplate) { + return new PatronProfileReadModel(jdbcTemplate); + } +} diff --git a/src/main/java/io/pillopl/library/lending/patronprofile/infrastructure/PatronProfileReadModel.java b/src/main/java/io/pillopl/library/lending/patronprofile/infrastructure/PatronProfileReadModel.java new file mode 100644 index 0000000..8d02628 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patronprofile/infrastructure/PatronProfileReadModel.java @@ -0,0 +1,68 @@ +package io.pillopl.library.lending.patronprofile.infrastructure; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.lending.patron.model.PatronId; +import io.pillopl.library.lending.patronprofile.model.Checkout; +import io.pillopl.library.lending.patronprofile.model.CheckoutsView; +import io.pillopl.library.lending.patronprofile.model.Hold; +import io.pillopl.library.lending.patronprofile.model.HoldsView; +import io.pillopl.library.lending.patronprofile.model.PatronProfile; +import io.pillopl.library.lending.patronprofile.model.PatronProfiles; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import org.springframework.jdbc.core.ColumnMapRowMapper; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.sql.Timestamp; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static io.vavr.collection.List.ofAll; +import static java.util.stream.Collectors.toList; + +@AllArgsConstructor(access = AccessLevel.PACKAGE) +class PatronProfileReadModel implements PatronProfiles { + + private final JdbcTemplate sheets; + + @Override + public PatronProfile fetchFor(PatronId patronId) { + HoldsView holdsView = new HoldsView( + ofAll(findCurrentHoldsFor(patronId) + .stream() + .map(this::toHold) + .collect(toList()))); + CheckoutsView checkoutsView = new CheckoutsView( + ofAll(findCurrentCheckoutsFor(patronId) + .stream() + .map(this::toCheckout) + .collect(toList()))); + return new PatronProfile(holdsView, checkoutsView); + } + + private List> findCurrentHoldsFor(PatronId patronId) { + return sheets.query( + "SELECT h.book_id, h.hold_till FROM holds_sheet h WHERE h.hold_by_patron_id = ? AND h.checked_out_at IS NULL AND h.expired_at IS NULL AND h.canceled_at IS NULL", + new Object[]{patronId.getPatronId()}, + new ColumnMapRowMapper()); + } + + private Hold toHold(Map map) { + return new Hold(new BookId((UUID) map.get("BOOK_ID")), + ((Timestamp) map.get("HOLD_TILL")).toInstant()); + } + + private List> findCurrentCheckoutsFor(PatronId patronId) { + return sheets.query( + "SELECT h.book_id, h.checkout_till FROM checkouts_sheet h WHERE h.checked_out_by_patron_id = ? AND h.returned_at IS NULL", + new Object[]{patronId.getPatronId()}, + new ColumnMapRowMapper()); + } + + private Checkout toCheckout(Map map) { + return new Checkout(new BookId((UUID) map.get("BOOK_ID")), + ((Timestamp) map.get("CHECKOUT_TILL")).toInstant()); + } +} + diff --git a/src/main/java/io/pillopl/library/lending/patronprofile/model/Checkout.java b/src/main/java/io/pillopl/library/lending/patronprofile/model/Checkout.java new file mode 100644 index 0000000..953d381 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patronprofile/model/Checkout.java @@ -0,0 +1,15 @@ +package io.pillopl.library.lending.patronprofile.model; + +import io.pillopl.library.catalogue.BookId; +import lombok.Value; + +import java.time.Instant; + +@Value +public class Checkout { + + private final BookId book; + + private final Instant till; + +} diff --git a/src/main/java/io/pillopl/library/lending/patronprofile/model/CheckoutsView.java b/src/main/java/io/pillopl/library/lending/patronprofile/model/CheckoutsView.java new file mode 100644 index 0000000..6473a94 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patronprofile/model/CheckoutsView.java @@ -0,0 +1,13 @@ +package io.pillopl.library.lending.patronprofile.model; + +import io.vavr.collection.List; +import lombok.NonNull; +import lombok.Value; + +@Value +public class CheckoutsView { + + @NonNull + List currentCheckouts; + +} diff --git a/src/main/java/io/pillopl/library/lending/patronprofile/model/Hold.java b/src/main/java/io/pillopl/library/lending/patronprofile/model/Hold.java new file mode 100644 index 0000000..7f332d5 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patronprofile/model/Hold.java @@ -0,0 +1,15 @@ +package io.pillopl.library.lending.patronprofile.model; + +import io.pillopl.library.catalogue.BookId; +import lombok.Value; + +import java.time.Instant; + +@Value +public class Hold { + + private final BookId book; + + private final Instant till; + +} diff --git a/src/main/java/io/pillopl/library/lending/patronprofile/model/HoldsView.java b/src/main/java/io/pillopl/library/lending/patronprofile/model/HoldsView.java new file mode 100644 index 0000000..c1d8e8a --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patronprofile/model/HoldsView.java @@ -0,0 +1,13 @@ +package io.pillopl.library.lending.patronprofile.model; + +import io.vavr.collection.List; +import lombok.NonNull; +import lombok.Value; + +@Value +public class HoldsView { + + @NonNull + List currentHolds; + +} diff --git a/src/main/java/io/pillopl/library/lending/patronprofile/model/PatronProfile.java b/src/main/java/io/pillopl/library/lending/patronprofile/model/PatronProfile.java new file mode 100644 index 0000000..bcce830 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patronprofile/model/PatronProfile.java @@ -0,0 +1,31 @@ +package io.pillopl.library.lending.patronprofile.model; + +import io.pillopl.library.catalogue.BookId; +import io.vavr.control.Option; +import lombok.NonNull; +import lombok.Value; + +@Value +public class PatronProfile { + + @NonNull HoldsView holdsView; + @NonNull CheckoutsView currentCheckouts; + + public Option findHold(BookId bookId) { + return + holdsView + .getCurrentHolds() + .toStream() + .find(hold -> hold.getBook().equals(bookId)); + } + + public Option findCheckout(BookId bookId) { + return + currentCheckouts + .getCurrentCheckouts() + .toStream() + .find(hold -> hold.getBook().equals(bookId)); + } + + +} diff --git a/src/main/java/io/pillopl/library/lending/patronprofile/model/PatronProfiles.java b/src/main/java/io/pillopl/library/lending/patronprofile/model/PatronProfiles.java new file mode 100644 index 0000000..df6c778 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patronprofile/model/PatronProfiles.java @@ -0,0 +1,12 @@ +package io.pillopl.library.lending.patronprofile.model; + +import io.pillopl.library.lending.patron.model.PatronId; + +@FunctionalInterface +public interface PatronProfiles { + + PatronProfile fetchFor(PatronId patronId); + +} + + diff --git a/src/main/java/io/pillopl/library/lending/patronprofile/web/PatronProfileController.java b/src/main/java/io/pillopl/library/lending/patronprofile/web/PatronProfileController.java new file mode 100644 index 0000000..58e9da9 --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patronprofile/web/PatronProfileController.java @@ -0,0 +1,188 @@ +package io.pillopl.library.lending.patronprofile.web; + + +import com.fasterxml.jackson.annotation.JsonCreator; +import io.micrometer.core.annotation.Timed; +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.commons.commands.Result; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import io.pillopl.library.lending.patron.application.hold.CancelHoldCommand; +import io.pillopl.library.lending.patron.application.hold.CancelingHold; +import io.pillopl.library.lending.patron.application.hold.PlaceOnHoldCommand; +import io.pillopl.library.lending.patron.application.hold.PlacingOnHold; +import io.pillopl.library.lending.patron.model.PatronId; +import io.pillopl.library.lending.patronprofile.model.PatronProfiles; +import io.vavr.Predicates; +import io.vavr.control.Option; +import io.vavr.control.Try; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Value; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.RepresentationModel; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + + +import static io.vavr.API.$; +import static io.vavr.API.Case; +import static io.vavr.API.Match; +import static java.util.stream.Collectors.toList; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.afford; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; +import static org.springframework.http.ResponseEntity.notFound; +import static org.springframework.http.ResponseEntity.ok; + +@Timed(percentiles = {0.5, 0.75, 0.95, 0.99}) +@RestController +@AllArgsConstructor +class PatronProfileController { + + private final PatronProfiles patronProfiles; + private final PlacingOnHold placingOnHold; + private final CancelingHold cancelingHold; + + @GetMapping("/profiles/{patronId}") + ResponseEntity patronProfile(@PathVariable UUID patronId) { + return ok(new ProfileResource(patronId)); + } + + @GetMapping("/profiles/{patronId}/holds/") + ResponseEntity>> findHolds(@PathVariable UUID patronId) { + List> holds = patronProfiles.fetchFor(new PatronId(patronId)) + .getHoldsView() + .getCurrentHolds() + .toStream() + .map(hold -> resourceWithLinkToHoldSelf(patronId, hold)) + .collect(toList()); + return ResponseEntity.ok(new CollectionModel<>(holds, linkTo(methodOn(PatronProfileController.class).findHolds(patronId)).withSelfRel())); + + } + + @GetMapping("/profiles/{patronId}/holds/{bookId}") + ResponseEntity> findHold(@PathVariable UUID patronId, @PathVariable UUID bookId) { + return patronProfiles.fetchFor(new PatronId(patronId)) + .findHold(new BookId(bookId)) + .map(hold -> ok(resourceWithLinkToHoldSelf(patronId, hold))) + .getOrElse(notFound().build()); + + } + + @GetMapping("/profiles/{patronId}/checkouts/") + ResponseEntity>> findCheckouts(@PathVariable UUID patronId) { + List> checkouts = patronProfiles.fetchFor(new PatronId(patronId)) + .getCurrentCheckouts() + .getCurrentCheckouts() + .toStream() + .map(checkout -> resourceWithLinkToCheckoutSelf(patronId, checkout)) + .collect(toList()); + return ResponseEntity.ok(new CollectionModel<>(checkouts, linkTo(methodOn(PatronProfileController.class).findHolds(patronId)).withSelfRel())); + } + + @GetMapping("/profiles/{patronId}/checkouts/{bookId}") + ResponseEntity> findCheckout(@PathVariable UUID patronId, @PathVariable UUID bookId) { + return patronProfiles.fetchFor(new PatronId(patronId)) + .findCheckout(new BookId(bookId)) + .map(hold -> ok(resourceWithLinkToCheckoutSelf(patronId, hold))) + .getOrElse(notFound().build()); + } + + @PostMapping("/profiles/{patronId}/holds") + ResponseEntity placeHold(@PathVariable UUID patronId, @RequestBody PlaceHoldRequest request) { + Try result = placingOnHold.placeOnHold( + new PlaceOnHoldCommand( + Instant.now(), + new PatronId(patronId), + new LibraryBranchId(request.getLibraryBranchId()), + new BookId(request.getBookId()), + Option.of(request.getNumberOfDays()) + ) + ); + return result + .map(success -> ResponseEntity.ok().build()) + .getOrElse(ResponseEntity.status(INTERNAL_SERVER_ERROR).build()); + } + + @DeleteMapping("/profiles/{patronId}/holds/{bookId}") + ResponseEntity cancelHold(@PathVariable UUID patronId, @PathVariable UUID bookId) { + Try result = cancelingHold.cancelHold(new CancelHoldCommand(Instant.now(), new PatronId(patronId), new BookId(bookId))); + return result + .map(success -> ResponseEntity.noContent().build()) + .recover(r -> Match(r).of(Case($(Predicates.instanceOf(IllegalArgumentException.class)), ResponseEntity.notFound().build()))) + .getOrElse(ResponseEntity.status(INTERNAL_SERVER_ERROR).build()); + } + + private EntityModel resourceWithLinkToHoldSelf(UUID patronId, io.pillopl.library.lending.patronprofile.model.Hold hold) { + return new EntityModel<>( + new Hold(hold), + linkTo(methodOn(PatronProfileController.class).findHold(patronId, hold.getBook().getBookId())) + .withSelfRel() + .andAffordance(afford(methodOn(PatronProfileController.class) + .cancelHold(patronId, hold.getBook().getBookId())))); + } + + private EntityModel resourceWithLinkToCheckoutSelf(UUID patronId, io.pillopl.library.lending.patronprofile.model.Checkout checkout) { + return new EntityModel<>( + new Checkout(checkout), + linkTo(methodOn(PatronProfileController.class).findCheckout(patronId, checkout.getBook().getBookId())) + .withSelfRel()); + } +} + +@Value +class ProfileResource extends RepresentationModel { + + UUID patronId; + + ProfileResource(UUID patronId) { + this.patronId = patronId; + add(linkTo(methodOn(PatronProfileController.class).findHolds(patronId)).withRel("holds")); + add(linkTo(methodOn(PatronProfileController.class).findCheckouts(patronId)).withRel("checkouts")); + add(linkTo(methodOn(PatronProfileController.class).patronProfile(patronId)).withSelfRel()); + + } + +} + +@Value +class Hold { + + UUID bookId; + Instant till; + + Hold(io.pillopl.library.lending.patronprofile.model.Hold hold) { + this.bookId = hold.getBook().getBookId(); + this.till = hold.getTill(); + } +} + +@Value +class Checkout { + + UUID bookId; + Instant till; + + Checkout(io.pillopl.library.lending.patronprofile.model.Checkout hold) { + this.bookId = hold.getBook().getBookId(); + this.till = hold.getTill(); + } + +} + +@Value +@AllArgsConstructor(onConstructor = @__(@JsonCreator)) +class PlaceHoldRequest { + UUID bookId; + UUID libraryBranchId; + Integer numberOfDays; +} diff --git a/src/main/java/io/pillopl/library/lending/patronprofile/web/WebConfiguration.java b/src/main/java/io/pillopl/library/lending/patronprofile/web/WebConfiguration.java new file mode 100644 index 0000000..0e3b3ed --- /dev/null +++ b/src/main/java/io/pillopl/library/lending/patronprofile/web/WebConfiguration.java @@ -0,0 +1,66 @@ +package io.pillopl.library.lending.patronprofile.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.hateoas.config.EnableHypermediaSupport; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; + +import static org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType.HAL_FORMS; + +@Configuration +@EnableAutoConfiguration +@EnableHypermediaSupport(type = HAL_FORMS) +@ComponentScan +public class WebConfiguration { + + @Bean + static HalObjectMapperConfigurer halObjectMapperConfigurer() { + return new HalObjectMapperConfigurer(); + } + + private static class HalObjectMapperConfigurer + implements BeanPostProcessor, BeanFactoryAware { + + private BeanFactory beanFactory; + + /** + * Assume any {@link ObjectMapper} starts with {@literal _hal} and ends with {@literal Mapper}. + */ + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) + throws BeansException { + if (bean instanceof ObjectMapper && beanName.startsWith("_hal") && beanName.endsWith("Mapper")) { + postProcessHalObjectMapper((ObjectMapper) bean); + } + return bean; + } + + private void postProcessHalObjectMapper(ObjectMapper objectMapper) { + try { + Jackson2ObjectMapperBuilder builder = this.beanFactory.getBean(Jackson2ObjectMapperBuilder.class); + builder.configure(objectMapper); + } catch (NoSuchBeanDefinitionException ex) { + // No Jackson configuration required + } + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) + throws BeansException { + return bean; + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = beanFactory; + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..264baaf --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,5 @@ +management: + endpoints: + web: + exposure: + include: info, health, metrics, prometheus diff --git a/src/main/resources/create_catalogue_book.sql b/src/main/resources/create_catalogue_book.sql new file mode 100644 index 0000000..f1235c0 --- /dev/null +++ b/src/main/resources/create_catalogue_book.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS catalogue_book ( + id INTEGER IDENTITY PRIMARY KEY, + isbn VARCHAR(100) NOT NULL, + title VARCHAR(100) NOT NULL, + author VARCHAR(100) NOT NULL); + + +CREATE TABLE IF NOT EXISTS catalogue_book_instance ( + id INTEGER IDENTITY PRIMARY KEY, + isbn VARCHAR(100) NOT NULL, + book_id UUID NOT NULL); + +CREATE SEQUENCE catalogue_book_seq; +CREATE SEQUENCE catalogue_book_instance_seq; + + diff --git a/src/main/resources/create_lending_book_db.sql b/src/main/resources/create_lending_book_db.sql new file mode 100644 index 0000000..dff0b2c --- /dev/null +++ b/src/main/resources/create_lending_book_db.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS book_database_entity ( + id INTEGER IDENTITY PRIMARY KEY, + book_id UUID UNIQUE, + book_type VARCHAR(100) NOT NULL, + book_state VARCHAR(100) NOT NULL, + available_at_branch UUID, + on_hold_at_branch UUID, + on_hold_by_patron UUID, + checked_out_at_branch UUID, + checked_out_by_patron UUID, + on_hold_till TIMESTAMP, + version INTEGER); + +CREATE SEQUENCE book_database_entity_seq; diff --git a/src/main/resources/create_patron_db.sql b/src/main/resources/create_patron_db.sql new file mode 100644 index 0000000..239a2cf --- /dev/null +++ b/src/main/resources/create_patron_db.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS patron_database_entity (id INTEGER IDENTITY PRIMARY KEY, patron_type VARCHAR(100) NOT NULL, patron_id UUID UNIQUE); + +CREATE TABLE IF NOT EXISTS hold_database_entity (id INTEGER IDENTITY PRIMARY KEY, book_id UUID NOT NULL, patron_id UUID NOT NULL, library_branch_id UUID NOT NULL, patron_database_entity INTEGER NOT NULL, till TIMESTAMP NOT NULL); + +CREATE TABLE IF NOT EXISTS overdue_checkout_database_entity (id INTEGER IDENTITY PRIMARY KEY, book_id UUID NOT NULL, patron_id UUID NOT NULL, library_branch_id UUID NOT NULL, patron_database_entity INTEGER NOT NULL); diff --git a/src/main/resources/create_sheets_db.sql b/src/main/resources/create_sheets_db.sql new file mode 100644 index 0000000..bfee527 --- /dev/null +++ b/src/main/resources/create_sheets_db.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS checkouts_sheet ( +id INTEGER IDENTITY PRIMARY KEY, + book_id UUID NOT NULL, + status VARCHAR(20) NOT NULL, + checkout_event_id UUID UNIQUE, + checked_out_by_patron_id UUID, + checked_out_at TIMESTAMP, + returned_at TIMESTAMP, + checked_out_at_branch UUID, + checkout_till TIMESTAMP); + + +CREATE TABLE IF NOT EXISTS holds_sheet ( +id INTEGER IDENTITY PRIMARY KEY, + book_id UUID NOT NULL, + status VARCHAR(20) NOT NULL, + hold_event_id UUID UNIQUE, + hold_at_branch UUID, + hold_by_patron_id UUID, + hold_at TIMESTAMP, + hold_till TIMESTAMP, + expired_at TIMESTAMP, + canceled_at TIMESTAMP, + checked_out_at TIMESTAMP); + +CREATE SEQUENCE holds_sheet_seq; +CREATE SEQUENCE checkouts_sheet_seq; + diff --git a/src/test/groovy/io/pillopl/library/ModularArchitectureTest.java b/src/test/groovy/io/pillopl/library/ModularArchitectureTest.java new file mode 100644 index 0000000..49b5b56 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/ModularArchitectureTest.java @@ -0,0 +1,43 @@ +package io.pillopl.library; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.junit.ArchUnitRunner; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.runner.RunWith; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +@RunWith(ArchUnitRunner.class) +@AnalyzeClasses(packages = "io.pillopl") +public class ModularArchitectureTest { + + @ArchTest + public static final ArchRule catalogue_should_not_depend_on_lending = + noClasses() + .that() + .resideInAPackage("..catalogue..") + .should() + .dependOnClassesThat() + .resideInAPackage("..lending.."); + + @ArchTest + public static final ArchRule commons_should_not_depend_on_catalogue = + noClasses() + .that() + .resideInAPackage("..commons..") + .should() + .dependOnClassesThat() + .resideInAPackage("..catalogue.."); + + @ArchTest + public static final ArchRule commons_should_not_depend_on_lending = + noClasses() + .that() + .resideInAPackage("..commons..") + .should() + .dependOnClassesThat() + .resideInAPackage("..lending.."); + + +} \ No newline at end of file diff --git a/src/test/groovy/io/pillopl/library/catalogue/AddBookToCatalogueTest.groovy b/src/test/groovy/io/pillopl/library/catalogue/AddBookToCatalogueTest.groovy new file mode 100644 index 0000000..7b8b9aa --- /dev/null +++ b/src/test/groovy/io/pillopl/library/catalogue/AddBookToCatalogueTest.groovy @@ -0,0 +1,100 @@ +package io.pillopl.library.catalogue + +import io.pillopl.library.commons.commands.Result +import io.pillopl.library.commons.events.DomainEvents +import io.vavr.control.Option +import io.vavr.control.Try +import spock.lang.Specification + +import static io.pillopl.library.catalogue.BookFixture.DDD_ISBN_STR +import static io.pillopl.library.catalogue.BookType.Restricted + +class AddBookToCatalogueTest extends Specification { + + CatalogueDatabase catalogueDatabase = Stub() + DomainEvents domainEvents = Mock() + Catalogue catalogue = new Catalogue(catalogueDatabase, domainEvents) + + def 'should add a new book to catalogue'() { + given: + databaseWorks() + when: + Try result = catalogue.addBook("Eric Evans", "DDD", DDD_ISBN_STR) + then: + result.isSuccess() + result.get() == Result.Success + + } + + def 'should add a new book instance to catalogue'() { + given: + databaseWorks() + and: + thereIsBookWith(DDD_ISBN_STR) + when: + Try result = catalogue.addBookInstance(DDD_ISBN_STR, Restricted) + then: + result.isSuccess() + result.get() == Result.Success + and: + 1 * domainEvents.publish(_ as BookInstanceAddedToCatalogue) + + } + + def 'should reject adding a new book instance to catalogue when book isbn does not exist'() { + given: + databaseWorks() + and: + thereIsNoBookWith(DDD_ISBN_STR) + when: + Try result = catalogue.addBookInstance(DDD_ISBN_STR, Restricted) + then: + result.isSuccess() + result.get() == Result.Rejection + and: + 0 * domainEvents.publish(_ as BookInstanceAddedToCatalogue) + + } + + def 'should fail when adding a book if database fails'() { + given: + databaseDoesNotWork() + when: + Try result = catalogue.addBook("Eric Evans", "DDD", DDD_ISBN_STR) + then: + result.isFailure() + } + + def 'should fail when adding a book instance if database fails'() { + given: + databaseDoesNotWork() + and: + thereIsBookWith(DDD_ISBN_STR) + when: + Try result = catalogue.addBookInstance(DDD_ISBN_STR, Restricted) + then: + result.isFailure() + and: + 0 * domainEvents.publish(_ as BookInstanceAddedToCatalogue) + } + + void databaseWorks() { + catalogueDatabase.saveNew(_ as Book) >> null + catalogueDatabase.saveNew(_ as BookInstance) >> null + + } + + void databaseDoesNotWork() { + catalogueDatabase.saveNew(_ as Book) >> { (new IllegalStateException()) } + catalogueDatabase.saveNew(_ as BookInstance) >> { (new IllegalStateException()) } + + } + + void thereIsBookWith(String isbn) { + catalogueDatabase.findBy(new ISBN(isbn)) >> Option.of(BookFixture.DDD) + } + + void thereIsNoBookWith(String isbn) { + catalogueDatabase.findBy(new ISBN(isbn)) >> Option.none() + } +} diff --git a/src/test/groovy/io/pillopl/library/catalogue/BookFixture.java b/src/test/groovy/io/pillopl/library/catalogue/BookFixture.java new file mode 100644 index 0000000..2d6533f --- /dev/null +++ b/src/test/groovy/io/pillopl/library/catalogue/BookFixture.java @@ -0,0 +1,12 @@ +package io.pillopl.library.catalogue; + +class BookFixture { + + static final String DDD_ISBN_STR = "0321125215"; + + static final ISBN DDD_ISBN_10 = new ISBN(DDD_ISBN_STR); + + static final ISBN NON_PRESENT_ISBN = new ISBN("032112521X"); + + static final Book DDD = new Book(new ISBN(DDD_ISBN_STR), new Title("DDD"), new Author("Eric Evans")); +} diff --git a/src/test/groovy/io/pillopl/library/catalogue/BookTitleAuthorISBNTest.groovy b/src/test/groovy/io/pillopl/library/catalogue/BookTitleAuthorISBNTest.groovy new file mode 100644 index 0000000..829866e --- /dev/null +++ b/src/test/groovy/io/pillopl/library/catalogue/BookTitleAuthorISBNTest.groovy @@ -0,0 +1,72 @@ +package io.pillopl.library.catalogue + +import spock.lang.Specification + +class BookTitleAuthorISBNTest extends Specification { + + def "title should be trimmed"() { + given: + Title title = new Title(" to trim ") + expect: + title.title == "to trim" + + } + + def "author should be trimmed"() { + given: + Author author = new Author(" to trim ") + expect: + author.name == "to trim" + } + + def "title should not be empty"() { + when: + new Title("") + then: + thrown(IllegalArgumentException) + } + + def "author should not be empty"() { + when: + new Author("") + then: + thrown(IllegalArgumentException) + } + + def "title should not be null"() { + when: + new Title(null) + then: + thrown(NullPointerException) + } + + def "author should not be null"() { + when: + new Author(null) + then: + thrown(NullPointerException) + } + + def "isbn should be correct"() { + when: + ISBN isbn = new ISBN("123412341X") + then: + isbn.isbn == "123412341X" + } + + def "isbn should be trimmed"() { + when: + ISBN isbn = new ISBN(" 1234123414 ") + then: + isbn.isbn == "1234123414" + } + + def "wrong isbn should not be accepted"() { + when: + new ISBN("not isbn") + then: + thrown(IllegalArgumentException) + } +} + + diff --git a/src/test/groovy/io/pillopl/library/lending/architecture/LendingHexagonalArchitectureTest.java b/src/test/groovy/io/pillopl/library/lending/architecture/LendingHexagonalArchitectureTest.java new file mode 100644 index 0000000..57d68eb --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/architecture/LendingHexagonalArchitectureTest.java @@ -0,0 +1,71 @@ +package io.pillopl.library.lending.architecture; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.junit.ArchUnitRunner; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.runner.RunWith; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +@RunWith(ArchUnitRunner.class) +@AnalyzeClasses(packages = "io.pillopl.library.lending") +public class LendingHexagonalArchitectureTest { + + @ArchTest + public static final ArchRule model_should_not_depend_on_application = + noClasses() + .that() + .resideInAPackage("..model..") + .should() + .dependOnClassesThat() + .resideInAPackage("..application.."); + + @ArchTest + public static final ArchRule model_should_not_depend_on_infrastructure = + noClasses() + .that() + .resideInAPackage("..model..") + .should() + .dependOnClassesThat() + .resideInAPackage("..infrastructure.."); + + @ArchTest + public static final ArchRule model_should_not_depend_on_ui = + noClasses() + .that() + .resideInAPackage("..model..") + .should() + .dependOnClassesThat() + .resideInAPackage("..ui.."); + + @ArchTest + public static final ArchRule application_should_not_depend_on_infrastructure = + noClasses() + .that() + .resideInAPackage("..application..") + .should() + .dependOnClassesThat() + .resideInAPackage("..infrastructure.."); + + @ArchTest + public static final ArchRule application_should_not_depend_on_ui = + noClasses() + .that() + .resideInAPackage("..application..") + .should() + .dependOnClassesThat() + .resideInAPackage("..ui.."); + + @ArchTest + public static final ArchRule ui_should_not_depend_on_infrastructure = + noClasses() + .that() + .resideInAPackage("..ui..") + .should() + .dependOnClassesThat() + .resideInAPackage("..infrastructure.."); + + + +} \ No newline at end of file diff --git a/src/test/groovy/io/pillopl/library/lending/architecture/NoSpringInDomainLogicTest.java b/src/test/groovy/io/pillopl/library/lending/architecture/NoSpringInDomainLogicTest.java new file mode 100644 index 0000000..2534005 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/architecture/NoSpringInDomainLogicTest.java @@ -0,0 +1,36 @@ +package io.pillopl.library.lending.architecture; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.junit.ArchUnitRunner; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.runner.RunWith; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +@RunWith(ArchUnitRunner.class) +@AnalyzeClasses(packages = {"io.pillopl.library", "org.springframework"}) +public class NoSpringInDomainLogicTest { + + @ArchTest + public static final ArchRule model_should_not_depend_on_spring = + noClasses() + .that() + .resideInAPackage( + "..io.pillopl.library.lending..model..") + .should() + .dependOnClassesThat() + .resideInAPackage("org.springframework.."); + + @ArchTest + public static final ArchRule application_should_not_depend_on_spring = + noClasses() + .that() + .resideInAPackage( + "..io.pillopl.library.lending..application..") + .should() + .dependOnClassesThat() + .resideInAPackage("org.springframework.."); + + +} \ No newline at end of file diff --git a/src/test/groovy/io/pillopl/library/lending/book/application/CreatingAvailableBookForLendingTest.groovy b/src/test/groovy/io/pillopl/library/lending/book/application/CreatingAvailableBookForLendingTest.groovy new file mode 100644 index 0000000..0a5aa2d --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/book/application/CreatingAvailableBookForLendingTest.groovy @@ -0,0 +1,34 @@ +package io.pillopl.library.lending.book.application + +import io.pillopl.library.catalogue.BookId +import io.pillopl.library.catalogue.BookInstanceAddedToCatalogue +import io.pillopl.library.catalogue.BookType +import io.pillopl.library.lending.book.model.AvailableBook +import io.pillopl.library.lending.book.model.Book +import io.pillopl.library.lending.book.model.BookFixture +import io.pillopl.library.lending.book.model.BookRepository +import io.vavr.control.Option +import spock.lang.Specification + +class CreatingAvailableBookForLendingTest extends Specification { + + static final BookId bookId = BookFixture.anyBookId() + + BookRepository bookRepository = new InMemoryBookRepo() + + CreateAvailableBookOnInstanceAddedEventHandler handler = new CreateAvailableBookOnInstanceAddedEventHandler(bookRepository) + + def 'should create new available book for lending when book instance was added to catalogue'() { + when: + handler.handle(new BookInstanceAddedToCatalogue("isbn", BookType.Restricted, bookId.getBookId())) + Option book = bookRepository.findBy(bookId) + then: + book.isDefined() + book.get() instanceof AvailableBook + book.get().bookId() == bookId + book.get().bookInformation.bookType == BookType.Restricted + + } +} + + diff --git a/src/test/groovy/io/pillopl/library/lending/book/application/DuplicateHoldFoundTest.groovy b/src/test/groovy/io/pillopl/library/lending/book/application/DuplicateHoldFoundTest.groovy new file mode 100644 index 0000000..35b2d50 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/book/application/DuplicateHoldFoundTest.groovy @@ -0,0 +1,60 @@ +package io.pillopl.library.lending.book.application + +import io.pillopl.library.commons.events.DomainEvent +import io.pillopl.library.commons.events.DomainEvents +import io.pillopl.library.lending.book.model.BookDuplicateHoldFound +import io.pillopl.library.lending.book.model.BookFixture +import io.pillopl.library.lending.book.model.BookOnHold +import io.pillopl.library.lending.book.model.BookRepository +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.PatronEvent +import io.pillopl.library.lending.patron.model.PatronId +import io.vavr.control.Option +import spock.lang.Specification + +import java.time.Instant + +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId + +class DuplicateHoldFoundTest extends Specification { + + BookOnHold bookOnHold = BookFixture.bookOnHold() + BookRepository bookRepository = Stub() + DomainEvents domainEvents = Mock() + PatronEventsHandler patronEventsHandler = new PatronEventsHandler(bookRepository, domainEvents) + + PatronId patronId = anyPatronId() + LibraryBranchId libraryBranchId = anyBranch() + + + def 'should raise duplicate hold found event when someone placed on hold book already on hold'() { + given: + bookIsAlreadyOnHold() + when: + patronEventsHandler.handle(placedOnHoldBy(patronId)) + then: + 1 * domainEvents.publish({ + it.firstPatronId == bookOnHold.byPatron.patronId && + it.secondPatronId == patronId.patronId + } as BookDuplicateHoldFound) + } + + + def 'should not raise anything if book is on hold by the same patron'() { + given: + bookIsAlreadyOnHold() + when: + patronEventsHandler.handle(placedOnHoldBy(bookOnHold.byPatron)) + then: + 0 * domainEvents.publish(_ as DomainEvent) + } + + PatronEvent.BookPlacedOnHold placedOnHoldBy(PatronId patronId) { + return new PatronEvent.BookPlacedOnHold(Instant.now(), patronId.patronId, bookOnHold.bookId.bookId, bookOnHold.bookInformation.bookType, libraryBranchId.libraryBranchId, Instant.now(), Instant.now()) + } + + void bookIsAlreadyOnHold() { + bookRepository.findBy(bookOnHold.bookId) >> Option.of(bookOnHold) + } +} diff --git a/src/test/groovy/io/pillopl/library/lending/book/application/InMemoryBookRepo.java b/src/test/groovy/io/pillopl/library/lending/book/application/InMemoryBookRepo.java new file mode 100644 index 0000000..f71bc3a --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/book/application/InMemoryBookRepo.java @@ -0,0 +1,24 @@ +package io.pillopl.library.lending.book.application; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.lending.book.model.Book; +import io.pillopl.library.lending.book.model.BookRepository; +import io.vavr.control.Option; + +import java.util.HashMap; +import java.util.Map; + +class InMemoryBookRepo implements BookRepository { + + Map books = new HashMap<>(); + + @Override + public Option findBy(BookId bookId) { + return Option.of(books.get(bookId)); + } + + @Override + public void save(Book book) { + books.put(book.bookId(), book); + } +} diff --git a/src/test/groovy/io/pillopl/library/lending/book/infrastructure/BookEntityToDomainModelMappingTest.groovy b/src/test/groovy/io/pillopl/library/lending/book/infrastructure/BookEntityToDomainModelMappingTest.groovy new file mode 100644 index 0000000..64649de --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/book/infrastructure/BookEntityToDomainModelMappingTest.groovy @@ -0,0 +1,88 @@ +package io.pillopl.library.lending.book.infrastructure + + +import io.pillopl.library.lending.book.model.AvailableBook +import io.pillopl.library.lending.book.model.Book +import io.pillopl.library.catalogue.BookId +import io.pillopl.library.lending.book.model.BookOnHold +import io.pillopl.library.lending.book.model.CheckedOutBook +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.PatronId +import io.pillopl.library.lending.book.infrastructure.BookDatabaseEntity.BookState +import spock.lang.Specification + +import java.time.Instant + +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.catalogue.BookType.Circulating +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId +import static io.pillopl.library.lending.book.infrastructure.BookDatabaseEntity.BookState.* + +class BookEntityToDomainModelMappingTest extends Specification { + + LibraryBranchId libraryBranchId = anyBranch() + LibraryBranchId anotherBranchId = anyBranch() + LibraryBranchId yetAnotherBranchId = anyBranch() + PatronId patronId = anyPatronId() + PatronId anotherPatronId = anyPatronId() + BookId bookId = anyBookId() + Instant holdTill = Instant.now() + + + def 'should map to available book'() { + given: + BookDatabaseEntity entity = bookEntity(Available) + when: + Book book = entity.toDomainModel() + and: + AvailableBook availableBook = book as AvailableBook + then: + availableBook.bookId == bookId + availableBook.bookInformation.bookType == Circulating + availableBook.libraryBranch == libraryBranchId + + } + + def 'should map to on hold book'() { + given: + BookDatabaseEntity entity = bookEntity(OnHold) + when: + Book book = entity.toDomainModel() + and: + BookOnHold bookOnHold = book as BookOnHold + then: + bookOnHold.bookId == bookId + bookOnHold.bookInformation.bookType == Circulating + bookOnHold.holdPlacedAt == anotherBranchId + bookOnHold.byPatron == patronId + bookOnHold.holdTill == holdTill + } + + def 'should map to checked out book'() { + given: + BookDatabaseEntity entity = bookEntity(CheckedOut) + when: + Book book = entity.toDomainModel() + and: + CheckedOutBook checkedOutBook = book as CheckedOutBook + then: + checkedOutBook.bookId == bookId + checkedOutBook.bookInformation.bookType == Circulating + checkedOutBook.checkedOutAt == yetAnotherBranchId + checkedOutBook.byPatron == anotherPatronId + } + + BookDatabaseEntity bookEntity(BookState state) { + new BookDatabaseEntity( + book_id: bookId.bookId, + book_type: Circulating, + book_state: state, + available_at_branch: libraryBranchId.libraryBranchId, + on_hold_at_branch: anotherBranchId.libraryBranchId, + on_hold_by_patron: patronId.patronId, + on_hold_till: holdTill, + checked_out_at_branch: yetAnotherBranchId.libraryBranchId, + checked_out_by_patron: anotherPatronId.patronId) + } +} diff --git a/src/test/groovy/io/pillopl/library/lending/book/model/BookCheckingOutTest.groovy b/src/test/groovy/io/pillopl/library/lending/book/model/BookCheckingOutTest.groovy new file mode 100644 index 0000000..9bcf259 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/book/model/BookCheckingOutTest.groovy @@ -0,0 +1,33 @@ +package io.pillopl.library.lending.book.model + +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import spock.lang.Specification + +import static BookDSL.aCirculatingBook +import static BookDSL.the +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronEvent.BookCheckedOut +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatron + +class BookCheckingOutTest extends Specification { + + def 'should check out book which is marked as placed on hold in the system'() { + given: + BookDSL bookOnHold = aCirculatingBook() with anyBookId() locatedIn anyBranch() placedOnHoldBy anyPatron() + + and: + LibraryBranchId aBranch = anyBranch() + + and: + BookCheckedOut bookCheckedOutEvent = the bookOnHold isCheckedOutBy anyPatron() at aBranch + + when: + CheckedOutBook checkedOutBook = the bookOnHold reactsTo bookCheckedOutEvent + + then: + checkedOutBook.bookId == bookOnHold.bookId + checkedOutBook.checkedOutAt == aBranch + checkedOutBook.version == bookOnHold.version + } +} diff --git a/src/test/groovy/io/pillopl/library/lending/book/model/BookDSL.groovy b/src/test/groovy/io/pillopl/library/lending/book/model/BookDSL.groovy new file mode 100644 index 0000000..6855a17 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/book/model/BookDSL.groovy @@ -0,0 +1,173 @@ +package io.pillopl.library.lending.book.model + +import io.pillopl.library.catalogue.BookId +import io.pillopl.library.catalogue.BookType +import io.pillopl.library.commons.aggregates.Version +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.PatronEvent +import io.pillopl.library.lending.patron.model.PatronId + +import java.time.Instant + +import static io.pillopl.library.lending.book.model.BookFixture.version0 +import static io.pillopl.library.catalogue.BookType.Circulating + +class BookDSL { + BookType bookType + BookId bookId + LibraryBranchId libraryBranchId + PatronId patronId + Closure bookProvider + Version version = version0() + + static BookDSL the(BookDSL book) { + return book + } + + static BookDSL aCirculatingBook() { + return new BookDSL(Circulating) + } + + BookDSL(BookType type) { + this.bookType = type + } + + BookDSL(BookDSL from) { + this.bookType = from.bookType + this.bookId = from.bookId + this.libraryBranchId = from.libraryBranchId + this.patronId = from.patronId + this.bookProvider = from.bookProvider + this.version = from.version + } + + BookDSL with(BookId id) { + this.bookId = id + return this + } + + BookDSL locatedIn(LibraryBranchId libraryBranch) { + this.libraryBranchId = libraryBranch + return this + } + + BookDSL placedOnHoldBy(PatronId aPatron) { + this.patronId = aPatron + this.bookProvider = { -> + new BookOnHold(new BookInformation(bookId, bookType), libraryBranchId, patronId, Instant.now(), version0()) + } + return this + } + + BookDSL stillAvailable() { + bookProvider = { -> new AvailableBook(new BookInformation(bookId, bookType), libraryBranchId, version0()) } + return this + } + + BookDSL checkedOutBy(PatronId aPatron) { + bookProvider = { -> + new CheckedOutBook(new BookInformation(bookId, bookType), libraryBranchId, aPatron, version0()) + } + return this + } + + def isReturnedBy(PatronId aPatron) { + return new BookDSL(this) { + PatronEvent.BookReturned at(LibraryBranchId branchId) { + return bookReturned(bookProvider(), aPatron, branchId) + } + } + } + + def isPlacedOnHoldBy(PatronId aPatron) { + return new BookDSL(this) { + + PatronId onHoldPatronId + LibraryBranchId placeOnHoldBranchId + Instant onHoldFrom + + { + onHoldPatronId = aPatron + onHoldFrom = Instant.now() + } + + def at(LibraryBranchId branchId) { + placeOnHoldBranchId = branchId + return this + } + + def from(Instant from) { + onHoldFrom = from + return this + } + + PatronEvent.BookPlacedOnHold till(Instant till) { + return bookPlacedOnHold(bookProvider(), onHoldPatronId, placeOnHoldBranchId, onHoldFrom, till) + } + } + } + + def isCheckedOutBy(PatronId aPatron) { + new BookDSL(this) { + PatronEvent.BookCheckedOut at(LibraryBranchId branchId) { + return bookCheckedOut(bookProvider(), aPatron, branchId) + } + } + } + + PatronEvent.BookHoldCanceled isCancelledBy(PatronId aPatron) { + return bookHoldCanceled(bookProvider(), aPatron, libraryBranchId) + } + + PatronEvent.BookHoldExpired expired() { + return bookHoldExpired(bookProvider(), patronId, libraryBranchId) + } + + Book reactsTo(PatronEvent event) { + return bookProvider().handle(event) + } + + + private static PatronEvent.BookReturned bookReturned(Book bookCheckedOut, PatronId patronId, LibraryBranchId libraryBranchId) { + return new PatronEvent.BookReturned(Instant.now(), + patronId.patronId, + bookCheckedOut.getBookId().bookId, + bookCheckedOut.bookInformation.bookType, + libraryBranchId.libraryBranchId) + } + + private static PatronEvent.BookCheckedOut bookCheckedOut(Book bookOnHold, PatronId patronId, LibraryBranchId libraryBranchId) { + return new PatronEvent.BookCheckedOut(Instant.now(), + patronId.patronId, + bookOnHold.getBookId().bookId, + bookOnHold.bookInformation.bookType, + libraryBranchId.libraryBranchId, + Instant.now()) + } + + private static PatronEvent.BookPlacedOnHold bookPlacedOnHold(Book availableBook, PatronId byPatron, LibraryBranchId libraryBranchId, Instant from, Instant till) { + return new PatronEvent.BookPlacedOnHold(Instant.now(), + byPatron.patronId, + availableBook.getBookId().bookId, + availableBook.bookInformation.bookType, + libraryBranchId.libraryBranchId, + from, + till) + } + + + private static PatronEvent.BookHoldExpired bookHoldExpired(Book bookOnHold, PatronId patronId, LibraryBranchId libraryBranchId) { + return new PatronEvent.BookHoldExpired(Instant.now(), + bookOnHold.getBookId().bookId, + patronId.patronId, + libraryBranchId.libraryBranchId) + } + + private static PatronEvent.BookHoldCanceled bookHoldCanceled(Book bookOnHold, PatronId patronId, LibraryBranchId libraryBranchId) { + return new PatronEvent.BookHoldCanceled(Instant.now(), + bookOnHold.getBookId().bookId, + patronId.patronId, + libraryBranchId.libraryBranchId) + } + +} \ No newline at end of file diff --git a/src/test/groovy/io/pillopl/library/lending/book/model/BookFixture.java b/src/test/groovy/io/pillopl/library/lending/book/model/BookFixture.java new file mode 100644 index 0000000..574ff87 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/book/model/BookFixture.java @@ -0,0 +1,67 @@ +package io.pillopl.library.lending.book.model; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.commons.aggregates.Version; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import io.pillopl.library.lending.patron.model.PatronId; + +import java.time.Instant; +import java.util.UUID; + +import static io.pillopl.library.catalogue.BookType.Circulating; +import static io.pillopl.library.catalogue.BookType.Restricted; +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch; + +public class BookFixture { + + public static BookOnHold bookOnHold(BookId bookId, LibraryBranchId libraryBranchId) { + return new BookOnHold(new BookInformation(bookId, Circulating), libraryBranchId, anyPatronId(), Instant.now(), version0()); + } + + public static AvailableBook circulatingBook() { + return new AvailableBook(new BookInformation(anyBookId(), Circulating), anyBranch(), version0()); + } + + public static BookOnHold bookOnHold() { + return new BookOnHold(new BookInformation(anyBookId(), Circulating), anyBranch(), anyPatronId(), Instant.now(), version0()); + } + + public static AvailableBook circulatingAvailableBookAt(LibraryBranchId libraryBranchId) { + return new AvailableBook(new BookInformation(anyBookId(), Circulating), libraryBranchId, version0()); + } + + public static AvailableBook circulatingAvailableBookAt(BookId bookId, LibraryBranchId libraryBranchId) { + return new AvailableBook(new BookInformation(bookId, Circulating), libraryBranchId, version0()); + } + + public static AvailableBook aBookAt(LibraryBranchId libraryBranchId) { + return new AvailableBook(new BookInformation(anyBookId(), Circulating), libraryBranchId, version0()); + } + + public static Version version0() { + return new Version(0); + } + + public static AvailableBook circulatingAvailableBook() { + return circulatingAvailableBookAt(anyBranch()); + } + + public static CheckedOutBook checkedOutBook() { + return new CheckedOutBook(new BookInformation(anyBookId(), Circulating), anyBranch(), anyPatronId(), version0()); + } + + public static AvailableBook restrictedBook() { + return new AvailableBook(new BookInformation(anyBookId(), Restricted), anyBranch(), version0()); + } + + public static BookId anyBookId() { + return new BookId(UUID.randomUUID()); + } + + + private static PatronId anyPatronId() { + return new PatronId(UUID.randomUUID()); + } + + +} diff --git a/src/test/groovy/io/pillopl/library/lending/book/model/BookHoldCanceledTest.groovy b/src/test/groovy/io/pillopl/library/lending/book/model/BookHoldCanceledTest.groovy new file mode 100644 index 0000000..7de494b --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/book/model/BookHoldCanceledTest.groovy @@ -0,0 +1,29 @@ +package io.pillopl.library.lending.book.model + + +import io.pillopl.library.lending.patron.model.PatronEvent +import spock.lang.Specification + +import static io.pillopl.library.lending.book.model.BookDSL.aCirculatingBook +import static io.pillopl.library.lending.book.model.BookDSL.the +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatron + +class BookHoldCanceledTest extends Specification { + + def 'should make book available when hold canceled'() { + given: + BookDSL bookOnHold = aCirculatingBook() with anyBookId() locatedIn anyBranch() placedOnHoldBy anyPatron() + and: + PatronEvent.BookHoldCanceled bookHoldCanceledEvent = the bookOnHold isCancelledBy anyPatron() + + when: + AvailableBook availableBook = the bookOnHold reactsTo bookHoldCanceledEvent + then: + availableBook.bookId == bookOnHold.bookId + availableBook.libraryBranch == bookOnHold.libraryBranchId + availableBook.version == bookOnHold.version + } + +} diff --git a/src/test/groovy/io/pillopl/library/lending/book/model/BookHoldExpiredTest.groovy b/src/test/groovy/io/pillopl/library/lending/book/model/BookHoldExpiredTest.groovy new file mode 100644 index 0000000..da98c72 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/book/model/BookHoldExpiredTest.groovy @@ -0,0 +1,30 @@ +package io.pillopl.library.lending.book.model + + +import spock.lang.Specification + +import static BookDSL.aCirculatingBook +import static BookDSL.the +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronEvent.BookHoldExpired +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatron + +class BookHoldExpiredTest extends Specification { + + def 'should make book available when hold expired'() { + given: + BookDSL bookOnHold = aCirculatingBook() with anyBookId() locatedIn anyBranch() placedOnHoldBy anyPatron() + + and: + BookHoldExpired bookHoldExpiredEvent = the bookOnHold expired() + + when: + AvailableBook availableBook = the bookOnHold reactsTo bookHoldExpiredEvent + then: + availableBook.bookId == bookOnHold.bookId + availableBook.libraryBranch == bookOnHold.libraryBranchId + availableBook.version == bookOnHold.version + } + +} diff --git a/src/test/groovy/io/pillopl/library/lending/book/model/BookPlacingOnHoldTest.groovy b/src/test/groovy/io/pillopl/library/lending/book/model/BookPlacingOnHoldTest.groovy new file mode 100644 index 0000000..e11181a --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/book/model/BookPlacingOnHoldTest.groovy @@ -0,0 +1,44 @@ +package io.pillopl.library.lending.book.model + +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.PatronEvent +import io.pillopl.library.lending.patron.model.PatronId +import spock.lang.Specification + +import java.time.Instant + +import static io.pillopl.library.lending.book.model.BookDSL.aCirculatingBook +import static io.pillopl.library.lending.book.model.BookDSL.the +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatron + +class BookPlacingOnHoldTest extends Specification { + + private static Instant now = Instant.MIN + private static Instant oneHourLater = now.plusSeconds(3600) + + def 'should place on hold book which is marked as available in the system'() { + given: + BookDSL availableBook = aCirculatingBook() with anyBookId() locatedIn anyBranch() stillAvailable() + + and: + PatronId aPatron = anyPatron() + + and: + LibraryBranchId aBranch = anyBranch() + + and: + PatronEvent.BookPlacedOnHold bookPlacedOnHoldEvent = the availableBook isPlacedOnHoldBy aPatron at aBranch from now till oneHourLater + + when: + BookOnHold onHold = the availableBook reactsTo bookPlacedOnHoldEvent + + then: + onHold.bookId == availableBook.bookId + onHold.byPatron == aPatron + onHold.holdTill == oneHourLater + onHold.holdPlacedAt == aBranch + onHold.version == availableBook.version + } +} diff --git a/src/test/groovy/io/pillopl/library/lending/book/model/BookReturningTest.groovy b/src/test/groovy/io/pillopl/library/lending/book/model/BookReturningTest.groovy new file mode 100644 index 0000000..be09037 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/book/model/BookReturningTest.groovy @@ -0,0 +1,95 @@ +package io.pillopl.library.lending.book.model + +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.PatronEvent +import io.pillopl.library.lending.patron.model.PatronId +import spock.lang.Specification + +import java.time.Instant + +import static BookDSL.aCirculatingBook +import static BookDSL.the +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatron + +class BookReturningTest extends Specification { + + private static Instant now = Instant.MIN + private static Instant oneHour = now.plusSeconds(3600) + + def 'should return book which is marked as placed on hold in the system'() { + given: + BookDSL bookOnHold = aCirculatingBook() with anyBookId() locatedIn anyBranch() placedOnHoldBy anyPatron() + + and: + LibraryBranchId aBranch = anyBranch() + + and: + PatronEvent.BookReturned bookReturnedEvent = the bookOnHold isReturnedBy anyPatron() at aBranch + + when: + AvailableBook availableBook = the bookOnHold reactsTo bookReturnedEvent + + then: + availableBook.bookId == bookOnHold.bookId + availableBook.libraryBranch == aBranch + availableBook.version == bookOnHold.version + } + + def 'should place on hold book which is marked as available in the system'() { + given: + BookDSL availableBook = aCirculatingBook() with anyBookId() locatedIn anyBranch() stillAvailable() + + and: + PatronId aPatron = anyPatron() + + and: + LibraryBranchId aBranch = anyBranch() + + and: + PatronEvent.BookPlacedOnHold bookPlacedOnHoldEvent = the availableBook isPlacedOnHoldBy aPatron at aBranch from now till oneHour + + when: + BookOnHold onHold = the availableBook reactsTo bookPlacedOnHoldEvent + + then: + onHold.bookId == availableBook.bookId + onHold.byPatron == aPatron + onHold.holdTill == oneHour + onHold.holdPlacedAt == aBranch + } + + def 'should return book which is marked as checkedOut in the system'() { + given: + BookDSL checkedOutBook = aCirculatingBook() with anyBookId() locatedIn anyBranch() checkedOutBy anyPatron() + + and: + PatronEvent.BookReturned bookReturnedEvent = the checkedOutBook isReturnedBy anyPatron() at anyBranch() + + when: + AvailableBook available = the checkedOutBook reactsTo bookReturnedEvent + + then: + available.bookId == checkedOutBook.bookId + } + + def 'should check out book which is marked as placed on hold in the system'() { + given: + BookDSL onHoldBook = aCirculatingBook() with anyBookId() locatedIn anyBranch() placedOnHoldBy anyPatron() + + and: + LibraryBranchId aBranch = anyBranch() + + and: + PatronEvent.BookCheckedOut bookCheckedOutEvent = the onHoldBook isCheckedOutBy anyPatron() at aBranch + + when: + CheckedOutBook checkedOutBook = the onHoldBook reactsTo bookCheckedOutEvent + + then: + checkedOutBook.bookId == onHoldBook.bookId + checkedOutBook.checkedOutAt == aBranch + } + +} diff --git a/src/test/groovy/io/pillopl/library/lending/dailysheet/model/CheckoutsToOverdueSheetTest.groovy b/src/test/groovy/io/pillopl/library/lending/dailysheet/model/CheckoutsToOverdueSheetTest.groovy new file mode 100644 index 0000000..0bbfa75 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/dailysheet/model/CheckoutsToOverdueSheetTest.groovy @@ -0,0 +1,51 @@ +package io.pillopl.library.lending.dailysheet.model + +import io.pillopl.library.catalogue.BookId +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.PatronEvent +import io.pillopl.library.lending.patron.model.PatronId +import io.vavr.collection.List +import spock.lang.Specification + +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId + +class CheckoutsToOverdueSheetTest extends Specification { + + PatronId patronId = anyPatronId() + PatronId anotherPatronId = anyPatronId() + + BookId bookId = anyBookId() + BookId anotherBookId = anyBookId() + + LibraryBranchId libraryBranchId = anyBranch() + LibraryBranchId anotherLibraryBranchId = anyBranch() + + def 'should transform sheet into stream of OverdueCheckoutRegistered events'() { + given: + CheckoutsToOverdueSheet sheet = sheet(patronId, anotherPatronId, bookId, anotherBookId, libraryBranchId, anotherLibraryBranchId) + expect: + sheet.toStreamOfEvents().with { + + PatronEvent.OverdueCheckoutRegistered first = it.get(0) as PatronEvent.OverdueCheckoutRegistered + first.patronId == patronId.patronId + first.bookId == bookId.bookId + first.libraryBranchId == libraryBranchId.libraryBranchId + first.eventId != null + + PatronEvent.OverdueCheckoutRegistered second = it.get(1) as PatronEvent.OverdueCheckoutRegistered + + second.patronId == anotherPatronId.patronId + second.bookId == anotherBookId.bookId + second.libraryBranchId == anotherLibraryBranchId.libraryBranchId + second.eventId != null + } + } + + private CheckoutsToOverdueSheet sheet(PatronId patronId, PatronId anotherPatronId, BookId bookId, BookId anotherBookId, LibraryBranchId libraryBranchId, LibraryBranchId anotherBranchId) { + new CheckoutsToOverdueSheet(List.of( + new OverdueCheckout(bookId, patronId, libraryBranchId), + new OverdueCheckout(anotherBookId, anotherPatronId, anotherBranchId))) + } +} diff --git a/src/test/groovy/io/pillopl/library/lending/dailysheet/model/HoldsToExpireSheetTest.groovy b/src/test/groovy/io/pillopl/library/lending/dailysheet/model/HoldsToExpireSheetTest.groovy new file mode 100644 index 0000000..87ece05 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/dailysheet/model/HoldsToExpireSheetTest.groovy @@ -0,0 +1,51 @@ +package io.pillopl.library.lending.dailysheet.model + +import io.pillopl.library.catalogue.BookId +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.PatronEvent +import io.pillopl.library.lending.patron.model.PatronId +import io.vavr.collection.List +import spock.lang.Specification + +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId + +class HoldsToExpireSheetTest extends Specification { + + PatronId patronId = anyPatronId() + PatronId anotherPatronId = anyPatronId() + + BookId bookId = anyBookId() + BookId anotherBookId = anyBookId() + + LibraryBranchId libraryBranchId = anyBranch() + LibraryBranchId anotherLibraryBranchId = anyBranch() + + def 'should transform sheet into stream of BookHoldExpired events'() { + given: + HoldsToExpireSheet sheet = sheet(patronId, anotherPatronId, bookId, anotherBookId, libraryBranchId, anotherLibraryBranchId) + expect: + sheet.toStreamOfEvents().with { + + PatronEvent.BookHoldExpired first = it.get(0) as PatronEvent.BookHoldExpired + first.patronId == patronId.patronId + first.bookId == bookId.bookId + first.libraryBranchId == libraryBranchId.libraryBranchId + first.eventId != null + + PatronEvent.BookHoldExpired second = it.get(1) as PatronEvent.BookHoldExpired + + second.patronId == anotherPatronId.patronId + second.bookId == anotherBookId.bookId + second.libraryBranchId == anotherLibraryBranchId.libraryBranchId + second.eventId != null + } + } + + private HoldsToExpireSheet sheet(PatronId patronId, PatronId anotherPatronId, BookId bookId, BookId anotherBookId, LibraryBranchId libraryBranchId, LibraryBranchId anotherBranchId) { + new HoldsToExpireSheet(List.of( + new ExpiredHold(bookId, patronId, libraryBranchId), + new ExpiredHold(anotherBookId, anotherPatronId, anotherBranchId))) + } +} diff --git a/src/test/groovy/io/pillopl/library/lending/librarybranch/model/LibraryBranchFixture.java b/src/test/groovy/io/pillopl/library/lending/librarybranch/model/LibraryBranchFixture.java new file mode 100644 index 0000000..37431e1 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/librarybranch/model/LibraryBranchFixture.java @@ -0,0 +1,10 @@ +package io.pillopl.library.lending.librarybranch.model; + +import java.util.UUID; + +public class LibraryBranchFixture { + + public static LibraryBranchId anyBranch() { + return new LibraryBranchId(UUID.randomUUID()); + } +} diff --git a/src/test/groovy/io/pillopl/library/lending/patron/application/checkout/CheckingOutBookOnHoldTest.groovy b/src/test/groovy/io/pillopl/library/lending/patron/application/checkout/CheckingOutBookOnHoldTest.groovy new file mode 100644 index 0000000..f8a37d7 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/patron/application/checkout/CheckingOutBookOnHoldTest.groovy @@ -0,0 +1,116 @@ +package io.pillopl.library.lending.patron.application.checkout + + +import io.pillopl.library.commons.commands.Result +import io.pillopl.library.lending.patron.application.hold.FindBookOnHold +import io.pillopl.library.lending.book.model.BookOnHold +import io.pillopl.library.lending.patron.model.Patron +import io.pillopl.library.lending.patron.model.PatronEvent +import io.pillopl.library.lending.patron.model.Patrons +import io.pillopl.library.lending.patron.model.PatronId +import io.vavr.control.Option +import io.vavr.control.Try +import spock.lang.Specification + +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.book.model.BookFixture.bookOnHold +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronFixture.* + +class CheckingOutBookOnHoldTest extends Specification { + + BookOnHold bookOnHold = bookOnHold() + PatronId patronId = anyPatronId() + + FindBookOnHold willFindBook = { bookId, patronId -> Option.of(bookOnHold) } + FindBookOnHold willNotFindBook = { bookId, patronId -> Option.none() } + Patrons repository = Stub() + + def 'should successfully check out book if patron and book exist'() { + given: + CheckingOutBookOnHold checkingOut = new CheckingOutBookOnHold(willFindBook, repository) + and: + persisted(regularPatronWith(bookOnHold, patronId)) + when: + Try result = checkingOut.checkOut(for3days(patronId)) + then: + result.isSuccess() + result.get() == Result.Success + + } + + def 'should reject checking out if one of the domain rules is broken (but should not fail!)'() { + given: + CheckingOutBookOnHold checkingOut = new CheckingOutBookOnHold(willFindBook, repository) + and: + persisted(regularPatron(patronId)) + when: + Try result = checkingOut.checkOut(for3days(patronId)) + then: + result.isSuccess() + result.get() == Result.Rejection + + } + + def 'should fail if patron does not exists'() { + given: + CheckingOutBookOnHold checkingOut = new CheckingOutBookOnHold(willFindBook, repository) + and: + unknownPatron() + when: + Try result = checkingOut.checkOut(for3days(patronId)) + then: + result.isFailure() + + } + + + def 'should fail if book does not exists'() { + given: + CheckingOutBookOnHold checkingOut = new CheckingOutBookOnHold(willNotFindBook, repository) + and: + persisted(regularPatronWith(bookOnHold, patronId)) + when: + Try result = checkingOut.checkOut(for3days(patronId)) + then: + result.isFailure() + } + + def 'should fail if saving patron fails'() { + given: + CheckingOutBookOnHold checkingOutBookOnHold = new CheckingOutBookOnHold(willFindBook, repository) + and: + PatronId patron = persistedRegularPatronThatFailsOnSaving(patronId) + when: + Try result = checkingOutBookOnHold.checkOut(for3days(patronId)) + then: + result.isFailure() + + } + + CheckOutBookCommand for3days(PatronId patron) { + return CheckOutBookCommand.create(patron, anyBranch(), anyBookId(), 4) + } + + PatronId persisted(Patron patron) { + repository.findBy(patronId) >> Option.of(patron) + repository.publish(_ as PatronEvent) >> patron + return patronId + } + + + PatronId persistedRegularPatronThatFailsOnSaving(PatronId patronId) { + Patron patron = regularPatron(patronId) + repository.findBy(patronId) >> Option.of(patron) + repository.publish(_ as PatronEvent) >> {throw new IllegalStateException()} + return patronId + } + + PatronId unknownPatron() { + repository.findBy(patronId) >> Option.none() + return anyPatronId() + } + +} + + diff --git a/src/test/groovy/io/pillopl/library/lending/patron/application/checkout/RegisteringOverdueCheckoutsTest.groovy b/src/test/groovy/io/pillopl/library/lending/patron/application/checkout/RegisteringOverdueCheckoutsTest.groovy new file mode 100644 index 0000000..1d1347f --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/patron/application/checkout/RegisteringOverdueCheckoutsTest.groovy @@ -0,0 +1,75 @@ +package io.pillopl.library.lending.patron.application.checkout + +import io.pillopl.library.commons.commands.BatchResult +import io.pillopl.library.lending.dailysheet.model.CheckoutsToOverdueSheet +import io.pillopl.library.lending.dailysheet.model.DailySheet +import io.pillopl.library.lending.dailysheet.model.OverdueCheckout +import io.pillopl.library.lending.patron.model.PatronEvent +import io.pillopl.library.lending.patron.model.PatronId +import io.pillopl.library.lending.patron.model.Patrons +import io.vavr.control.Try +import spock.lang.Specification + +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId +import static io.pillopl.library.lending.patron.model.PatronFixture.regularPatron +import static io.vavr.collection.List.of + +class RegisteringOverdueCheckoutsTest extends Specification { + + Patrons repository = Stub() + DailySheet dailySheet = Stub() + PatronId patronWithOverdueCheckouts = anyPatronId() + PatronId anotherPatronWithOverdueCheckouts = anyPatronId() + + RegisteringOverdueCheckout registeringOverdueCheckout = + new RegisteringOverdueCheckout(dailySheet, repository) + + def setup() { + dailySheet.queryForCheckoutsToOverdue() >> overdueCheckoutsBy(patronWithOverdueCheckouts, anotherPatronWithOverdueCheckouts) + } + + + def 'should return success if all checkouts were marked as overdue'() { + given: + checkoutsWillBeMarkedAsOverdueForBothPatrons() + when: + Try result = registeringOverdueCheckout.registerOverdueCheckouts() + then: + result.isSuccess() + result.get() == BatchResult.FullSuccess + + } + + def 'should return an error (but should not fail) if at least one operation failed'() { + given: + registeringOverdueCheckoutWillFailForSecondPatron() + when: + Try result = registeringOverdueCheckout.registerOverdueCheckouts() + then: + result.isSuccess() + result.get() == BatchResult.SomeFailed + + } + + void registeringOverdueCheckoutWillFailForSecondPatron() { + repository.publish(_ as PatronEvent) >>> [regularPatron(), { throw new IllegalStateException() }] + } + + void checkoutsWillBeMarkedAsOverdueForBothPatrons() { + repository.publish(_ as PatronEvent) >> regularPatron() + } + + CheckoutsToOverdueSheet overdueCheckoutsBy(PatronId patronId, PatronId anotherPatronId) { + return new CheckoutsToOverdueSheet( + of( + new OverdueCheckout(anyBookId(), patronId, anyBranch()), + new OverdueCheckout(anyBookId(), anotherPatronId, anyBranch()), + + )) + } + + +} + diff --git a/src/test/groovy/io/pillopl/library/lending/patron/application/hold/CancelingHoldTest.groovy b/src/test/groovy/io/pillopl/library/lending/patron/application/hold/CancelingHoldTest.groovy new file mode 100644 index 0000000..b42f0e0 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/patron/application/hold/CancelingHoldTest.groovy @@ -0,0 +1,116 @@ +package io.pillopl.library.lending.patron.application.hold + +import io.pillopl.library.commons.commands.Result +import io.pillopl.library.lending.book.model.BookOnHold + +import io.pillopl.library.lending.patron.model.* +import io.vavr.control.Option +import io.vavr.control.Try +import spock.lang.Specification + +import java.time.Instant + +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.book.model.BookFixture.bookOnHold +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId + +class CancelingHoldTest extends Specification { + + BookOnHold bookOnHold = bookOnHold() + PatronId patronId = anyPatronId() + + FindBookOnHold willFindBook = { bookId, patronId -> Option.of(bookOnHold) } + FindBookOnHold willNotFindBook = { bookId, patronId -> Option.none() } + Patrons repository = Stub() + + def 'should successfully cancel hold if book was placed on hold by patron and patron and book exist'() { + given: + CancelingHold canceling = new CancelingHold(willFindBook, repository) + and: + persistedRegularPatronWithBookOnHold() + when: + Try result = canceling.cancelHold(cmd()) + then: + result.isSuccess() + result.get() == Result.Success + } + + def 'should reject placing on hold book if one of the domain rules is broken (but should not fail!)'() { + given: + CancelingHold canceling = new CancelingHold(willFindBook, repository) + and: + persistedRegularPatronWithoutBookOnHold() + when: + Try result = canceling.cancelHold(cmd()) + then: + result.isSuccess() + result.get() == Result.Rejection + + } + + def 'should fail if patron does not exists'() { + given: + CancelingHold canceling = new CancelingHold(willFindBook, repository) + and: + unknownPatron() + when: + Try result = canceling.cancelHold(cmd()) + then: + result.isFailure() + + } + + def 'should fail if book does not exists'() { + given: + CancelingHold canceling = new CancelingHold(willNotFindBook, repository) + and: + persistedRegularPatronWithBookOnHold() + when: + Try result = canceling.cancelHold(cmd()) + then: + result.isFailure() + } + + def 'should fail if saving patron fails'() { + given: + CancelingHold canceling = new CancelingHold(willFindBook, repository) + and: + persistedRegularPatronThatFailsOnSaving() + when: + Try result = canceling.cancelHold(cmd()) + then: + result.isFailure() + + } + + CancelHoldCommand cmd() { + return new CancelHoldCommand(Instant.now(), patronId, anyBookId()) + } + + PatronId persistedRegularPatronWithBookOnHold() { + Patron patron = PatronFixture.regularPatronWithHold(bookOnHold) + repository.findBy(patronId) >> Option.of(patron) + repository.publish(_ as PatronEvent) >> patron + return patronId + } + + PatronId persistedRegularPatronWithoutBookOnHold() { + Patron patron = PatronFixture.regularPatronWithHolds(10) + repository.findBy(patronId) >> Option.of(patron) + return patronId + } + + PatronId persistedRegularPatronThatFailsOnSaving() { + Patron patron = PatronFixture.regularPatronWithHold(bookOnHold) + repository.findBy(patronId) >> Option.of(patron) + repository.publish(_ as PatronEvent) >> { throw new IllegalStateException() } + return patronId + } + + PatronId unknownPatron() { + return anyPatronId() + } + +} + + diff --git a/src/test/groovy/io/pillopl/library/lending/patron/application/hold/ExpiringHoldsTest.groovy b/src/test/groovy/io/pillopl/library/lending/patron/application/hold/ExpiringHoldsTest.groovy new file mode 100644 index 0000000..7198b84 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/patron/application/hold/ExpiringHoldsTest.groovy @@ -0,0 +1,72 @@ +package io.pillopl.library.lending.patron.application.hold + +import io.pillopl.library.commons.commands.BatchResult +import io.pillopl.library.lending.dailysheet.model.DailySheet +import io.pillopl.library.lending.dailysheet.model.ExpiredHold +import io.pillopl.library.lending.dailysheet.model.HoldsToExpireSheet +import io.pillopl.library.lending.patron.model.PatronEvent +import io.pillopl.library.lending.patron.model.PatronId +import io.pillopl.library.lending.patron.model.Patrons +import io.vavr.control.Try +import spock.lang.Specification + +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId +import static io.vavr.collection.List.of + +class ExpiringHoldsTest extends Specification { + + Patrons repository = Stub() + DailySheet dailySheet = Stub() + + PatronId patronWithExpiringHolds = anyPatronId() + PatronId anotherPatronWithExpiringHolds = anyPatronId() + + ExpiringHolds expiring = new ExpiringHolds(dailySheet, repository) + + def setup() { + dailySheet.queryForHoldsToExpireSheet() >> expiredHoldsBy(patronWithExpiringHolds, anotherPatronWithExpiringHolds) + } + + def 'should return success if all holds were marked as expired'() { + given: + holdsWillBeExpiredSuccessfullyForBothPatrons() + when: + Try result = expiring.expireHolds() + then: + result.isSuccess() + result.get() == BatchResult.FullSuccess + + } + + def 'should return an error (but should not fail) if at least one operation failed'() { + given: + expiringHoldWillFailForSecondPatron() + when: + Try result = expiring.expireHolds() + then: + result.isSuccess() + result.get() == BatchResult.SomeFailed + + } + + void expiringHoldWillFailForSecondPatron() { + repository.publish(_ as PatronEvent) >>> [null, { throw new IllegalStateException() }] + } + + void holdsWillBeExpiredSuccessfullyForBothPatrons() { + repository.publish(_ as PatronEvent) >> null + } + + HoldsToExpireSheet expiredHoldsBy(PatronId patronId, PatronId anotherPatronId) { + return new HoldsToExpireSheet( + of( + new ExpiredHold(anyBookId(), patronId, anyBranch()), + new ExpiredHold(anyBookId(), anotherPatronId, anyBranch()) + )) + } + + +} + diff --git a/src/test/groovy/io/pillopl/library/lending/patron/application/hold/HandleDuplicateHoldTest.groovy b/src/test/groovy/io/pillopl/library/lending/patron/application/hold/HandleDuplicateHoldTest.groovy new file mode 100644 index 0000000..3edb773 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/patron/application/hold/HandleDuplicateHoldTest.groovy @@ -0,0 +1,54 @@ +package io.pillopl.library.lending.patron.application.hold + +import io.pillopl.library.catalogue.BookId +import io.pillopl.library.lending.book.model.BookDuplicateHoldFound +import io.pillopl.library.lending.patron.model.PatronId +import spock.lang.Specification + +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset + +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId +import static java.time.Instant.now + +class HandleDuplicateHoldTest extends Specification { + + CancelingHold cancelingHold = Mock() + + def "should start cancelling hold if book was already hold by other patron"() { + given: + Clock clock = Clock.fixed(Instant.parse('2020-02-27T12:21:00Z'), ZoneOffset.UTC) + and: + HandleDuplicateHold duplicateHold = new HandleDuplicateHold(cancelingHold, clock) + and: + BookDuplicateHoldFound bookDuplicateHoldFound = duplicateHoldFoundBy() + and: + CancelHoldCommand cancelHoldCommand = cancelHoldCommandFrom(bookDuplicateHoldFound, clock) + when: + duplicateHold.handle(bookDuplicateHoldFound) + then: + 1 * cancelingHold.cancelHold(cancelHoldCommand) + } + + BookDuplicateHoldFound duplicateHoldFoundBy() { + return new BookDuplicateHoldFound( + now(), + anyPatronId().patronId, + anyPatronId().patronId, + anyBranch().libraryBranchId, + anyBookId().bookId + ) + } + + CancelHoldCommand cancelHoldCommandFrom(BookDuplicateHoldFound event, Clock clock) { + return new CancelHoldCommand( + clock.instant(), + new PatronId(event.getSecondPatronId()), + new BookId(event.getBookId()) + ) + } + +} diff --git a/src/test/groovy/io/pillopl/library/lending/patron/application/hold/PlacingBookOnHoldTest.groovy b/src/test/groovy/io/pillopl/library/lending/patron/application/hold/PlacingBookOnHoldTest.groovy new file mode 100644 index 0000000..2a3e4ef --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/patron/application/hold/PlacingBookOnHoldTest.groovy @@ -0,0 +1,124 @@ +package io.pillopl.library.lending.patron.application.hold + + +import io.pillopl.library.commons.commands.Result +import io.pillopl.library.lending.patron.model.Patron +import io.pillopl.library.lending.patron.model.PatronEvent +import io.pillopl.library.lending.patron.model.PatronFixture +import io.pillopl.library.lending.patron.model.Patrons +import io.pillopl.library.lending.patron.model.PatronId +import io.vavr.control.Option +import io.vavr.control.Try +import spock.lang.Specification + +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.book.model.BookFixture.circulatingBook +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId +import static io.pillopl.library.lending.patron.model.PatronFixture.regularPatron + +class PlacingBookOnHoldTest extends Specification { + + FindAvailableBook willFindBook = { id -> Option.of(circulatingBook()) } + FindAvailableBook willNotFindBook = { id -> Option.none() } + Patrons repository = Stub() + + def 'should successfully place on hold book if patron and book exist'() { + given: + PlacingOnHold holding = new PlacingOnHold(willFindBook, repository) + and: + PatronId patron = persistedRegularPatron() + when: + Try result = holding.placeOnHold(for3days(patron)) + then: + result.isSuccess() + result.get() == Result.Success + + } + + + def 'should reject placing on hold book if one of the domain rules is broken (but should not fail!)'() { + given: + PlacingOnHold holding = new PlacingOnHold(willFindBook, repository) + and: + PatronId patron = persistedRegularPatronWithManyHolds() + when: + Try result = holding.placeOnHold(for3days(patron)) + then: + result.isSuccess() + result.get() == Result.Rejection + + } + + def 'should fail if patron does not exists'() { + given: + PlacingOnHold holding = new PlacingOnHold(willFindBook, repository) + and: + PatronId patron = unknownPatron() + when: + Try result = holding.placeOnHold(for3days(patron)) + then: + result.isFailure() + + } + + + def 'should fail if book does not exists'() { + given: + PlacingOnHold holding = new PlacingOnHold(willNotFindBook, repository) + and: + PatronId patron = persistedRegularPatron() + when: + Try result = holding.placeOnHold(for3days(patron)) + then: + result.isFailure() + } + + def 'should fail if saving patron fails'() { + given: + PlacingOnHold holding = new PlacingOnHold(willFindBook, repository) + and: + PatronId patron = persistedRegularPatronThatFailsOnSaving() + when: + Try result = holding.placeOnHold(for3days(patron)) + then: + result.isFailure() + + } + + PlaceOnHoldCommand for3days(PatronId patron) { + return PlaceOnHoldCommand.closeEnded(patron, anyBranch(), anyBookId(), 4) + } + + PatronId persistedRegularPatron() { + PatronId patronId = anyPatronId() + Patron patron = regularPatron(patronId) + repository.findBy(patronId) >> Option.of(patron) + repository.publish(_ as PatronEvent) >> patron + return patronId + } + + PatronId persistedRegularPatronWithManyHolds() { + PatronId patronId = anyPatronId() + Patron patron = PatronFixture.regularPatronWithHolds(10) + repository.findBy(patronId) >> Option.of(patron) + repository.publish(_ as PatronEvent) >> patron + + return patronId + } + + PatronId persistedRegularPatronThatFailsOnSaving() { + PatronId patronId = anyPatronId() + Patron patron = regularPatron(patronId) + repository.findBy(patronId) >> Option.of(patron) + repository.publish(_ as PatronEvent) >> {throw new IllegalStateException()} + return patronId + } + + PatronId unknownPatron() { + return anyPatronId() + } + +} + + diff --git a/src/test/groovy/io/pillopl/library/lending/patron/infrastructure/CreatingDataModelFromPatronEventsTest.groovy b/src/test/groovy/io/pillopl/library/lending/patron/infrastructure/CreatingDataModelFromPatronEventsTest.groovy new file mode 100644 index 0000000..c9fc733 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/patron/infrastructure/CreatingDataModelFromPatronEventsTest.groovy @@ -0,0 +1,170 @@ +package io.pillopl.library.lending.patron.infrastructure + +import io.pillopl.library.catalogue.BookId +import io.pillopl.library.catalogue.BookType +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.* +import spock.lang.Specification + +import java.time.Duration +import java.time.Instant + +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.catalogue.BookType.Restricted +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.CheckoutDuration.forNoOfDays +import static io.pillopl.library.lending.patron.model.HoldDuration.closeEnded +import static io.pillopl.library.lending.patron.model.HoldDuration.openEnded +import static io.pillopl.library.lending.patron.model.PatronEvent.BookHoldCanceled.holdCanceledNow +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHold.bookPlacedOnHoldNow +import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents.events +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId +import static io.pillopl.library.lending.patron.model.PatronType.Regular + +class CreatingDataModelFromPatronEventsTest extends Specification { + + PatronId patronId = anyPatronId() + PatronType regular = Regular + LibraryBranchId libraryBranchId = anyBranch() + BookType type = Restricted + BookId bookId = anyBookId() + Instant holdFrom = Instant.now() + + def 'should add hold on placedOnHold event with close ended duration'() { + given: + PatronDatabaseEntity entity = createPatron() + when: + entity.handle(placedOnHold(closeEnded(holdFrom, NumberOfDays.of(1)))) + then: + entity.booksOnHold.size() == 1 + entity.booksOnHold.iterator().next().till == holdFrom.plus(Duration.ofDays(1)) + + } + + def 'should add hold on placedOnHold event with open ended duration '() { + given: + PatronDatabaseEntity entity = createPatron() + when: + entity.handle(placedOnHold(openEnded())) + then: + entity.booksOnHold.size() == 1 + entity.booksOnHold.iterator().next().till == null + + } + + def 'should remove hold on patronCheckedOut event'() { + given: + PatronDatabaseEntity entity = createPatron() + when: + entity.handle(placedOnHold()) + then: + entity.booksOnHold.size() == 1 + when: + entity.handle(bookCheckedOut()) + then: + entity.booksOnHold.size() == 0 + + } + + def 'should remove hold on holdCancelled event'() { + given: + PatronDatabaseEntity entity = createPatron() + when: + entity.handle(placedOnHold()) + then: + entity.booksOnHold.size() == 1 + when: + entity.handle(holdCanceled()) + then: + entity.booksOnHold.size() == 0 + } + + def 'should remove hold on holdExpired event'() { + given: + PatronDatabaseEntity entity = createPatron() + when: + entity.handle(placedOnHold()) + then: + entity.booksOnHold.size() == 1 + when: + entity.handle(bookHoldExpired()) + then: + entity.booksOnHold.size() == 0 + + } + + def 'should add overdue checkout on overdueCheckoutRegistered'() { + given: + PatronDatabaseEntity entity = createPatron() + when: + entity.handle(overdueCheckoutRegistered()) + then: + entity.checkouts.size() == 1 + } + + def 'should remove overdue checkout on bookReturned event'() { + given: + PatronDatabaseEntity entity = createPatron() + when: + entity.handle(overdueCheckoutRegistered()) + then: + entity.checkouts.size() == 1 + when: + entity.handle(bookReturned()) + then: + entity.checkouts.size() == 0 + + + } + + PatronDatabaseEntity createPatron() { + return new PatronDatabaseEntity(patronId, Regular) + } + + PatronEvent.BookCheckedOut bookCheckedOut() { + return PatronEvent.BookCheckedOut.bookCheckedOutNow( + bookId, + type, + libraryBranchId, + patronId, + forNoOfDays(1)) + } + + PatronEvent.BookReturned bookReturned() { + return new PatronEvent.BookReturned( + Instant.now(), + patronId.patronId, + bookId.bookId, + type, + libraryBranchId.libraryBranchId) + } + + PatronEvent.BookHoldCanceled holdCanceled() { + return holdCanceledNow( + bookId, + libraryBranchId, + patronId) + } + + PatronEvent.BookPlacedOnHoldEvents placedOnHold(HoldDuration duration = closeEnded(5)) { + return events(bookPlacedOnHoldNow( + bookId, + type, + libraryBranchId, + patronId, + duration)) + } + + PatronEvent.BookHoldExpired bookHoldExpired() { + return PatronEvent.BookHoldExpired.now( + bookId, + patronId, + libraryBranchId + ) + } + + PatronEvent.OverdueCheckoutRegistered overdueCheckoutRegistered() { + return PatronEvent.OverdueCheckoutRegistered.now(patronId, bookId, libraryBranchId) + } + +} diff --git a/src/test/groovy/io/pillopl/library/lending/patron/infrastructure/PatronEntityToDomainModelMappingTest.groovy b/src/test/groovy/io/pillopl/library/lending/patron/infrastructure/PatronEntityToDomainModelMappingTest.groovy new file mode 100644 index 0000000..e256d4d --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/patron/infrastructure/PatronEntityToDomainModelMappingTest.groovy @@ -0,0 +1,65 @@ +package io.pillopl.library.lending.patron.infrastructure + +import io.pillopl.library.catalogue.BookId +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.pillopl.library.lending.patron.model.PatronFactory +import io.pillopl.library.lending.patron.model.PatronId +import io.pillopl.library.lending.patron.model.PatronType +import spock.lang.Specification + +import java.time.Instant + +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId +import static io.pillopl.library.lending.patron.model.PatronType.Regular +import static java.util.Collections.emptyList + +class PatronEntityToDomainModelMappingTest extends Specification { + + DomainModelMapper domainModelMapper = new DomainModelMapper(new PatronFactory()) + + LibraryBranchId libraryBranchId = anyBranch() + LibraryBranchId anotherBranchId = anyBranch() + PatronId patronId = anyPatronId() + BookId bookId = anyBookId() + BookId anotherBookId = anyBookId() + Instant anyDate = Instant.now() + + def 'should map patron holds'() { + given: + PatronDatabaseEntity entity = patronEntity(patronId, Regular, [ + new HoldDatabaseEntity(bookId.bookId, patronId.patronId, libraryBranchId.libraryBranchId, anyDate), + new HoldDatabaseEntity(anotherBookId.bookId, patronId.patronId, anotherBranchId.libraryBranchId, anyDate)]) + when: + Set> patronHolds = domainModelMapper.mapPatronHolds(entity) + then: + patronHolds.size() == 2 + + + } + + def 'should map patron overdue checkouts'() { + given: + PatronDatabaseEntity entity = patronEntity(patronId, Regular, [], [ + new OverdueCheckoutDatabaseEntity(bookId.bookId, patronId.patronId, libraryBranchId.libraryBranchId), + new OverdueCheckoutDatabaseEntity(anotherBookId.bookId, patronId.patronId, anotherBranchId.libraryBranchId)]) + when: + Map> overdueCheckouts = domainModelMapper.mapPatronOverdueCheckouts(entity) + then: + overdueCheckouts.get(libraryBranchId).size() == 1 + overdueCheckouts.get(anotherBranchId).size() == 1 + } + + + PatronDatabaseEntity patronEntity(PatronId patronId, + PatronType type, + List holds = emptyList(), + List overdueCheckouts = emptyList()) { + PatronDatabaseEntity entity = new PatronDatabaseEntity(patronId, type) + entity.booksOnHold = holds as Set + entity.checkouts = overdueCheckouts as Set + return entity + } + +} diff --git a/src/test/groovy/io/pillopl/library/lending/patron/model/PatronCancelingHoldTest.groovy b/src/test/groovy/io/pillopl/library/lending/patron/model/PatronCancelingHoldTest.groovy new file mode 100644 index 0000000..31b6285 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/patron/model/PatronCancelingHoldTest.groovy @@ -0,0 +1,58 @@ +package io.pillopl.library.lending.patron.model + +import io.pillopl.library.lending.book.model.BookFixture +import io.pillopl.library.lending.book.model.BookOnHold +import io.vavr.control.Either +import spock.lang.Specification + +import static PatronFixture.regularPatron +import static PatronFixture.regularPatronWithHold +import static PatronEvent.BookHoldCanceled +import static PatronEvent.BookHoldCancelingFailed + + +class PatronCancelingHoldTest extends Specification { + + def 'patron should be able to cancel his holds'() { + given: + BookOnHold forBook = BookFixture.bookOnHold() + and: + Patron patron = regularPatronWithHold(forBook) + when: + Either cancelHold = patron.cancelHold(forBook) + then: + cancelHold.isRight() + cancelHold.get().with { + assert it.libraryBranchId == forBook.getHoldPlacedAt().libraryBranchId + assert it.bookId == forBook.bookInformation.bookId.bookId + } + + } + + def 'patron cannot cancel a hold which does not exist'() { + given: + BookOnHold forBook = BookFixture.bookOnHold() + and: + Patron patron = regularPatron() + when: + Either cancelHold = patron.cancelHold(forBook) + then: + cancelHold.isLeft() + + } + + def 'patron cannot cancel a hold which was done by someone else'() { + given: + BookOnHold forBook = BookFixture.bookOnHold() + and: + Patron patron = regularPatron() + and: + Patron differentPatron = regularPatronWithHold(forBook) + when: + Either cancelHold = patron.cancelHold(forBook) + then: + cancelHold.isLeft() + + } + +} diff --git a/src/test/groovy/io/pillopl/library/lending/patron/model/PatronCheckingOutBookTest.groovy b/src/test/groovy/io/pillopl/library/lending/patron/model/PatronCheckingOutBookTest.groovy new file mode 100644 index 0000000..639b86e --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/patron/model/PatronCheckingOutBookTest.groovy @@ -0,0 +1,80 @@ +package io.pillopl.library.lending.patron.model + +import io.pillopl.library.lending.book.model.BookOnHold +import io.vavr.control.Either +import spock.lang.Specification + +import java.time.Duration +import java.time.Instant + +import static io.pillopl.library.lending.patron.model.PatronEvent.BookCheckingOutFailed +import static PatronFixture.regularPatron +import static PatronFixture.regularPatronWith +import static io.pillopl.library.lending.book.model.BookFixture.bookOnHold +import static io.pillopl.library.lending.patron.model.CheckoutDuration.MAX_CHECKOUT_DURATION +import static io.pillopl.library.lending.patron.model.CheckoutDuration.maxDuration +import static io.pillopl.library.lending.patron.model.CheckoutDuration.forNoOfDays +import static io.pillopl.library.lending.patron.model.PatronEvent.BookCheckedOut +import static PatronFixture.onHold + +class PatronCheckingOutBookTest extends Specification { + + def 'patron cannot check out book which is not placed on hold'() { + when: + Either checkOut = regularPatron().checkOut(bookOnHold(), maxDuration()) + then: + checkOut.isLeft() + BookCheckingOutFailed e = checkOut.getLeft() + e.reason.contains("book is not on hold by patron") + } + + + def 'patron can check out book which was placed on hold by him'() { + given: + Hold onHold = onHold() + and: + Patron patron = regularPatronWith(onHold) + when: + Either checkOut = patron.checkOut(bookOnHold(onHold.bookId, onHold.libraryBranchId), maxDuration()) + then: + checkOut.isRight() + } + + def 'patron can checkout up to 60 days'() { + given: + Hold onHold = onHold() + and: + Instant checkoutTime = Instant.now() + and: + Patron patron = regularPatronWith(onHold) + and: + BookOnHold bookOnHold = bookOnHold(onHold.bookId, onHold.libraryBranchId) + when: + Either checkOut = patron.checkOut(bookOnHold, forNoOfDays(checkoutTime, checkoutDays)) + then: + checkOut.isRight() + checkOut.get().with { + assert it.libraryBranchId == bookOnHold.holdPlacedAt.libraryBranchId + assert it.bookId == bookOnHold.bookInformation.bookId.bookId + assert it.till == checkoutTime.plus(Duration.ofDays(checkoutDays)) + + } + where: + checkoutDays << (1 .. MAX_CHECKOUT_DURATION) + } + + def 'patron cannot checkout for 0 or less'() { + given: + Hold onHold = onHold() + and: + Patron patron = regularPatronWith(onHold) + when: + patron.checkOut(bookOnHold(onHold.bookId, onHold.libraryBranchId), forNoOfDays(checkoutDays)) + then: + thrown(IllegalArgumentException) + where: + checkoutDays << (-10 .. 0) + } + + +} diff --git a/src/test/groovy/io/pillopl/library/lending/patron/model/PatronFixture.java b/src/test/groovy/io/pillopl/library/lending/patron/model/PatronFixture.java new file mode 100644 index 0000000..ddedccf --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/patron/model/PatronFixture.java @@ -0,0 +1,156 @@ +package io.pillopl.library.lending.patron.model; + +import io.pillopl.library.catalogue.BookId; +import io.pillopl.library.lending.book.model.BookOnHold; +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId; +import io.vavr.collection.List; + +import java.util.*; + +import static io.pillopl.library.lending.book.model.BookFixture.anyBookId; +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch; +import static io.pillopl.library.lending.patron.model.PatronType.Regular; +import static io.pillopl.library.lending.patron.model.PatronType.Researcher; +import static io.pillopl.library.lending.patron.model.PlacingOnHoldPolicy.*; +import static java.util.stream.Collectors.toSet; +import static java.util.stream.IntStream.rangeClosed; + +public class PatronFixture { + + public static Patron regularPatron() { + return regularPatron(anyPatronId()); + } + + public static Patron regularPatronWithPolicy(PlacingOnHoldPolicy placingOnHoldPolicy) { + return patronWithPolicy(anyPatronId(), Regular, placingOnHoldPolicy); + } + + public static Patron researcherPatronWithPolicy(PlacingOnHoldPolicy placingOnHoldPolicy) { + return patronWithPolicy(anyPatronId(), Researcher, placingOnHoldPolicy); + } + + public static Patron regularPatronWithPolicy(PatronId patronId, PlacingOnHoldPolicy placingOnHoldPolicy) { + return patronWithPolicy(patronId, Regular, placingOnHoldPolicy); + } + + public static Patron researcherPatronWithPolicy(PatronId patronId, PlacingOnHoldPolicy placingOnHoldPolicy) { + return patronWithPolicy(patronId, Researcher, placingOnHoldPolicy); + } + + private static Patron patronWithPolicy(PatronId patronId, PatronType type, PlacingOnHoldPolicy placingOnHoldPolicy) { + return new Patron(patronInformation(patronId, type), + List.of(placingOnHoldPolicy), + new OverdueCheckouts(new HashMap<>()), + noHolds()); + } + + public static Patron regularPatron(PatronId patronId) { + return new Patron( + patronInformation(patronId, Regular), + List.of(onlyResearcherPatronsCanHoldRestrictedBooksPolicy), + new OverdueCheckouts(new HashMap<>()), + noHolds()); + } + + public static Patron researcherPatron(PatronId patronId) { + return new Patron( + patronInformation(patronId, Researcher), + List.of(onlyResearcherPatronsCanHoldRestrictedBooksPolicy), + new OverdueCheckouts(new HashMap<>()), + noHolds()); + } + + static PatronInformation patronInformation(PatronId id, PatronType type) { + return new PatronInformation(id, type); + } + + public static Patron regularPatronWithHolds(int numberOfHolds) { + PatronId patronId = anyPatronId(); + return new Patron( + patronInformation(patronId, Regular), + List.of(regularPatronMaximumNumberOfHoldsPolicy), + new OverdueCheckouts(new HashMap<>()), + booksOnHold(numberOfHolds)); + } + + static Patron regularPatronWith(Hold hold) { + PatronId patronId = anyPatronId(); + PatronHolds patronHolds = new PatronHolds(Collections.singleton(hold)); + return new Patron( + patronInformation(patronId, Regular), + allCurrentPolicies(), + new OverdueCheckouts(new HashMap<>()), + patronHolds); + } + + public static Patron regularPatronWith(BookOnHold bookOnHold, PatronId patronId) { + PatronHolds patronHolds = new PatronHolds(Collections.singleton(new Hold(bookOnHold.getBookId(), bookOnHold.getHoldPlacedAt()))); + return new Patron( + patronInformation(patronId, Regular), + allCurrentPolicies(), + new OverdueCheckouts(new HashMap<>()), + patronHolds); + } + + public static Hold onHold() { + return new Hold(anyBookId(), anyBranch()); + } + + static PatronHolds booksOnHold(int numberOfHolds) { + return new PatronHolds(rangeClosed(1, numberOfHolds) + .mapToObj(i -> new Hold(anyBookId(), anyBranch())) + .collect(toSet())); + } + + static Patron researcherPatronWithHolds(int numberOfHolds) { + PatronId patronId = anyPatronId(); + return new Patron( + patronInformation(patronId, Researcher), + List.of(regularPatronMaximumNumberOfHoldsPolicy), + new OverdueCheckouts(new HashMap<>()), + booksOnHold(numberOfHolds)); + } + + static Patron regularPatronWithOverdueCheckouts(LibraryBranchId libraryBranchId, Set overdueBooks) { + Map> overdueCheckouts = new HashMap<>(); + overdueCheckouts.put(libraryBranchId, overdueBooks); + return new Patron( + patronInformation(anyPatronId(), Regular), + List.of(overdueCheckoutsRejectionPolicy), + new OverdueCheckouts(overdueCheckouts), + noHolds()); + } + + static Patron regularPatronWith3_OverdueCheckoutsAt(LibraryBranchId libraryBranchId) { + Map> overdueCheckouts = new HashMap<>(); + overdueCheckouts.put(libraryBranchId, Set.of(anyBookId(), anyBookId(), anyBookId())); + return new Patron( + patronInformation(anyPatronId(), Regular), + List.of(overdueCheckoutsRejectionPolicy), + new OverdueCheckouts(overdueCheckouts), + noHolds()); + } + + public static PatronId anyPatronId() { + return patronId(UUID.randomUUID()); + } + + public static PatronId anyPatron() { + return patronId(UUID.randomUUID()); + } + + static PatronId patronId(UUID patronId) { + return new PatronId(patronId); + } + + static PatronHolds noHolds() { + return new PatronHolds(new HashSet<>()); + } + + + public static Patron regularPatronWithHold(BookOnHold bookOnHold) { + return regularPatronWith(new Hold(bookOnHold.getBookId(), bookOnHold.getHoldPlacedAt())); + } + + +} diff --git a/src/test/groovy/io/pillopl/library/lending/patron/model/PatronRequestingCloseEndedHoldTest.groovy b/src/test/groovy/io/pillopl/library/lending/patron/model/PatronRequestingCloseEndedHoldTest.groovy new file mode 100644 index 0000000..260ee2b --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/patron/model/PatronRequestingCloseEndedHoldTest.groovy @@ -0,0 +1,61 @@ +package io.pillopl.library.lending.patron.model + +import io.pillopl.library.lending.book.model.AvailableBook +import io.vavr.control.Either +import spock.lang.Specification + +import java.time.Duration +import java.time.Instant + +import static io.pillopl.library.lending.book.model.BookFixture.circulatingAvailableBook +import static PatronEvent.BookHoldFailed +import static PatronEvent.BookPlacedOnHold +import static PatronEvent.BookPlacedOnHoldEvents +import static PatronFixture.regularPatron +import static PatronFixture.regularPatronWithPolicy +import static PatronFixture.researcherPatronWithPolicy + +import static io.pillopl.library.lending.patron.model.PlacingOnHoldPolicy.onlyResearcherPatronsCanPlaceOpenEndedHolds + +class PatronRequestingCloseEndedHoldTest extends Specification { + + Instant from = Instant.MIN + + def 'any patron can request close ended hold'() { + given: + AvailableBook aBook = circulatingAvailableBook() + when: + Either hold = patron.placeOnHold(aBook, HoldDuration.closeEnded(from, NumberOfDays.of(3))) + then: + hold.isRight() + hold.get().with { + BookPlacedOnHold bookPlacedOnHold = it.bookPlacedOnHold + assert bookPlacedOnHold.libraryBranchId == aBook.libraryBranch.libraryBranchId + assert bookPlacedOnHold.bookId == aBook.bookInformation.bookId.bookId + assert bookPlacedOnHold.holdFrom == from + assert bookPlacedOnHold.holdTill == from.plus(Duration.ofDays(3)) + assert it.maximumNumberOhHoldsReached.isEmpty() + } + where: + patron << [regularPatronWithPolicy(onlyResearcherPatronsCanPlaceOpenEndedHolds), + researcherPatronWithPolicy(onlyResearcherPatronsCanPlaceOpenEndedHolds)] + + } + + def 'patron cannot hold a book for 0 or negative amount of days'() { + given: + AvailableBook aBook = circulatingAvailableBook() + and: + Patron patron = regularPatron() + when: + patron.placeOnHold(aBook, HoldDuration.closeEnded(from, NumberOfDays.of(days))) + then: + thrown(IllegalArgumentException) + + where: + days << (-10 .. 0) + + } + + +} diff --git a/src/test/groovy/io/pillopl/library/lending/patron/model/PatronRequestingLastPossibleHoldTest.groovy b/src/test/groovy/io/pillopl/library/lending/patron/model/PatronRequestingLastPossibleHoldTest.groovy new file mode 100644 index 0000000..6b88d04 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/patron/model/PatronRequestingLastPossibleHoldTest.groovy @@ -0,0 +1,31 @@ +package io.pillopl.library.lending.patron.model + +import io.pillopl.library.lending.book.model.AvailableBook +import io.vavr.control.Either +import spock.lang.Specification + +import static io.pillopl.library.lending.book.model.BookFixture.circulatingBook +import static io.pillopl.library.lending.patron.model.HoldDuration.closeEnded +import static PatronEvent.* +import static PatronFixture.regularPatronWithHolds + +class PatronRequestingLastPossibleHoldTest extends Specification { + + + def 'should announce that a regular patron places his last possible hold (4th)'() { + given: + AvailableBook book = circulatingBook() + when: + Either hold = regularPatronWithHolds(4).placeOnHold(book, closeEnded(3)) + then: + hold.isRight() + hold.get().with { + assert it.maximumNumberOhHoldsReached.isDefined() + MaximumNumberOhHoldsReached maximumNumberOhHoldsReached = it.maximumNumberOhHoldsReached.get() + assert maximumNumberOhHoldsReached.numberOfHolds == 5 + } + + } + + +} diff --git a/src/test/groovy/io/pillopl/library/lending/patron/model/PatronRequestingOpenEndedHoldTest.groovy b/src/test/groovy/io/pillopl/library/lending/patron/model/PatronRequestingOpenEndedHoldTest.groovy new file mode 100644 index 0000000..836ebc5 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/patron/model/PatronRequestingOpenEndedHoldTest.groovy @@ -0,0 +1,63 @@ +package io.pillopl.library.lending.patron.model + +import io.pillopl.library.lending.book.model.AvailableBook +import io.vavr.control.Either +import spock.lang.Specification + +import java.time.Instant + +import static io.pillopl.library.lending.book.model.BookFixture.circulatingAvailableBook +import static PatronEvent.BookHoldFailed +import static PatronEvent.BookPlacedOnHold +import static PatronEvent.BookPlacedOnHoldEvents +import static PatronFixture.* +import static io.pillopl.library.lending.patron.model.PlacingOnHoldPolicy.onlyResearcherPatronsCanPlaceOpenEndedHolds + +class PatronRequestingOpenEndedHoldTest extends Specification { + + Instant from = Instant.MIN + + def 'researcher patron can request close ended hold'() { + given: + AvailableBook aBook = circulatingAvailableBook() + and: + PatronId patronId = anyPatronId() + and: + Patron researcherPatron = researcherPatronWithPolicy(patronId, onlyResearcherPatronsCanPlaceOpenEndedHolds) + when: + Either hold = researcherPatron.placeOnHold(aBook, HoldDuration.openEnded(from)) + then: + hold.isRight() + hold.get().with { + BookPlacedOnHold bookPlacedOnHold = it.bookPlacedOnHold + assert bookPlacedOnHold.libraryBranchId == aBook.libraryBranch.libraryBranchId + assert bookPlacedOnHold.patronId == patronId.patronId + assert bookPlacedOnHold.bookId == aBook.bookInformation.bookId.bookId + assert bookPlacedOnHold.holdFrom == from + assert bookPlacedOnHold.holdTill == null + } + + } + + def 'regular patron cannot request open ended hold'() { + given: + AvailableBook aBook = circulatingAvailableBook() + and: + PatronId patronId = anyPatronId() + and: + Patron regularPatron = regularPatronWithPolicy(patronId, onlyResearcherPatronsCanPlaceOpenEndedHolds) + when: + Either hold = regularPatron.placeOnHold(aBook, HoldDuration.openEnded(from)) + then: + hold.isLeft() + hold.getLeft().with { + assert it.reason.contains("regular patron cannot place open ended holds") + assert it.libraryBranchId == aBook.libraryBranch.libraryBranchId + assert it.patronId == patronId.patronId + assert it.bookId == aBook.bookInformation.bookId.bookId + } + + } + + +} diff --git a/src/test/groovy/io/pillopl/library/lending/patron/model/RegularPatronRequestingCirculatingBooksTest.groovy b/src/test/groovy/io/pillopl/library/lending/patron/model/RegularPatronRequestingCirculatingBooksTest.groovy new file mode 100644 index 0000000..92de630 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/patron/model/RegularPatronRequestingCirculatingBooksTest.groovy @@ -0,0 +1,86 @@ +package io.pillopl.library.lending.patron.model + +import io.pillopl.library.lending.book.model.AvailableBook +import io.pillopl.library.lending.librarybranch.model.LibraryBranchId +import io.vavr.control.Either +import spock.lang.Specification + +import static io.pillopl.library.lending.book.model.BookFixture.* +import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch +import static io.pillopl.library.lending.patron.model.HoldDuration.closeEnded +import static PatronEvent.BookHoldFailed +import static PatronEvent.BookPlacedOnHoldEvents +import static PatronFixture.* +import static java.util.Collections.emptySet + +class RegularPatronRequestingCirculatingBooksTest extends Specification { + + + def 'a regular patron cannot place on hold more than 5 books'() { + when: + Either hold = regularPatronWithHolds(holds).placeOnHold(circulatingBook()) + then: + hold.isLeft() + BookHoldFailed e = hold.getLeft() + e.reason.contains("patron cannot hold more books") + where: + holds << [5, 6, 3000] + + } + + def 'a regular patron can place on hold book when he did not place on hold more than 4 books'() { + given: + AvailableBook book = circulatingBook() + when: + Either hold = regularPatronWithHolds(holds).placeOnHold(book, closeEnded(3)) + then: + hold.isRight() + where: + holds << [0, 1, 2, 3, 4] + + } + + def 'a regular patron cannot place on hold books anymore when he has at least two overdue checkouts'() { + given: + LibraryBranchId libraryBranchId = anyBranch() + when: + Either hold = + regularPatronWithOverdueCheckouts(libraryBranchId, books).placeOnHold(circulatingAvailableBookAt(libraryBranchId), closeEnded(3)) + then: + hold.isLeft() + BookHoldFailed e = hold.getLeft() + e.reason.contains("cannot place on hold when there are overdue checkouts") + where: + books << [ + [anyBookId(), anyBookId()] as Set, + [anyBookId(), anyBookId(), anyBookId()] as Set + ] + + } + + def 'a regular patron can place on hold books even though he has 2 overdue checkouts at different library'() { + given: + LibraryBranchId branch = anyBranch() + LibraryBranchId differentBranch = anyBranch() + when: + Either hold = + regularPatronWith3_OverdueCheckoutsAt(branch) + .placeOnHold(aBookAt(differentBranch), closeEnded(3)) + then: + hold.isRight() + } + + def 'a regular patron can place on hold books when he does not have 2 overdues'() { + given: + AvailableBook book = circulatingBook() + when: + Either hold = regularPatronWithOverdueCheckouts(anyBranch(), books).placeOnHold(book, closeEnded(3)) + then: + hold.isRight() + where: + books << [[anyBookId()] as Set, + emptySet()] + } + + +} diff --git a/src/test/groovy/io/pillopl/library/lending/patron/model/RegularPatronRequestingRestrictedBooksTest.groovy b/src/test/groovy/io/pillopl/library/lending/patron/model/RegularPatronRequestingRestrictedBooksTest.groovy new file mode 100644 index 0000000..817d907 --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/patron/model/RegularPatronRequestingRestrictedBooksTest.groovy @@ -0,0 +1,23 @@ +package io.pillopl.library.lending.patron.model + + +import io.vavr.control.Either +import spock.lang.Specification + +import static PatronEvent.BookHoldFailed +import static PatronEvent.BookPlacedOnHoldEvents +import static PatronFixture.regularPatron +import static io.pillopl.library.lending.book.model.BookFixture.restrictedBook + +class RegularPatronRequestingRestrictedBooksTest extends Specification { + + def 'a regular patron cannot place on hold restricted book'() { + when: + Either hold = regularPatron().placeOnHold(restrictedBook()) + then: + hold.isLeft() + BookHoldFailed e = hold.getLeft() + e.reason.contains("Regular patrons cannot hold restricted books") + } + +} diff --git a/src/test/groovy/io/pillopl/library/lending/patron/model/ResearcherPatronRequestingCirculatingBookTest.groovy b/src/test/groovy/io/pillopl/library/lending/patron/model/ResearcherPatronRequestingCirculatingBookTest.groovy new file mode 100644 index 0000000..f6f337f --- /dev/null +++ b/src/test/groovy/io/pillopl/library/lending/patron/model/ResearcherPatronRequestingCirculatingBookTest.groovy @@ -0,0 +1,23 @@ +package io.pillopl.library.lending.patron.model + + +import io.vavr.control.Either +import spock.lang.Specification + +import static PatronFixture.researcherPatronWithHolds +import static io.pillopl.library.lending.book.model.BookFixture.circulatingBook +import static PatronEvent.BookHoldFailed +import static PatronEvent.BookPlacedOnHoldEvents + +class ResearcherPatronRequestingCirculatingBookTest extends Specification { + + def 'a researcher patron can hold any number of circulating books'() { + when: + Either hold = researcherPatronWithHolds(holds).placeOnHold(circulatingBook()) + then: + hold.isRight() + where: + holds << [0, 1, 2, 3, 4, 5, 100000] + + } +} diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..0bb1726 --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,13 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + \ No newline at end of file