diff --git a/build.sbt b/build.sbt index 8fe4ad92..54092a42 100644 --- a/build.sbt +++ b/build.sbt @@ -29,7 +29,7 @@ inThisBuild( scalaVersion := "2.13.7", Global / onChangedBuildSource := ReloadOnSourceChanges, PB.protocVersion := "3.17.3", // works on Apple Silicon, - versionPolicyIntention := Compatibility.None, + versionPolicyIntention := Compatibility.BinaryAndSourceCompatible, versionPolicyIgnoredInternalDependencyVersions := Some( "^\\d+\\.\\d+\\.\\d+\\+\\d+".r ) // Support for versions generated by sbt-dynver diff --git a/documentation/src/main/paradox/example.md b/documentation/src/main/paradox/example.md index e19ec05b..52b56c9c 100644 --- a/documentation/src/main/paradox/example.md +++ b/documentation/src/main/paradox/example.md @@ -55,4 +55,8 @@ Unit testing for entity algebra implementation, event handling and effector is e @@snip [BookingEffectorSuite](/example/src/test/scala/endless/example/logic/BookingEffectorSuite.scala) { #example } -`CommandProtocol` is more effectively covered via component tests as it is mostly about serialization and switchboard boilerplate. \ No newline at end of file +Command protocol can be also easily be covered with synchronous round-trip tests: + +@@snip [BookingCommandProtocolSuite](/example/src/test/scala/endless/example/protocol/BookingCommandProtocolSuite.scala) { #example } + +Component and integration tests using akka testkit are also advisable and work as usual, see @github[ExampleAppSuite](/example/src/test/scala/endless/example/ExampleAppSuite.scala). \ No newline at end of file diff --git a/documentation/src/main/paradox/protocol.md b/documentation/src/main/paradox/protocol.md index f119ac38..884b3ffb 100644 --- a/documentation/src/main/paradox/protocol.md +++ b/documentation/src/main/paradox/protocol.md @@ -16,4 +16,8 @@ In other words, `client` materializes algebra invocations into concrete serializ @@@ note { .info title="Explicit or implicit representations" } `CommandProtocol` is the entry point for implementations to map algebra entries to concrete commands and replies. We tend to prefer explicit materialization for migration safety but nothing prevents protocol implementers to opt for automatic serialization via macros. We provide helpers for definition of binary protocols in `endless-scodec-helpers` and JSON protocols in `endless-circe-helpers`. -@@@ \ No newline at end of file +@@@ + +@@@ note { .tip title="Testing" } +Command protocols can be tested in isolation via synchronous round-trip exercise of the journey _client invocation -> command materialization -> command encoding -> command decoding -> behavior invocation -> reply materialization -> reply encoding -> reply decoding_. See @github[BookingCommandProtocolSuite](/example/src/test/scala/endless/example/protocol/BookingCommandProtocolSuite.scala) for an example. +@@@ diff --git a/example/src/test/scala/endless/example/logic/Generators.scala b/example/src/test/scala/endless/example/logic/Generators.scala index 13ef0957..c36ed9db 100644 --- a/example/src/test/scala/endless/example/logic/Generators.scala +++ b/example/src/test/scala/endless/example/logic/Generators.scala @@ -1,5 +1,6 @@ package endless.example.logic +import endless.example.algebra.BookingAlg.{BookingAlreadyExists, BookingUnknown} import endless.example.data.Booking import endless.example.data.Booking.{BookingID, LatLon} import org.scalacheck.{Arbitrary, Gen} @@ -16,6 +17,14 @@ trait Generators { destination <- latLonGen passengerCount <- Gen.posNum[Int] } yield Booking(id, origin, destination, passengerCount) + implicit val bookingAlreadyExists: Gen[BookingAlreadyExists] = + Gen.uuid.map(uuid => BookingAlreadyExists(BookingID(uuid))) + implicit val bookingUnknown: Gen[BookingUnknown.type] = Gen.const(BookingUnknown) + implicit val arbBooking: Arbitrary[Booking] = Arbitrary(bookingGen) implicit val arbLatLon: Arbitrary[LatLon] = Arbitrary(latLonGen) + implicit val arbBookingAlreadyExists: Arbitrary[BookingAlreadyExists] = Arbitrary( + bookingAlreadyExists + ) + implicit val arbBookingUnknown: Arbitrary[BookingUnknown.type] = Arbitrary(bookingUnknown) } diff --git a/example/src/test/scala/endless/example/protocol/BookingCommandProtocolSuite.scala b/example/src/test/scala/endless/example/protocol/BookingCommandProtocolSuite.scala new file mode 100644 index 00000000..5884e940 --- /dev/null +++ b/example/src/test/scala/endless/example/protocol/BookingCommandProtocolSuite.scala @@ -0,0 +1,145 @@ +package endless.example.protocol + +import cats.Id +import endless.\/ +import endless.example.algebra.BookingAlg +import endless.example.data.Booking +import endless.example.logic.Generators +import org.scalacheck.Prop.forAll +import cats.syntax.functor._ + +//#example +class BookingCommandProtocolSuite extends munit.ScalaCheckSuite with Generators { + val protocol = new BookingCommandProtocol + + test("place booking") { + forAll { (booking: Booking, reply: BookingAlg.BookingAlreadyExists \/ Unit) => + val outgoingCommand = protocol.client.place( + booking.id, + booking.passengerCount, + booking.origin, + booking.destination + ) + val incomingCommand = protocol.server[Id].decode(outgoingCommand.payload) + val encodedReply = incomingCommand + .runWith(new TestBookingAlg { + override def place( + bookingID: Booking.BookingID, + passengerCount: Int, + origin: Booking.LatLon, + destination: Booking.LatLon + ): Id[BookingAlg.BookingAlreadyExists \/ Unit] = reply + }) + .map(incomingCommand.replyEncoder.encode(_)) + assertEquals(outgoingCommand.replyDecoder.decode(encodedReply), reply) + } + } +//#example + + test("get booking") { + forAll { (reply: BookingAlg.BookingUnknown.type \/ Booking) => + val outgoingCommand = protocol.client.get + val incomingCommand = protocol.server[Id].decode(outgoingCommand.payload) + val encodedReply = incomingCommand + .runWith(new TestBookingAlg { + override def get: Id[BookingAlg.BookingUnknown.type \/ Booking] = reply + }) + .map(incomingCommand.replyEncoder.encode(_)) + assertEquals(outgoingCommand.replyDecoder.decode(encodedReply), reply) + } + } + + test("change origin") { + forAll { (newOrigin: Booking.LatLon, reply: BookingAlg.BookingUnknown.type \/ Unit) => + val outgoingCommand = protocol.client.changeOrigin(newOrigin) + val incomingCommand = protocol.server[Id].decode(outgoingCommand.payload) + val encodedReply = incomingCommand + .runWith(new TestBookingAlg { + override def changeOrigin( + origin: Booking.LatLon + ): Id[BookingAlg.BookingUnknown.type \/ Unit] = reply + }) + .map(incomingCommand.replyEncoder.encode(_)) + assertEquals(outgoingCommand.replyDecoder.decode(encodedReply), reply) + } + } + + test("change destination") { + forAll { (newDestination: Booking.LatLon, reply: BookingAlg.BookingUnknown.type \/ Unit) => + val outgoingCommand = protocol.client.changeDestination(newDestination) + val incomingCommand = protocol.server[Id].decode(outgoingCommand.payload) + val encodedReply = incomingCommand + .runWith(new TestBookingAlg { + override def changeDestination( + destination: Booking.LatLon + ): Id[BookingAlg.BookingUnknown.type \/ Unit] = reply + }) + .map(incomingCommand.replyEncoder.encode(_)) + assertEquals(outgoingCommand.replyDecoder.decode(encodedReply), reply) + } + } + + test("change origin and destination") { + forAll { + ( + newOrigin: Booking.LatLon, + newDestination: Booking.LatLon, + reply: BookingAlg.BookingUnknown.type \/ Unit + ) => + val outgoingCommand = protocol.client.changeOriginAndDestination(newOrigin, newDestination) + val incomingCommand = protocol.server[Id].decode(outgoingCommand.payload) + val encodedReply = incomingCommand + .runWith(new TestBookingAlg { + override def changeOriginAndDestination( + origin: Booking.LatLon, + destination: Booking.LatLon + ): Id[BookingAlg.BookingUnknown.type \/ Unit] = reply + }) + .map(incomingCommand.replyEncoder.encode(_)) + assertEquals(outgoingCommand.replyDecoder.decode(encodedReply), reply) + } + } + + test("cancel") { + forAll { (reply: BookingAlg.BookingUnknown.type \/ Unit) => + val outgoingCommand = protocol.client.cancel + val incomingCommand = protocol.server[Id].decode(outgoingCommand.payload) + val encodedReply = incomingCommand + .runWith(new TestBookingAlg { + override def cancel: Id[BookingAlg.BookingUnknown.type \/ Unit] = reply + }) + .map(incomingCommand.replyEncoder.encode(_)) + assertEquals(outgoingCommand.replyDecoder.decode(encodedReply), reply) + } + } + + trait TestBookingAlg extends BookingAlg[Id] { + def place( + bookingID: Booking.BookingID, + passengerCount: Int, + origin: Booking.LatLon, + destination: Booking.LatLon + ): Id[BookingAlg.BookingAlreadyExists \/ Unit] = throw new RuntimeException( + "not supposed to be called" + ) + def get: Id[BookingAlg.BookingUnknown.type \/ Booking] = throw new RuntimeException( + "not supposed to be called" + ) + def changeOrigin(newOrigin: Booking.LatLon): Id[BookingAlg.BookingUnknown.type \/ Unit] = + throw new RuntimeException("not supposed to be called") + def changeDestination( + newDestination: Booking.LatLon + ): Id[BookingAlg.BookingUnknown.type \/ Unit] = throw new RuntimeException( + "not supposed to be called" + ) + def changeOriginAndDestination( + newOrigin: Booking.LatLon, + newDestination: Booking.LatLon + ): Id[BookingAlg.BookingUnknown.type \/ Unit] = throw new RuntimeException( + "not supposed to be called" + ) + def cancel: Id[BookingAlg.BookingUnknown.type \/ Unit] = throw new RuntimeException( + "not supposed to be called" + ) + } +}