├── .editorconfig ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── behat.yml ├── bin ├── composer ├── install └── run_tests ├── bootstrap.php ├── cache └── .gitkeep ├── composer.json ├── composer.lock ├── docker-compose.yml ├── docker └── php │ ├── Dockerfile │ └── app.ini ├── phpstan-baseline.neon ├── phpstan.neon ├── phpunit.xml.dist ├── run_tests.sh ├── src └── TicketMill │ ├── Application │ ├── CancelReservation.php │ ├── MakeReservation.php │ ├── Notifications │ │ ├── Mailer.php │ │ └── SendMail.php │ └── PlanConcert.php │ ├── Domain │ └── Model │ │ ├── Common │ │ ├── EmailAddress.php │ │ └── EventRecording.php │ │ ├── Concert │ │ ├── Concert.php │ │ ├── ConcertId.php │ │ ├── ConcertRepository.php │ │ ├── ConcertWasPlanned.php │ │ ├── CouldNotFindConcert.php │ │ ├── CouldNotFindReservation.php │ │ ├── CouldNotRescheduleConcert.php │ │ ├── CouldNotReserveSeats.php │ │ ├── Reservation.php │ │ ├── ReservationId.php │ │ ├── ReservationWasCancelled.php │ │ ├── ReservationWasMade.php │ │ └── ScheduledDate.php │ │ └── Reservation │ │ ├── Reservation.php │ │ └── ReservationRepository.php │ └── Infrastructure │ ├── EventSubscriberSpy.php │ ├── InMemoryConcertRepository.php │ ├── InMemoryReservationRepository.php │ ├── MailerSpy.php │ └── ServiceContainer.php └── test ├── Acceptance ├── FeatureContext.php └── features │ └── reserving_seats.feature └── Unit ├── TicketMill ├── Domain │ └── Model │ │ ├── Common │ │ └── EmailAddressTest.php │ │ ├── Concert │ │ ├── ConcertIdTest.php │ │ ├── ConcertTest.php │ │ ├── ReservationIdTest.php │ │ └── ScheduledDateTest.php │ │ └── Reservation │ │ └── ReservationTest.php └── Infrastructure │ ├── InMemoryConcertRepositoryTest.php │ ├── InMemoryReservationRepositoryTest.php │ ├── MailerSpyTest.php │ └── ServiceContainerTest.php └── Utility ├── AggregateTestCase.php ├── AggregateTestCaseTest.php ├── ArrayContainsObjectOfClass.php └── Dummy.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | 8 | [*.{php,json,yaml,yml}] 9 | indent_style = space 10 | indent_size = 4 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /.idea/ 3 | /.phpunit.result.cache 4 | /cache/ 5 | /.env 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | 3 | services: 4 | - docker 5 | 6 | install: 7 | - bin/install 8 | 9 | script: 10 | - bin/run_tests 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Matthias Noback 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Aggregate design workshop 2 | 3 | ## Requirements 4 | 5 | - [Docker Engine](https://docs.docker.com/engine/installation/) 6 | - [Docker Compose](https://docs.docker.com/compose/install/) 7 | 8 | ## Getting started 9 | 10 | - Clone this repository and `cd` into it. 11 | - Run `bin/install`, which will pull the relevant Docker images and run `composer install` 12 | 13 | ## Usage 14 | 15 | - Run `bin/composer` to use Composer (e.g. `bin/composer require --dev [...]`). 16 | - Run `bin/run_tests` to run the tests. 17 | -------------------------------------------------------------------------------- /behat.yml: -------------------------------------------------------------------------------- 1 | default: 2 | suites: 3 | acceptance: 4 | paths: ["%paths.base%/test/Acceptance/features"] 5 | contexts: 6 | - Test\Acceptance\FeatureContext 7 | -------------------------------------------------------------------------------- /bin/composer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker compose run --rm composer --ignore-platform-reqs "$@" 4 | -------------------------------------------------------------------------------- /bin/install: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Copy user and group ID into .env file 4 | printf "HOST_UID=%s\nHOST_GID=%s\n" "$(id -u)" "$(id -g)" > .env 5 | 6 | # Pull Docker images 7 | docker compose pull 8 | 9 | # Run composer install 10 | docker compose run --rm composer install --ignore-platform-reqs --prefer-dist 11 | -------------------------------------------------------------------------------- /bin/run_tests: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker compose run --rm php ./run_tests.sh 4 | -------------------------------------------------------------------------------- /bootstrap.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | src/ 6 | 7 | 8 | 9 | 10 | ./test/Unit 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -eu 4 | 5 | vendor/bin/phpstan analyse 6 | vendor/bin/phpunit --testsuite unit 7 | #vendor/bin/behat --suite acceptance --tags "~@ignore" 8 | -------------------------------------------------------------------------------- /src/TicketMill/Application/CancelReservation.php: -------------------------------------------------------------------------------- 1 | concertRepository = $concertRepository; 21 | $this->eventDispatcher = $eventDispatcher; 22 | } 23 | 24 | public function cancelReservation(string $concertId, string $reservationId): void 25 | { 26 | $concert = $this->concertRepository->getById( 27 | ConcertId::fromString($concertId) 28 | ); 29 | 30 | $concert->cancelReservation(ReservationId::fromString($reservationId)); 31 | 32 | $this->concertRepository->save($concert); 33 | 34 | $this->eventDispatcher->dispatchAll($concert->releaseEvents()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/TicketMill/Application/MakeReservation.php: -------------------------------------------------------------------------------- 1 | concertRepository = $concertRepository; 22 | $this->eventDispatcher = $eventDispatcher; 23 | } 24 | 25 | public function makeReservation(string $concertId, string $emailAddress, int $numberOfSeats): ReservationId 26 | { 27 | $concert = $this->concertRepository->getById(ConcertId::fromString($concertId)); 28 | 29 | $reservationId = $this->concertRepository->nextReservationId(); 30 | 31 | $concert->makeReservation( 32 | $reservationId, 33 | EmailAddress::fromString($emailAddress), 34 | $numberOfSeats 35 | ); 36 | 37 | $this->concertRepository->save($concert); 38 | 39 | $this->eventDispatcher->dispatchAll($concert->releaseEvents()); 40 | 41 | return $reservationId; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/TicketMill/Application/Notifications/Mailer.php: -------------------------------------------------------------------------------- 1 | mailer = $mailer; 15 | } 16 | 17 | public function whenReservationWasMade(ReservationWasMade $reservationWasMade): void 18 | { 19 | $this->mailer->sendReservationWasMadeEmail( 20 | $reservationWasMade->emailAddress(), 21 | $reservationWasMade->numberOfSeats() 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/TicketMill/Application/PlanConcert.php: -------------------------------------------------------------------------------- 1 | concertRepository = $concertRepository; 22 | $this->eventDispatcher = $eventDispatcher; 23 | } 24 | 25 | public function plan( 26 | string $name, 27 | string $date, 28 | int $numberOfSeats 29 | ): ConcertId { 30 | $concert = Concert::plan( 31 | $this->concertRepository->nextIdentity(), 32 | $name, 33 | ScheduledDate::fromString($date), 34 | $numberOfSeats 35 | ); 36 | 37 | $this->concertRepository->save($concert); 38 | 39 | $this->eventDispatcher->dispatchAll($concert->releaseEvents()); 40 | 41 | return $concert->concertId(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/TicketMill/Domain/Model/Common/EmailAddress.php: -------------------------------------------------------------------------------- 1 | emailAddress = $emailAddress; 16 | } 17 | 18 | public static function fromString(string $emailAddress): EmailAddress 19 | { 20 | return new self($emailAddress); 21 | } 22 | 23 | public function asString(): string 24 | { 25 | return $this->emailAddress; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/TicketMill/Domain/Model/Common/EventRecording.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | private array $events = []; 14 | 15 | final private function recordThat(object $event): void 16 | { 17 | Assertion::isObject($event, 'An event should be an object'); 18 | 19 | $this->events[] = $event; 20 | } 21 | 22 | /** 23 | * @return array 24 | */ 25 | final public function releaseEvents(): array 26 | { 27 | $events = $this->events; 28 | $this->events = []; 29 | 30 | return $events; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/TicketMill/Domain/Model/Concert/Concert.php: -------------------------------------------------------------------------------- 1 | concertId = $concertId; 28 | 29 | return $instance; 30 | } 31 | 32 | public function concertId(): ConcertId 33 | { 34 | return $this->concertId; 35 | } 36 | 37 | public function reschedule(ScheduledDate $newDate): void 38 | { 39 | } 40 | 41 | public function cancel(): void 42 | { 43 | } 44 | 45 | public function makeReservation(ReservationId $reservationId, EmailAddress $emailAddress, 46 | int $numberOfSeats 47 | ): void { 48 | } 49 | 50 | public function cancelReservation(ReservationId $reservationId): void 51 | { 52 | } 53 | 54 | public function numberOfSeatsAvailable(): int 55 | { 56 | return 0; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/TicketMill/Domain/Model/Concert/ConcertId.php: -------------------------------------------------------------------------------- 1 | id = $id; 16 | } 17 | 18 | public static function fromString(string $id): ConcertId 19 | { 20 | return new self($id); 21 | } 22 | 23 | public function asString(): string 24 | { 25 | return $this->id; 26 | } 27 | 28 | public function equals(self $other): bool 29 | { 30 | return $this->id === $other->id; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/TicketMill/Domain/Model/Concert/ConcertRepository.php: -------------------------------------------------------------------------------- 1 | concertId = $concertId; 17 | $this->numberOfSeats = $numberOfSeats; 18 | } 19 | 20 | public function concertId(): ConcertId 21 | { 22 | return $this->concertId; 23 | } 24 | 25 | public function numberOfSeats(): int 26 | { 27 | return $this->numberOfSeats; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/TicketMill/Domain/Model/Concert/CouldNotFindConcert.php: -------------------------------------------------------------------------------- 1 | asString() 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/TicketMill/Domain/Model/Concert/CouldNotFindReservation.php: -------------------------------------------------------------------------------- 1 | asString() 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/TicketMill/Domain/Model/Concert/CouldNotRescheduleConcert.php: -------------------------------------------------------------------------------- 1 | reservationId = $reservationId; 20 | $this->emailAddress = $emailAddress; 21 | $this->numberOfSeats = $numberOfSeats; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/TicketMill/Domain/Model/Concert/ReservationId.php: -------------------------------------------------------------------------------- 1 | id = $id; 16 | } 17 | 18 | public static function fromString(string $id): self 19 | { 20 | return new self($id); 21 | } 22 | 23 | public function asString(): string 24 | { 25 | return $this->id; 26 | } 27 | 28 | public function equals(self $other): bool 29 | { 30 | return $this->id === $other->id; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/TicketMill/Domain/Model/Concert/ReservationWasCancelled.php: -------------------------------------------------------------------------------- 1 | reservationId = $reservationId; 19 | $this->concertId = $concertId; 20 | $this->numberOfSeats = $numberOfSeats; 21 | } 22 | 23 | public function reservationId(): ReservationId 24 | { 25 | return $this->reservationId; 26 | } 27 | 28 | public function concertId(): ConcertId 29 | { 30 | return $this->concertId; 31 | } 32 | 33 | public function numberOfSeats(): int 34 | { 35 | return $this->numberOfSeats; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/TicketMill/Domain/Model/Concert/ReservationWasMade.php: -------------------------------------------------------------------------------- 1 | reservationId = $reservationId; 22 | $this->concertId = $concertId; 23 | $this->emailAddress = $emailAddress; 24 | $this->numberOfSeats = $numberOfSeats; 25 | } 26 | 27 | public function concertId(): ConcertId 28 | { 29 | return $this->concertId; 30 | } 31 | 32 | public function emailAddress(): EmailAddress 33 | { 34 | return $this->emailAddress; 35 | } 36 | 37 | public function numberOfSeats(): int 38 | { 39 | return $this->numberOfSeats; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/TicketMill/Domain/Model/Concert/ScheduledDate.php: -------------------------------------------------------------------------------- 1 | date = $date; 21 | } 22 | 23 | public static function fromString(string $date): self 24 | { 25 | return new self($date); 26 | } 27 | 28 | public function asString(): string 29 | { 30 | return $this->date; 31 | } 32 | 33 | public function equals(ScheduledDate $other): bool 34 | { 35 | return $this->date === $other->date; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/TicketMill/Domain/Model/Reservation/Reservation.php: -------------------------------------------------------------------------------- 1 | reservationId = $reservationId; 36 | $instance->concertId = $concertId; 37 | $instance->emailAddress = $emailAddress; 38 | $instance->numberOfSeats = $numberOfSeats; 39 | 40 | return $instance; 41 | } 42 | 43 | public function reservationId(): ReservationId 44 | { 45 | return $this->reservationId; 46 | } 47 | 48 | public function cancel(): void 49 | { 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/TicketMill/Domain/Model/Reservation/ReservationRepository.php: -------------------------------------------------------------------------------- 1 | $dispatchedEvents 10 | */ 11 | private array $dispatchedEvents = []; 12 | 13 | public function __invoke(object $event): void 14 | { 15 | $this->dispatchedEvents[] = $event; 16 | } 17 | 18 | /** 19 | * @return array 20 | */ 21 | public function dispatchedEvents(): array 22 | { 23 | return $this->dispatchedEvents; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/TicketMill/Infrastructure/InMemoryConcertRepository.php: -------------------------------------------------------------------------------- 1 | & Concert[] 17 | */ 18 | private array $concerts = []; 19 | 20 | /** 21 | * @throws CouldNotFindConcert 22 | */ 23 | public function getById(ConcertId $concertId): Concert 24 | { 25 | if (!isset($this->concerts[$concertId->asString()])) { 26 | throw CouldNotFindConcert::withId($concertId); 27 | } 28 | 29 | return $this->concerts[$concertId->asString()]; 30 | } 31 | 32 | public function nextIdentity(): ConcertId 33 | { 34 | return ConcertId::fromString( 35 | Uuid::uuid4()->toString() 36 | ); 37 | } 38 | 39 | public function nextReservationId(): ReservationId 40 | { 41 | return ReservationId::fromString( 42 | Uuid::uuid4()->toString() 43 | ); 44 | } 45 | 46 | public function save(Concert $concert): void 47 | { 48 | $this->concerts[$concert->concertId()->asString()] = $concert; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/TicketMill/Infrastructure/InMemoryReservationRepository.php: -------------------------------------------------------------------------------- 1 | & Reservation[] 16 | */ 17 | private array $reservations = []; 18 | 19 | public function getById(ReservationId $reservationId): Reservation 20 | { 21 | if (!isset($this->reservations[$reservationId->asString()])) { 22 | throw CouldNotFindReservation::withId($reservationId); 23 | } 24 | 25 | return $this->reservations[$reservationId->asString()]; 26 | } 27 | 28 | public function nextIdentity(): ReservationId 29 | { 30 | return ReservationId::fromString( 31 | Uuid::uuid4()->toString() 32 | ); 33 | } 34 | 35 | public function save(Reservation $reservation): void 36 | { 37 | $this->reservations[$reservation->reservationId()->asString()] = $reservation; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/TicketMill/Infrastructure/MailerSpy.php: -------------------------------------------------------------------------------- 1 | > 15 | */ 16 | private array $emails = []; 17 | 18 | public function sendReservationWasMadeEmail(EmailAddress $emailAddress, int $numberOfSeats): void 19 | { 20 | $this->emails[$emailAddress->asString()][] = sprintf( 21 | '%d seats have been reserved', 22 | $numberOfSeats 23 | ); 24 | } 25 | 26 | public function assertEmailSent(string $emailAddress, string $messageContains): void 27 | { 28 | Assert::assertArrayHasKey($emailAddress, $this->emails, 'No mails were sent to ' . $emailAddress); 29 | foreach ($this->emails[$emailAddress] as $emailBody) { 30 | if (strpos($emailBody, $messageContains) !== false) { 31 | return; 32 | } 33 | } 34 | 35 | throw new ExpectationFailedException('Expected an email containing: ' . $messageContains); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/TicketMill/Infrastructure/ServiceContainer.php: -------------------------------------------------------------------------------- 1 | concertRepository(), $this->eventDispatcher()); 26 | } 27 | 28 | public function makeReservationService(): MakeReservation 29 | { 30 | return new MakeReservation($this->concertRepository(), $this->eventDispatcher()); 31 | } 32 | 33 | public function cancelReservation(): CancelReservation 34 | { 35 | return new CancelReservation($this->concertRepository(), $this->eventDispatcher()); 36 | } 37 | 38 | private function eventDispatcher(): EventDispatcher 39 | { 40 | if ($this->eventDispatcher === null) { 41 | $this->eventDispatcher = new EventDispatcher(); 42 | $this->eventDispatcher->subscribeToAllEvents( 43 | function (object $event): void { 44 | echo get_class($event) . "\n"; 45 | } 46 | ); 47 | $this->eventDispatcher->subscribeToAllEvents( 48 | $this->eventSubscriberSpy() 49 | ); 50 | $this->eventDispatcher->registerSubscriber( 51 | ReservationWasMade::class, 52 | [new SendMail($this->mailer()), 'whenReservationWasMade'] 53 | ); 54 | } 55 | 56 | return $this->eventDispatcher; 57 | } 58 | 59 | private function concertRepository(): ConcertRepository 60 | { 61 | if ($this->concertRepository === null) { 62 | $this->concertRepository = new InMemoryConcertRepository(); 63 | } 64 | 65 | return $this->concertRepository; 66 | } 67 | 68 | private function reservationRepository(): ReservationRepository 69 | { 70 | if ($this->reservationRepository === null) { 71 | $this->reservationRepository = new InMemoryReservationRepository(); 72 | } 73 | 74 | return $this->reservationRepository; 75 | } 76 | 77 | public function mailer(): MailerSpy 78 | { 79 | if ($this->mailer === null) { 80 | $this->mailer = new MailerSpy(); 81 | } 82 | 83 | return $this->mailer; 84 | } 85 | 86 | private function eventSubscriberSpy(): EventSubscriberSpy 87 | { 88 | if ($this->eventSubscriberSpy === null) { 89 | $this->eventSubscriberSpy = new EventSubscriberSpy(); 90 | } 91 | 92 | return $this->eventSubscriberSpy; 93 | } 94 | 95 | /** 96 | * @return array 97 | */ 98 | public function dispatchedEvents(): array 99 | { 100 | return $this->eventSubscriberSpy()->dispatchedEvents(); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /test/Acceptance/FeatureContext.php: -------------------------------------------------------------------------------- 1 | container = new ServiceContainer(); 36 | } 37 | 38 | /** 39 | * @Given a concert was planned with :numberOfSeats seats 40 | */ 41 | public function aConcertWasPlannedWithSeats(int $numberOfSeats): void 42 | { 43 | $this->concertId = $this->container->planConcertService()->plan( 44 | 'A concert', 45 | '2020-09-01 20:00', 46 | $numberOfSeats 47 | ); 48 | } 49 | 50 | /** 51 | * @When I make a reservation for :numberOfSeats seats and provide :emailAddress as my email address 52 | * @Then I should be able to make a reservation for :numberOfSeats seats 53 | * @Given :numberOfSeats seats have already been reserved 54 | */ 55 | public function iMakeAReservationForSeats(int $numberOfSeats, string $emailAddress = 'test@example.com'): void 56 | { 57 | Assertion::isInstanceOf($this->concertId, ConcertId::class); 58 | 59 | $this->emailAddress = $emailAddress; 60 | 61 | $this->reservationId = $this->container->makeReservationService()->makeReservation( 62 | $this->concertId->asString(), 63 | $emailAddress, 64 | $numberOfSeats 65 | ); 66 | } 67 | 68 | /** 69 | * @When I try to make a reservation for :numberOfSeats seats 70 | */ 71 | public function iTryToMakeAReservationForSeats(int $numberOfSeats): void 72 | { 73 | $this->shouldFail( 74 | function () use ($numberOfSeats) { 75 | Assertion::isInstanceOf($this->concertId, ConcertId::class); 76 | 77 | $this->container->makeReservationService()->makeReservation( 78 | $this->concertId->asString(), 79 | 'test@example.com', 80 | $numberOfSeats 81 | ); 82 | } 83 | ); 84 | } 85 | 86 | /** 87 | * @Then I should receive an email on the provided address saying: :message 88 | */ 89 | public function iShouldReceiveAnEmailSaying(string $messageContains): void 90 | { 91 | Assertion::string($this->emailAddress); 92 | 93 | $this->container->mailer()->assertEmailSent( 94 | $this->emailAddress, 95 | $messageContains 96 | ); 97 | } 98 | 99 | /** 100 | * @Then the system will show me an error message saying that :messageContains 101 | */ 102 | public function theSystemWillTellMeThat(string $messageContains): void 103 | { 104 | $this->assertCaughtExceptionMatches( 105 | Exception::class, 106 | $messageContains 107 | ); 108 | } 109 | 110 | /** 111 | * @When I cancel this reservation 112 | */ 113 | public function iCancelThisReservation(): void 114 | { 115 | Assertion::isInstanceOf($this->concertId, ConcertId::class); 116 | Assertion::isInstanceOf($this->reservationId, ReservationId::class); 117 | 118 | $this->container->cancelReservation()->cancelReservation( 119 | $this->concertId->asString(), 120 | $this->reservationId->asString() 121 | ); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /test/Acceptance/features/reserving_seats.feature: -------------------------------------------------------------------------------- 1 | Feature: 2 | 3 | Scenario: Reserving seats when plenty of seats are available 4 | Given a concert was planned with 10 seats 5 | When I make a reservation for 2 seats and provide "test@example.com" as my email address 6 | Then I should receive an email on the provided address saying: "2 seats have been reserved" 7 | 8 | Scenario: Reserving seats when not enough seats are available 9 | Given a concert was planned with 10 seats 10 | And 7 seats have already been reserved 11 | When I try to make a reservation for 6 seats 12 | Then the system will show me an error message saying that "Not enough seats were available" 13 | 14 | Scenario: Canceling a reservation makes the reserved seats available again 15 | Given a concert was planned with 5 seats 16 | And 3 seats have already been reserved 17 | When I cancel this reservation 18 | Then I should be able to make a reservation for 5 seats 19 | -------------------------------------------------------------------------------- /test/Unit/TicketMill/Domain/Model/Common/EmailAddressTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidArgumentException::class); 16 | 17 | EmailAddress::fromString('invalid-email-address'); 18 | } 19 | 20 | /** 21 | * @test 22 | */ 23 | public function it_can_be_created_from_a_string_and_converted_back_to_it(): void 24 | { 25 | $emailAddress = 'test@example.com'; 26 | 27 | self::assertEquals( 28 | $emailAddress, 29 | EmailAddress::fromString($emailAddress)->asString() 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/Unit/TicketMill/Domain/Model/Concert/ConcertIdTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidArgumentException::class); 16 | 17 | ConcertId::fromString('not-a-uuid'); 18 | } 19 | 20 | /** 21 | * @test 22 | */ 23 | public function it_can_be_created_from_a_string_and_converted_back_to_a_string(): void 24 | { 25 | $id = 'de939fac-7777-449a-9360-b66f3cc3daec'; 26 | 27 | self::assertEquals( 28 | $id, 29 | ConcertId::fromString($id)->asString() 30 | ); 31 | } 32 | 33 | /** 34 | * @test 35 | */ 36 | public function it_can_be_compared_to_another_id(): void 37 | { 38 | $id1 = ConcertId::fromString('de939fac-7777-449a-9360-b66f3cc3daec'); 39 | $id2 = ConcertId::fromString('49ee0aed-6d70-46e2-91e8-01a7488f21b9'); 40 | self::assertTrue( 41 | $id1->equals($id1) 42 | ); 43 | self::assertFalse( 44 | $id1->equals($id2) 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/Unit/TicketMill/Domain/Model/Concert/ConcertTest.php: -------------------------------------------------------------------------------- 1 | markTestIncomplete('Assignment 1'); 18 | 19 | $this->expectException(InvalidArgumentException::class); 20 | $this->expectExceptionMessage('name'); 21 | 22 | Concert::plan( 23 | $this->aConcertId(), 24 | $anEmptyName = '', 25 | $this->aDate(), 26 | $this->aNumberOfSeats() 27 | ); 28 | } 29 | 30 | /** 31 | * @test 32 | */ 33 | public function it_requires_a_positive_number_of_seats_available(): void 34 | { 35 | $this->markTestIncomplete('Assignment 1'); 36 | 37 | $this->expectException(InvalidArgumentException::class); 38 | $this->expectExceptionMessage('seats'); 39 | 40 | Concert::plan( 41 | $this->aConcertId(), 42 | $this->aName(), 43 | $this->aDate(), 44 | 0 45 | ); 46 | } 47 | 48 | /** 49 | * @test 50 | */ 51 | public function it_can_be_rescheduled(): void 52 | { 53 | $this->markTestIncomplete('Assignment 2'); 54 | 55 | $concert = $this->aConcertScheduledFor('2020-09-01 20:00'); 56 | 57 | // TODO: Verify that the concert has indeed been rescheduled 58 | $concert->reschedule($anotherDate = ScheduledDate::fromString('2021-10-01 20:00')); 59 | } 60 | 61 | /** 62 | * @test 63 | */ 64 | public function rescheduling_to_the_same_date_has_no_effect(): void 65 | { 66 | $this->markTestIncomplete('Assignment 2'); 67 | 68 | $date = '2021-10-01 20:00'; 69 | $concert = $this->aConcertScheduledFor( 70 | $date 71 | ); 72 | 73 | // TODO: Verify that nothing has changed 74 | $concert->reschedule($sameDate = ScheduledDate::fromString($date)); 75 | } 76 | 77 | /** 78 | * @test 79 | */ 80 | public function it_can_not_be_rescheduled_when_it_has_been_cancelled(): void 81 | { 82 | $this->markTestIncomplete('Assignment 3'); 83 | 84 | $aCancelledConcert = $this->aConcert(); 85 | $aCancelledConcert->cancel(); 86 | 87 | $this->expectException(CouldNotRescheduleConcert::class); 88 | $this->expectExceptionMessage('cancelled'); 89 | 90 | $aCancelledConcert->reschedule($anotherDate = ScheduledDate::fromString('2021-11-02 20:00')); 91 | } 92 | 93 | /** 94 | * @test 95 | */ 96 | public function it_can_be_cancelled(): void 97 | { 98 | $this->markTestIncomplete('Assignment 3'); 99 | 100 | $concert = $this->aConcert(); 101 | $concert->releaseEvents(); 102 | 103 | $concert->cancel(); 104 | 105 | $this->fail('TODO: Remove this statement; verify that the concert has indeed been cancelled'); 106 | } 107 | 108 | /** 109 | * @test 110 | */ 111 | public function cancelling_the_concert_twice_has_no_effect(): void 112 | { 113 | $this->markTestIncomplete('Assignment 3'); 114 | 115 | $concert = $this->aConcert(); 116 | $concert->cancel(); 117 | $concert->releaseEvents(); // the first time we cancel the concert, an event will be recorded 118 | 119 | $concert->cancel(); 120 | 121 | $this->fail('TODO: Remove this statement; verify that the concert has not been cancelled again'); 122 | } 123 | 124 | /** 125 | * @test 126 | */ 127 | public function you_can_reserve_seats_for_a_concert(): void 128 | { 129 | $this->markTestIncomplete('Assignment 4'); 130 | 131 | $concert = $this->concertWithNumberOfSeatsAvailable(10); 132 | 133 | $concert->makeReservation($this->aReservationId(), $this->anEmailAddress(), 3); 134 | 135 | self::assertArrayContainsObjectOfClass( 136 | ReservationWasMade::class, 137 | $concert->releaseEvents() 138 | ); 139 | self::assertEquals(7, $concert->numberOfSeatsAvailable()); 140 | } 141 | 142 | /** 143 | * @test 144 | */ 145 | public function you_can_not_reserve_more_seats_for_a_concert_than_there_are_seats(): void 146 | { 147 | $this->markTestIncomplete('Assignment 4'); 148 | 149 | $concert = $this->concertWithNumberOfSeatsAvailable(10); 150 | 151 | $this->expectException(CouldNotReserveSeats::class); 152 | $this->expectExceptionMessage('Not enough seats were available'); 153 | 154 | $concert->makeReservation($this->aReservationId(), $this->anEmailAddress(), $moreThanAvailable = 11); 155 | } 156 | 157 | /** 158 | * @test 159 | */ 160 | public function you_can_not_reserve_more_seats_for_a_concert_than_there_are_seats_available(): void 161 | { 162 | $this->markTestIncomplete('Assignment 4'); 163 | 164 | $concert = $this->concertWithNumberOfSeatsAvailable(10); 165 | $concert->makeReservation($this->aReservationId(), $this->anEmailAddress(), 7); 166 | self::assertEquals(3, $concert->numberOfSeatsAvailable()); 167 | 168 | $this->expectException(CouldNotReserveSeats::class); 169 | $this->expectExceptionMessage('Not enough seats were available'); 170 | 171 | $concert->makeReservation($this->anotherReservationId(), $this->anEmailAddress(), $moreThanAvailable = 6); 172 | } 173 | 174 | /** 175 | * @test 176 | */ 177 | public function cancelling_a_reservation_makes_its_seats_available_again(): void 178 | { 179 | $this->markTestIncomplete('Assignment 4'); 180 | 181 | $concert = $this->concertWithNumberOfSeatsAvailable(10); 182 | $concert->makeReservation($this->aReservationId(), $this->anEmailAddress(), 4); 183 | $reservationId = $this->anotherReservationId(); 184 | $concert->makeReservation($reservationId, $this->anEmailAddress(), 3); 185 | 186 | $concert->cancelReservation($reservationId); 187 | 188 | self::assertArrayContainsObjectOfClass( 189 | ReservationWasCancelled::class, 190 | $concert->releaseEvents() 191 | ); 192 | 193 | self::assertEquals(10 - 4, $concert->numberOfSeatsAvailable()); 194 | } 195 | 196 | /** 197 | * @test 198 | */ 199 | public function it_will_fail_to_cancel_a_reservation_if_the_reservation_does_not_exist(): void 200 | { 201 | $this->markTestIncomplete('Assignment 4'); 202 | 203 | $concert = $this->aConcert(); 204 | 205 | $this->expectException(RuntimeException::class); 206 | $this->expectExceptionMessage('Could not find'); 207 | 208 | // No reservations have been made, so reservation 1 does not exist 209 | $concert->cancelReservation($this->aReservationId()); 210 | } 211 | 212 | private function aConcertId(): ConcertId 213 | { 214 | return ConcertId::fromString('de939fac-7777-449a-9360-b66f3cc3daec'); 215 | } 216 | 217 | private function aName(): string 218 | { 219 | return 'Name'; 220 | } 221 | 222 | private function concertWithNumberOfSeatsAvailable(int $numberOfSeats): Concert 223 | { 224 | return Concert::plan( 225 | $this->aConcertId(), 226 | $this->aName(), 227 | $this->aDate(), 228 | $numberOfSeats 229 | ); 230 | } 231 | 232 | private function anEmailAddress(): EmailAddress 233 | { 234 | return EmailAddress::fromString('test@example.com'); 235 | } 236 | 237 | private function aDate(): ScheduledDate 238 | { 239 | return ScheduledDate::fromString('2021-10-01 20:00'); 240 | } 241 | 242 | private function aConcertScheduledFor(string $date): Concert 243 | { 244 | return Concert::plan( 245 | $this->aConcertId(), 246 | $this->aName(), 247 | ScheduledDate::fromString($date), 248 | $this->aNumberOfSeats() 249 | ); 250 | } 251 | 252 | private function aConcert(): Concert 253 | { 254 | return Concert::plan( 255 | $this->aConcertId(), 256 | $this->aName(), 257 | $this->aDate(), 258 | $this->aNumberOfSeats() 259 | ); 260 | } 261 | 262 | private function aNumberOfSeats(): int 263 | { 264 | return 10; 265 | } 266 | 267 | private function aReservationId(): ReservationId 268 | { 269 | return ReservationId::fromString('48ebab9c-1be8-42e5-b87a-6adda38d9116'); 270 | } 271 | 272 | private function anotherReservationId(): ReservationId 273 | { 274 | return ReservationId::fromString('dc5998cb-34fa-4589-a4a1-33f3f76a812a'); 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /test/Unit/TicketMill/Domain/Model/Concert/ReservationIdTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidArgumentException::class); 16 | 17 | ReservationId::fromString('not-a-uuid'); 18 | } 19 | 20 | /** 21 | * @test 22 | */ 23 | public function it_can_be_created_from_a_string_and_converted_back_to_a_string(): void 24 | { 25 | $id = 'de939fac-7777-449a-9360-b66f3cc3daec'; 26 | 27 | self::assertEquals( 28 | $id, 29 | ReservationId::fromString($id)->asString() 30 | ); 31 | } 32 | 33 | /** 34 | * @test 35 | */ 36 | public function it_can_be_compared_to_another_id(): void 37 | { 38 | $id1 = ReservationId::fromString('de939fac-7777-449a-9360-b66f3cc3daec'); 39 | $id2 = ReservationId::fromString('49ee0aed-6d70-46e2-91e8-01a7488f21b9'); 40 | self::assertTrue( 41 | $id1->equals($id1) 42 | ); 43 | self::assertFalse( 44 | $id1->equals($id2) 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/Unit/TicketMill/Domain/Model/Concert/ScheduledDateTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidArgumentException::class); 16 | 17 | ScheduledDate::fromString('incorrect-format'); 18 | } 19 | 20 | /** 21 | * @test 22 | */ 23 | public function it_can_be_created_from_a_string_and_converted_back_to_it(): void 24 | { 25 | $date = '2020-09-01 20:00'; 26 | 27 | self::assertEquals( 28 | $date, 29 | ScheduledDate::fromString($date)->asString() 30 | ); 31 | } 32 | 33 | /** 34 | * @test 35 | */ 36 | public function it_can_be_compared_with_other_instances(): void 37 | { 38 | self::assertTrue( 39 | ScheduledDate::fromString('2020-09-01 20:00')->equals( 40 | ScheduledDate::fromString($sameDate = '2020-09-01 20:00') 41 | ) 42 | ); 43 | 44 | self::assertFalse( 45 | ScheduledDate::fromString('2020-09-01 20:00')->equals( 46 | ScheduledDate::fromString($otherDate = '2021-10-01 20:00') 47 | ) 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/Unit/TicketMill/Domain/Model/Reservation/ReservationTest.php: -------------------------------------------------------------------------------- 1 | markTestIncomplete('Assignment 5'); 20 | 21 | $reservation = Reservation::make( 22 | ReservationId::fromString('cd2514c8-ac19-4e1c-9a8c-1204782233d9'), 23 | ConcertId::fromString('ca1f570f-e314-4199-9abb-74177b6da280'), 24 | EmailAddress::fromString('test@example.com'), 25 | 3 26 | ); 27 | 28 | self::assertArrayContainsObjectOfClass( 29 | ReservationWasMade::class, 30 | $reservation->releaseEvents() 31 | ); 32 | } 33 | 34 | /** 35 | * @test 36 | */ 37 | public function it_can_be_cancelled(): void 38 | { 39 | $this->markTestIncomplete('Assignment 5'); 40 | 41 | $reservation = Reservation::make( 42 | ReservationId::fromString('cd2514c8-ac19-4e1c-9a8c-1204782233d9'), 43 | ConcertId::fromString('ca1f570f-e314-4199-9abb-74177b6da280'), 44 | EmailAddress::fromString('test@example.com'), 45 | 3 46 | ); 47 | 48 | $reservation->cancel(); 49 | 50 | self::assertArrayContainsObjectOfClass( 51 | ReservationWasCancelled::class, 52 | $reservation->releaseEvents() 53 | ); 54 | } 55 | 56 | /** 57 | * @test 58 | */ 59 | public function cancelling_it_twice_has_no_effect(): void 60 | { 61 | $this->markTestIncomplete('Assignment 5'); 62 | 63 | $cancelledReservation = Reservation::make( 64 | ReservationId::fromString('cd2514c8-ac19-4e1c-9a8c-1204782233d9'), 65 | ConcertId::fromString('ca1f570f-e314-4199-9abb-74177b6da280'), 66 | EmailAddress::fromString('test@example.com'), 67 | 3 68 | ); 69 | $cancelledReservation->cancel(); 70 | $cancelledReservation->releaseEvents(); 71 | 72 | $cancelledReservation->cancel(); 73 | 74 | self::assertArrayContainsObjectOfClass( 75 | ReservationWasCancelled::class, 76 | $cancelledReservation->releaseEvents(), 77 | 0 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /test/Unit/TicketMill/Infrastructure/InMemoryConcertRepositoryTest.php: -------------------------------------------------------------------------------- 1 | nextIdentity(); 20 | $concert = $this->createConcert($concertId); 21 | 22 | $repository->save($concert); 23 | 24 | $fromRepository = $repository->getById($concertId); 25 | 26 | self::assertEquals($concert, $fromRepository); 27 | } 28 | 29 | private function createConcert(ConcertId $concertId): Concert 30 | { 31 | return Concert::plan( 32 | $concertId, 33 | 'Name', 34 | ScheduledDate::fromString('2021-10-01 20:00'), 35 | 10 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test/Unit/TicketMill/Infrastructure/InMemoryReservationRepositoryTest.php: -------------------------------------------------------------------------------- 1 | nextIdentity(); 21 | $reservation = $this->createReservation($reservationId); 22 | 23 | $repository->save($reservation); 24 | 25 | $fromRepository = $repository->getById($reservationId); 26 | 27 | self::assertEquals($reservation, $fromRepository); 28 | } 29 | 30 | private function createReservation(ReservationId $reservationId): Reservation 31 | { 32 | return Reservation::make( 33 | $reservationId, 34 | ConcertId::fromString('de939fac-7777-449a-9360-b66f3cc3daec'), 35 | EmailAddress::fromString('test@example.com'), 36 | 3 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/Unit/TicketMill/Infrastructure/MailerSpyTest.php: -------------------------------------------------------------------------------- 1 | mailer = new MailerSpy(); 16 | } 17 | 18 | /** 19 | * @test 20 | */ 21 | public function it_fails_if_no_email_was_sent(): void 22 | { 23 | $this->expectException(ExpectationFailedException::class); 24 | 25 | $this->mailer->assertEmailSent('test@example.com', 'body contains'); 26 | } 27 | 28 | /** 29 | * @test 30 | */ 31 | public function it_fails_if_an_email_was_sent_but_it_does_not_contain_the_expected_message(): void 32 | { 33 | $emailAddress = $this->anEmailAddress(); 34 | $this->mailer->sendReservationWasMadeEmail($emailAddress, $this->aNumberOfSeats()); 35 | 36 | $this->expectException(ExpectationFailedException::class); 37 | 38 | $this->mailer->assertEmailSent($emailAddress->asString(), 'not contained in the body'); 39 | } 40 | 41 | /** 42 | * @test 43 | */ 44 | public function it_succeeds_if_an_email_was_sent_and_it_contains_the_expected_message(): void 45 | { 46 | $emailAddress = $this->anEmailAddress(); 47 | $numberOfSeats = $this->aNumberOfSeats(); 48 | 49 | $this->mailer->sendReservationWasMadeEmail($emailAddress, $numberOfSeats); 50 | 51 | $this->mailer->assertEmailSent( 52 | $emailAddress->asString(), 53 | $numberOfSeats . ' seats have been reserved' 54 | ); 55 | 56 | // we're happy if it didn't fail 57 | $this->addToAssertionCount(1); 58 | } 59 | 60 | private function anEmailAddress(): EmailAddress 61 | { 62 | return EmailAddress::fromString('test@example.com'); 63 | } 64 | 65 | private function aNumberOfSeats(): int 66 | { 67 | return 10; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /test/Unit/TicketMill/Infrastructure/ServiceContainerTest.php: -------------------------------------------------------------------------------- 1 | getMethods() as $method) { 19 | if (!$method->isPublic()) { 20 | continue; 21 | } 22 | 23 | $method->invoke($container); 24 | $this->addToAssertionCount(1); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/Unit/Utility/AggregateTestCase.php: -------------------------------------------------------------------------------- 1 | expectException(ExpectationFailedException::class); 17 | 18 | self::assertArrayContainsObjectOfClass( 19 | Dummy::class, 20 | [] 21 | ); 22 | } 23 | 24 | /** 25 | * @test 26 | */ 27 | public function it_fails_if_the_array_does_not_contain_an_object_of_the_expected_type(): void 28 | { 29 | $this->expectException(ExpectationFailedException::class); 30 | 31 | self::assertArrayContainsObjectOfClass( 32 | Dummy::class, 33 | [$someOtherTypeOfObject = new stdClass()] 34 | ); 35 | } 36 | 37 | /** 38 | * @test 39 | */ 40 | public function it_succeeds_if_the_array_consists_of_an_object_of_the_expected_type(): void 41 | { 42 | self::assertArrayContainsObjectOfClass( 43 | Dummy::class, 44 | [new Dummy()] 45 | ); 46 | } 47 | 48 | /** 49 | * @test 50 | */ 51 | public function it_fails_if_the_array_does_not_contain_the_expected_number_of_objects_of_the_given_type(): void 52 | { 53 | $this->expectException(ExpectationFailedException::class); 54 | 55 | self::assertArrayContainsObjectOfClass( 56 | Dummy::class, 57 | [$oneObject = new Dummy()], 58 | 2 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /test/Unit/Utility/ArrayContainsObjectOfClass.php: -------------------------------------------------------------------------------- 1 | expectedClass = $expectedClass; 17 | $this->expectedNumberOfObjects = $expectedNumberOfObjects; 18 | } 19 | 20 | protected function matches($other): bool 21 | { 22 | Assertion::isArray($other); 23 | 24 | $countedNumberOfObjects = 0; 25 | 26 | foreach ($other as $element) { 27 | if (get_class($element) === $this->expectedClass) { 28 | $countedNumberOfObjects++; 29 | } 30 | } 31 | 32 | if ($countedNumberOfObjects === $this->expectedNumberOfObjects) { 33 | return true; 34 | } 35 | 36 | return false; 37 | } 38 | 39 | public function toString(): string 40 | { 41 | return sprintf( 42 | 'contains %d instance(s) of type %s', 43 | $this->expectedNumberOfObjects, 44 | $this->expectedClass 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/Unit/Utility/Dummy.php: -------------------------------------------------------------------------------- 1 |