chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:16:17 +08:00
commit 9958711e73
224 changed files with 12721 additions and 0 deletions
+33
View File
@@ -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)
+26
View File
@@ -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/
BIN
View File
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
distributionUrl=https://repo1.maven.org/maven2/org/apache/maven/apache-maven/3.5.3/apache-maven-3.5.3-bin.zip
+7
View File
@@ -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"]
+12
View File
@@ -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"]
+21
View File
@@ -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.
+820
View File
@@ -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<DomainEvent> 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<AvailableBook> findAvailableBookBy(BookId bookId);
}
```
```java
@AllArgsConstructor
class BookDatabaseRepository implements FindAvailableBook {
private final JdbcTemplate jdbcTemplate;
@Override
public Option<AvailableBook> findAvailableBookBy(BookId bookId) {
return Match(findBy(bookId)).of(
Case($Some($(instanceOf(AvailableBook.class))), Option::of),
Case($(), Option::none)
);
}
Option<Book> findBy(BookId bookId) {
return findBookById(bookId)
.map(BookDatabaseEntity::toDomainModel);
}
private Option<BookDatabaseEntity> 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<BookHoldFailed, BookPlacedOnHoldEvents> placeOnHold(Book book) {
if (book.status == AVAILABLE) {
...
}
}
```
but we use the _type system_ and declare method of following signature
```java
public Either<BookHoldFailed, BookPlacedOnHoldEvents> 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<Book> findBy(BookId bookId) {
...
}
```
* When a method might return one of two possible values, we use the `Either` monad:
```java
Either<BookHoldFailed, BookPlacedOnHoldEvents> placeOnHold(AvailableBook book) {
...
}
```
* When an exception might occur, we use `Try` monad:
```java
Try<Result> 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<PatronDatabaseEntity, Long> {
@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<Book> findBy(BookId bookId) {
return findBookById(bookId)
.map(BookDatabaseEntity::toDomainModel);
}
private Option<BookDatabaseEntity> 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<BookHoldFailed, BookPlacedOnHoldEvents> 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> 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<DomainEvent> normalize() {
return List.<DomainEvent>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
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`ddd-by-examples/library`
- 原始仓库:https://github.com/ddd-by-examples/library
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+32
View File
@@ -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
+80
View File
@@ -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**.
Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

+239
View File
@@ -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)
+37
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 979 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 870 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

+1
View File
@@ -0,0 +1 @@
lombok.addLombokGeneratedAnnotation = true
+2
View File
@@ -0,0 +1,2 @@
GF_SECURITY_ADMIN_PASSWORD=password
GF_USERS_ALLOW_SIGN_UP=false
File diff suppressed because it is too large Load Diff
@@ -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
+11
View File
@@ -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'
Vendored Executable
+286
View File
@@ -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 "$@"
Vendored
+161
View File
@@ -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%
+263
View File
@@ -0,0 +1,263 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.0.M6</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>io.pillopl</groupId>
<artifactId>library</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>library</name>
<description>Domain-Driven Design example</description>
<properties>
<java.version>11</java.version>
<spring-hateoas.version>1.0.0.BUILD-SNAPSHOT</spring-hateoas.version>
</properties>
<repositories>
<repository>
<id>central</id>
<url>https://repo1.maven.org/maven2/</url>
</repository>
<repository>
<id>spring-libs-snapshot</id>
<url>https://repo.spring.io/snapshot</url>
</repository>
<repository>
<id>repository.spring.milestone</id>
<name>Spring Milestone Repository</name>
<url>https://repo.spring.io/milestone</url>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>central</id>
<url>https://repo1.maven.org/maven2/</url>
</pluginRepository>
<pluginRepository>
<id>spring-libs-snapshot</id>
<url>https://repo.spring.io/snapshot</url>
</pluginRepository>
<pluginRepository>
<id>repository.spring.release</id>
<name>Spring GA Repository</name>
<url>https://repo.spring.io/plugins-release/</url>
</pluginRepository>
</pluginRepositories>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.vavr</groupId>
<artifactId>vavr</artifactId>
<version>0.9.2</version>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.4</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit4</artifactId>
<version>0.9.3</version>
</dependency>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-spring</artifactId>
<version>1.2-groovy-2.5</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-core</artifactId>
<version>1.2-groovy-2.5</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.197</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.20</version>
<configuration>
<includes>
<include>**/*Test.java</include>
</includes>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>2.22.1</version>
<executions>
<execution>
<id>integration-tests</id>
<goals>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.gmavenplus</groupId>
<artifactId>gmavenplus-plugin</artifactId>
<version>1.6.2</version>
<executions>
<execution>
<goals>
<goal>compileTests</goal>
<goal>addTestSources</goal>
</goals>
</execution>
</executions>
<configuration>
<testSources>
<testSource>
<directory>src/test/groovy</directory>
<includes>
<include>**/*.groovy</include>
</includes>
</testSource>
<testSource>
<directory>src/integration-test/groovy</directory>
<includes>
<include>**/*.groovy</include>
</includes>
</testSource>
</testSources>
</configuration>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<version>1.12</version>
<executions>
<execution>
<id>add-integration-test-resources</id>
<phase>generate-test-resources</phase>
<goals>
<goal>add-test-resource</goal>
</goals>
<configuration>
<resources>
<resource>
<directory>src/integration-test/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.3</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>prepare-agent-integration</id>
<goals>
<goal>prepare-agent-integration</goal>
</goals>
</execution>
<execution>
<id>merge-results</id>
<phase>verify</phase>
<goals>
<goal>merge</goal>
</goals>
<configuration>
<fileSets>
<fileSet>
<directory>target</directory>
<includes>
<include>*.exec</include>
</includes>
</fileSet>
</fileSets>
<destFile>target/jacoco-all.exec</destFile>
</configuration>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
<configuration>
<dataFile>target/jacoco-all.exec</dataFile>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -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<Book> ddd = catalogueDatabase.findBy(book.bookIsbn)
then:
ddd.isDefined()
ddd.get() == book
}
def 'should not load not present book'() {
when:
Option<Book> ddd = catalogueDatabase.findBy(NON_PRESENT_ISBN)
then:
ddd.isEmpty()
}
def 'should save book instance'() {
when:
catalogueDatabase.saveNew(instanceOf(DDD, Restricted))
then:
noExceptionThrown()
}
}
@@ -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()
);
}
}
@@ -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<DomainEvent> eventList = Collections.synchronizedList(new ArrayList<>());
@Override
synchronized public void save(DomainEvent event) {
eventList.add(event);
}
@Override
synchronized public List<DomainEvent> toPublish() {
return List.ofAll(eventList);
}
@Override
synchronized public void published(List<DomainEvent> events) {
eventList.removeAll(events.asJava());
}
}
@@ -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
}
}
@@ -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 {
}
@@ -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<Book> loaded = bookEntityRepository.findBy(bookId)
Book book = loaded.getOrElseThrow({
new IllegalStateException("should have been persisted")
})
return book
}
}
@@ -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<Patron> loaded = patronRepo.findBy(patronId)
Patron patron = loaded.getOrElseThrow({
new IllegalStateException("should have been persisted")
})
return patron
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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<Book> 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
}
}
@@ -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())
}
}
@@ -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())
}
}
@@ -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<Patron> loaded = patronRepo.findBy(patronId)
Patron patron = loaded.getOrElseThrow({
new IllegalStateException("should have been persisted")
})
return patron
}
}
@@ -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<Patron> loaded = patronRepo.findBy(patronId)
Patron patron = loaded.getOrElseThrow({
new IllegalStateException("should have been persisted")
})
return patron
}
}
@@ -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<Patron> loaded = patronRepo.findBy(patronId)
Patron patron = loaded.getOrElseThrow({
new IllegalStateException("should have been persisted")
})
return patron
}
}
@@ -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())
}
}
@@ -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))));
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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;
}
@@ -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);
}
}

Some files were not shown because too many files have changed in this diff Show More