diff --git a/build.sbt b/build.sbt index a3a9216..96605a3 100644 --- a/build.sbt +++ b/build.sbt @@ -51,7 +51,7 @@ ThisBuild / scalacOptions ++= Seq( ThisBuild / resolvers += "Typesafe Releases" at "https://repo.typesafe.com/typesafe/releases/" ThisBuild / libraryDependencies ++= Seq( - "org.scalatest" %% "scalatest" % "3.0.8" % Test + "org.scalatest" %% "scalatest" % "3.1.1" % Test ) lazy val fsm = project.in(file(".")) @@ -67,3 +67,9 @@ lazy val oo = project lazy val fp = project .settings(name := "phantom-state-machine-fp") + .settings(scalaVersion := "0.22.0-RC1") + .settings(scalacOptions in ThisBuild := Seq( + "-deprecation", + "-feature", + "-unchecked") + ) diff --git a/fp/src/main/scala/eu/thoughtway/fsm/fp/domain/model/Organisation.scala b/fp/src/main/scala/eu/thoughtway/fsm/fp/domain/model/Organisation.scala index 7c370c2..202124a 100644 --- a/fp/src/main/scala/eu/thoughtway/fsm/fp/domain/model/Organisation.scala +++ b/fp/src/main/scala/eu/thoughtway/fsm/fp/domain/model/Organisation.scala @@ -19,12 +19,16 @@ object Organisation { sealed trait Enabled extends Status case object Enabled extends Enabled + sealed trait Suspended extends Status + case object Suspended extends Suspended + sealed trait Disabled extends Status case object Disabled extends Disabled - type OrganisationDefined = Organisation[Defined] - type OrganisationEnabled = Organisation[Enabled] - type OrganisationDisabled = Organisation[Disabled] + type OrganisationDefined = Organisation[Defined] + type OrganisationEnabled = Organisation[Enabled] + type OrganisationSuspended = Organisation[Suspended] + type OrganisationDisabled = Organisation[Disabled] def apply(id: String): OrganisationDefined = new Organisation(id, LocalDateTime.now(), Defined, 0) diff --git a/fp/src/main/scala/eu/thoughtway/fsm/fp/domain/service/OrganisationService.scala b/fp/src/main/scala/eu/thoughtway/fsm/fp/domain/service/OrganisationService.scala index 1c6444b..94a1b55 100644 --- a/fp/src/main/scala/eu/thoughtway/fsm/fp/domain/service/OrganisationService.scala +++ b/fp/src/main/scala/eu/thoughtway/fsm/fp/domain/service/OrganisationService.scala @@ -10,10 +10,16 @@ trait OrganisationService { def credit(org: OrganisationEnabled)( amount: BigDecimal ): OrganisationEnabled - def debit(org: OrganisationEnabled)( + def debit[A <: Enabled | Suspended](org: Organisation[A])( amount: BigDecimal - ): OrganisationEnabled - def disable(org: OrganisationEnabled): OrganisationDisabled + ): Organisation[A] + def suspend( + org: OrganisationEnabled | OrganisationDisabled + ): OrganisationSuspended + def resume(org: OrganisationSuspended): OrganisationEnabled + def disable( + org: OrganisationEnabled | OrganisationSuspended + ): OrganisationDisabled def expedite(id: String)(amount: BigDecimal): OrganisationEnabled = (define _ andThen approve _ andThen credit)(id)(amount) @@ -31,11 +37,21 @@ trait OrganisationServiceImpl extends OrganisationService { ): OrganisationEnabled = org.copy(balance = org.balance + amount) - override def debit(org: OrganisationEnabled)( + override def debit[A <: Enabled | Suspended](org: Organisation[A])( amount: BigDecimal - ): OrganisationEnabled = + ): Organisation[A] = org.copy(balance = org.balance - amount) - override def disable(org: OrganisationEnabled): OrganisationDisabled = + override def suspend( + org: OrganisationEnabled | OrganisationDisabled + ): OrganisationSuspended = + org.copy(status = Suspended) + + override def resume(org: OrganisationSuspended): OrganisationEnabled = + org.copy(status = Enabled) + + override def disable( + org: OrganisationEnabled | OrganisationSuspended + ): OrganisationDisabled = org.copy(status = Disabled) } diff --git a/fp/src/test/scala/eu/thoughtway/fsm/fp/domain/service/OrganisationServiceTest.scala b/fp/src/test/scala/eu/thoughtway/fsm/fp/domain/service/OrganisationServiceTest.scala index d5d4232..ce64b30 100644 --- a/fp/src/test/scala/eu/thoughtway/fsm/fp/domain/service/OrganisationServiceTest.scala +++ b/fp/src/test/scala/eu/thoughtway/fsm/fp/domain/service/OrganisationServiceTest.scala @@ -5,14 +5,15 @@ import java.time.LocalDateTime import eu.thoughtway.fsm.fp.domain.model.Organisation.{ Defined, Disabled, - Enabled + Enabled, + Suspended } -import org.scalatest.{FunSpec, Matchers, ParallelTestExecution} +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers -class OrganisationServiceTest - extends FunSpec - with Matchers - with ParallelTestExecution { +import scala.language.implicitConversions + +class OrganisationServiceTest extends AnyFunSpec with Matchers { describe("Given an OrganisationService") { @@ -46,6 +47,14 @@ class OrganisationServiceTest "debit(defined)(10)" shouldNot typeCheck } + it("should not allow suspend") { + "suspend(defined)" shouldNot compile + } + + it("should not allow resume") { + "resume(defined)" shouldNot compile + } + it("should not allow disable") { "disable(defined)" shouldNot typeCheck } @@ -73,6 +82,10 @@ class OrganisationServiceTest "approve(approved)" shouldNot typeCheck } + it("should not allow resume") { + "resume(approved)" shouldNot compile + } + describe( "When an approved Organisation is credited with a given amount" ) { @@ -115,6 +128,86 @@ class OrganisationServiceTest } } + describe("When an approved Organisation is suspended") { + val suspended = suspend(approved) + + it("should have its id remain unchanged") { + suspended.id should be(approved.id) + } + + it("should have its created date remain unchanged") { + suspended.created should be(approved.created) + } + + it("should have its status set to Suspended") { + suspended.status should be(Suspended) + } + + it("should have its balance remain unchanged") { + suspended.balance should be(approved.balance) + } + + it("should not allow credit") { + "val a: String = 1" shouldNot typeCheck + } + + it("should not allow debit") { + "debit(10)(suspended)" shouldNot typeCheck + } + + it("should not allow suspend") { + "suspend(suspended)" shouldNot compile + } + + it("should not allow resume") { + "resume(suspended)" shouldNot compile + } + + it("should not allow disable") { + "disable(suspended)" shouldNot compile + } + + describe("When a suspended Organisation is debited") { + val debited = debit(suspended)(10) + + it("should have its id remain unchanged") { + debited.id should be(suspended.id) + } + + it("should have its created date remain unchanged") { + debited.created should be(suspended.created) + } + + it("should have its status remain unchanged") { + debited.status should be(suspended.status) + } + + it("should have its balance decremented by the given amount") { + debited.balance should be(suspended.balance - 10) + } + } + + describe("When a suspended Organisation is resumed") { + val resumed = resume(suspended) + + it("should have its id remain unchanged") { + resumed.id should be(suspended.id) + } + + it("should have its created date remain unchanged") { + resumed.created should be(suspended.created) + } + + it("should have its status set to Enabled") { + resumed.status should be(Enabled) + } + + it("should have its balance remain unchanged") { + resumed.balance should be(suspended.balance) + } + } + } + describe("When an approved Organisation is disabled") { val disabled = disable(approved) diff --git a/oo/src/main/scala/eu/thoughtway/fsm/oo/model/Organisation.scala b/oo/src/main/scala/eu/thoughtway/fsm/oo/model/Organisation.scala index 8958973..42682d2 100644 --- a/oo/src/main/scala/eu/thoughtway/fsm/oo/model/Organisation.scala +++ b/oo/src/main/scala/eu/thoughtway/fsm/oo/model/Organisation.scala @@ -22,6 +22,9 @@ class Organisation(private[this] val _id: String) private[this] val _disabled = new Disabled() def disabled: Disabled = _disabled + private[this] val _suspended = new Suspended() + def suspended: Suspended = _suspended + private[this] var _state: State = _defined def state: State = _state @@ -31,6 +34,10 @@ class Organisation(private[this] val _id: String) override def debit(amount: BigDecimal): Unit = _state.debit(amount) + override def suspend(): Unit = _state.suspend() + + override def resume(): Unit = _state.resume() + override def disable(): Unit = _state.disable() sealed trait State extends Organisation.Organisation @@ -48,6 +55,16 @@ class Organisation(private[this] val _id: String) "Can't debit Organisation in Defined state" ) + override def suspend(): Unit = + throw new IllegalStateException( + "Can't suspend Organisation in Defined state" + ) + + override def resume(): Unit = + throw new IllegalStateException( + "Can't resume Organisation in Defined state" + ) + override def disable(): Unit = throw new IllegalStateException( "Can't disable Organisation in Defined state" @@ -66,6 +83,38 @@ class Organisation(private[this] val _id: String) override def debit(amount: BigDecimal): Unit = _balance = balance - amount + override def suspend(): Unit = + _state = _suspended + + override def resume(): Unit = + throw new IllegalStateException( + "Can't resume Organisation in Enabled state" + ) + + override def disable(): Unit = _state = _disabled + } + + final class Suspended extends State { + override def approve(): Unit = + throw new IllegalStateException( + "Can't approve Organisation in Suspended state" + ) + + override def credit(amount: BigDecimal): Unit = + throw new IllegalStateException( + "Can't credit Organisation in Suspended state" + ) + + override def debit(amount: BigDecimal): Unit = + _balance = balance - amount + + override def suspend(): Unit = + throw new IllegalStateException( + "Can't suspend Organisation in Suspended state" + ) + + override def resume(): Unit = _state = _enabled + override def disable(): Unit = _state = _disabled } @@ -85,6 +134,13 @@ class Organisation(private[this] val _id: String) "Can't debit Organisation in Disabled state" ) + override def suspend(): Unit = _state = _suspended + + override def resume(): Unit = + throw new IllegalStateException( + "Can't resume Organisation in Disabled state" + ) + override def disable(): Unit = throw new IllegalStateException( "Can't disable Organisation in Disabled state" @@ -97,6 +153,8 @@ object Organisation { def approve(): Unit def credit(amount: BigDecimal): Unit def debit(amount: BigDecimal): Unit + def suspend(): Unit + def resume(): Unit def disable(): Unit } } diff --git a/oo/src/main/scala/eu/thoughtway/fsm/oo/service/OrganisationService.scala b/oo/src/main/scala/eu/thoughtway/fsm/oo/service/OrganisationService.scala index 56559d6..644d2ca 100644 --- a/oo/src/main/scala/eu/thoughtway/fsm/oo/service/OrganisationService.scala +++ b/oo/src/main/scala/eu/thoughtway/fsm/oo/service/OrganisationService.scala @@ -7,6 +7,8 @@ trait OrganisationService { def approve(org: Organisation): Unit def credit(org: Organisation, amount: BigDecimal): Unit def debit(org: Organisation, amount: BigDecimal): Unit + def suspend(org: Organisation): Unit + def resume(org: Organisation): Unit def disable(org: Organisation): Unit def expedite(amount: BigDecimal)(id: String): Organisation = { @@ -28,5 +30,9 @@ object OrganisationService extends OrganisationService { override def debit(org: Organisation, amount: BigDecimal): Unit = org.debit(amount) + override def suspend(org: Organisation): Unit = org.suspend() + + override def resume(org: Organisation): Unit = org.resume() + override def disable(org: Organisation): Unit = org.disable() } diff --git a/oo/src/test/scala/eu/thoughtway/fsm/oo/service/OrganisationServiceTest.scala b/oo/src/test/scala/eu/thoughtway/fsm/oo/service/OrganisationServiceTest.scala index 2e8ea74..0982bde 100644 --- a/oo/src/test/scala/eu/thoughtway/fsm/oo/service/OrganisationServiceTest.scala +++ b/oo/src/test/scala/eu/thoughtway/fsm/oo/service/OrganisationServiceTest.scala @@ -2,12 +2,10 @@ package eu.thoughtway.fsm.oo.service import java.time.LocalDateTime -import org.scalatest.{FunSpec, Matchers, ParallelTestExecution} +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers -class OrganisationServiceTest - extends FunSpec - with Matchers - with ParallelTestExecution { +class OrganisationServiceTest extends AnyFunSpec with Matchers { describe("Given an OrganisationService") { @@ -40,6 +38,14 @@ class OrganisationServiceTest an[IllegalStateException] shouldBe thrownBy(debit(org, 10)) } + it("should throw IllegalStateException on suspend") { + an[IllegalStateException] shouldBe thrownBy(suspend(org)) + } + + it("should throw IllegalStateException on resume") { + an[IllegalStateException] shouldBe thrownBy(resume(org)) + } + it("should throw IllegalStateException on disable") { an[IllegalStateException] shouldBe thrownBy(disable(org)) } @@ -73,6 +79,10 @@ class OrganisationServiceTest it("should throw IllegalStateException on approve") { an[IllegalStateException] shouldBe thrownBy(approve(org)) } + + it("should throw IllegalStateException on resume") { + an[IllegalStateException] shouldBe thrownBy(resume(org)) + } } describe( @@ -133,6 +143,114 @@ class OrganisationServiceTest } } + describe("When an approved Organisation is suspended") { + + val org = define("test") + + val createdOriginal = org.created + val balanceOriginal = org.balance + + approve(org) + suspend(org) + + it("should have its id remain unchanged") { + org.id should be("test") + } + + it("should have its created date remain unchanged") { + org.created should be(createdOriginal) + } + + it("should have its state set to the Suspended state") { + org.state should be(org.suspended) + } + + it("should have its balance remain unchanged") { + org.balance should be(balanceOriginal) + } + + it("should throw IllegalStateException on approve") { + an[IllegalStateException] shouldBe thrownBy(approve(org)) + } + + it("should throw IllegalStateException on credit") { + an[IllegalStateException] shouldBe thrownBy(credit(org, 10)) + } + + it("should throw IllegalStateException on suspend") { + an[IllegalStateException] shouldBe thrownBy(suspend(org)) + } + } + + describe("When an suspended Organisation is debited") { + + val org = define("test") + + val createdOriginal = org.created + val balanceOriginal = org.balance + + approve(org) + suspend(org) + debit(org, 10) + + it("should have its id remain unchanged") { + org.id should be("test") + } + + it("should have its created date remain unchanged") { + org.created should be(createdOriginal) + } + + it("should have its state remain unchanged") { + org.state should be(org.suspended) + } + + it("should have its balance decremented by the amount debited") { + org.balance should be(balanceOriginal - 10) + } + } + + describe("When an suspended Organisation is resumed") { + + val org = define("test") + + val createdOriginal = org.created + val balanceOriginal = org.balance + + approve(org) + suspend(org) + resume(org) + + it("should have its id remain unchanged") { + org.id should be("test") + } + + it("should have its created date remain unchanged") { + org.created should be(createdOriginal) + } + + it("should have its state set to the Enabled state") { + org.state should be(org.enabled) + } + + it("should have its balance remain unchanged") { + org.balance should be(balanceOriginal) + } + } + + describe("When a suspended Organisation is disabled") { + + val org = define("test") + + approve(org) + suspend(org) + disable(org) + + it("should have its state set to the Disabled state") { + org.state should be(org.disabled) + } + } + describe("When an approved Organisation is disabled") { val org = define("test") @@ -172,6 +290,10 @@ class OrganisationServiceTest an[IllegalStateException] shouldBe thrownBy(debit(org, 10)) } + it("should throw IllegalStateException on resume") { + an[IllegalStateException] shouldBe thrownBy(resume(org)) + } + it("should throw IllegalStateException on disable") { an[IllegalStateException] shouldBe thrownBy(disable(org)) } diff --git a/proc/src/main/scala/eu/thoughtway/fsm/proc/Organisation.scala b/proc/src/main/scala/eu/thoughtway/fsm/proc/Organisation.scala index 60f4b51..56eca49 100644 --- a/proc/src/main/scala/eu/thoughtway/fsm/proc/Organisation.scala +++ b/proc/src/main/scala/eu/thoughtway/fsm/proc/Organisation.scala @@ -16,6 +16,7 @@ object Organisation { sealed trait State case object Defined extends State case object Enabled extends State + case object Suspended extends State case object Disabled extends State def define(id: String): Organisation = @@ -40,7 +41,7 @@ object Organisation { } def debit(org: Organisation, amount: BigDecimal): Unit = - if (org.state.equals(Enabled)) { + if (org.state.equals(Enabled) || org.state.equals(Suspended)) { org.balance = org.balance - amount } else { throw new IllegalStateException( @@ -49,7 +50,7 @@ object Organisation { } def disable(org: Organisation): Unit = - if (org.state.equals(Enabled)) { + if (org.state.equals(Enabled) || org.state.equals(Suspended)) { org.state = Disabled } else { throw new IllegalStateException( @@ -57,6 +58,24 @@ object Organisation { ) } + def suspend(org: Organisation): Unit = + if (org.state.equals(Enabled) || org.state.equals(Disabled)) { + org.state = Suspended + } else { + throw new IllegalStateException( + s"Can't suspend Organisation(${org.id}, ${org.state})" + ) + } + + def resume(org: Organisation): Unit = + if (org.state.equals(Suspended)) { + org.state = Enabled + } else { + throw new IllegalStateException( + s"Can't resume Organisation(${org.id}, ${org.state})" + ) + } + def expedite(amount: BigDecimal)(id: String): Organisation = { val org = define(id) approve(org) diff --git a/proc/src/test/scala/eu.thoughtway.fsm.proc/OrganisationTest.scala b/proc/src/test/scala/eu.thoughtway.fsm.proc/OrganisationTest.scala index ef91402..ac50e01 100644 --- a/proc/src/test/scala/eu.thoughtway.fsm.proc/OrganisationTest.scala +++ b/proc/src/test/scala/eu.thoughtway.fsm.proc/OrganisationTest.scala @@ -2,12 +2,10 @@ package eu.thoughtway.fsm.proc import java.time.LocalDateTime -import org.scalatest.{FunSpec, Matchers, ParallelTestExecution} +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers -class OrganisationTest - extends FunSpec - with Matchers - with ParallelTestExecution { +class OrganisationTest extends AnyFunSpec with Matchers { describe("Given an Organisation") { @@ -43,6 +41,14 @@ class OrganisationTest it("should throw IllegalStateException on disable") { an[IllegalStateException] shouldBe thrownBy(disable(org)) } + + it("should throw IllegalStateException on suspend") { + an[IllegalStateException] shouldBe thrownBy(suspend(org)) + } + + it("should throw IllegalStateException on resume") { + an[IllegalStateException] shouldBe thrownBy(resume(org)) + } } describe("When a defined Organisation is approved") { @@ -73,6 +79,10 @@ class OrganisationTest it("should throw IllegalStateException on approve") { an[IllegalStateException] shouldBe thrownBy(approve(org)) } + + it("should throw IllegalStateException on resume") { + an[IllegalStateException] shouldBe thrownBy(resume(org)) + } } describe( @@ -133,6 +143,114 @@ class OrganisationTest } } + describe("When an approved Organisation is suspended") { + + val org = define("test") + + val createdOriginal = org.created + val balanceOriginal = org.balance + + approve(org) + suspend(org) + + it("should have its id remain unchanged") { + org.id should be("test") + } + + it("should have its created date remain unchanged") { + org.created should be(createdOriginal) + } + + it("should have its state set to the Suspended state") { + org.state should be(Suspended) + } + + it("should have its balance remain unchanged") { + org.balance should be(balanceOriginal) + } + + it("should throw IllegalStateException on approve") { + an[IllegalStateException] shouldBe thrownBy(approve(org)) + } + + it("should throw IllegalStateException on credit") { + an[IllegalStateException] shouldBe thrownBy(credit(org, 10)) + } + + it("should throw IllegalStateException on suspend") { + an[IllegalStateException] shouldBe thrownBy(suspend(org)) + } + } + + describe("When an suspended Organisation is debited") { + + val org = define("test") + + val createdOriginal = org.created + val balanceOriginal = org.balance + + approve(org) + suspend(org) + debit(org, 10) + + it("should have its id remain unchanged") { + org.id should be("test") + } + + it("should have its created date remain unchanged") { + org.created should be(createdOriginal) + } + + it("should have its state remain unchanged") { + org.state should be(Suspended) + } + + it("should have its balance decremented by the amount debited") { + org.balance should be(balanceOriginal - 10) + } + } + + describe("When an suspended Organisation is resumed") { + + val org = define("test") + + val createdOriginal = org.created + val balanceOriginal = org.balance + + approve(org) + suspend(org) + resume(org) + + it("should have its id remain unchanged") { + org.id should be("test") + } + + it("should have its created date remain unchanged") { + org.created should be(createdOriginal) + } + + it("should have its state set to the Enabled state") { + org.state should be(Enabled) + } + + it("should have its balance remain unchanged") { + org.balance should be(balanceOriginal) + } + } + + describe("When a suspended Organisation is disabled") { + + val org = define("test") + + approve(org) + suspend(org) + disable(org) + + it("should have its state set to the Disabled state") { + org.state should be(Disabled) + } + } + describe("When an approved Organisation is disabled") { val org = define("test") @@ -175,6 +293,10 @@ class OrganisationTest it("should throw IllegalStateException on disable") { an[IllegalStateException] shouldBe thrownBy(disable(org)) } + + it("should throw IllegalStateException on resume") { + an[IllegalStateException] shouldBe thrownBy(resume(org)) + } } describe( diff --git a/project/plugins.sbt b/project/plugins.sbt index 78bfa66..8580aa2 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,4 @@ // Scala format addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.0.7") + +addSbtPlugin("ch.epfl.lamp" % "sbt-dotty" % "0.4.0")