Skip to content

Commit

Permalink
test: add tests for posts
Browse files Browse the repository at this point in the history
  • Loading branch information
tassiluca committed Jan 14, 2024
1 parent b473b8b commit 8a34be0
Show file tree
Hide file tree
Showing 11 changed files with 156 additions and 83 deletions.
7 changes: 4 additions & 3 deletions src/main/scala/io/github/tassiLuca/boundaries/either.scala
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ object either:
case Right(value) => value
case Left(value) => break(Left(value))

type ThrowableConverter[L] = Throwable => L

extension [R](t: Try[R])
inline def ?[L](using Label[Left[L, Nothing]])(using converter: ThrowableConverter[L]): R = t match
inline def ?[L](using Label[Left[L, Nothing]])(using converter: Conversion[Throwable, L]): R = t match
case Success(value) => value
case Failure(exception) => break(Left(converter(exception)))

object EitherConversions:
given Conversion[Throwable, String] = _.getMessage
6 changes: 2 additions & 4 deletions src/main/scala/io/github/tassiLuca/posts/PostsModel.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package io.github.tassiLuca.posts
import gears.async.Async

import java.util.Date
import scala.util.Try

/** The model of a simple blog posts service. */
trait PostsModel:
Expand All @@ -29,7 +28,6 @@ trait PostsModel:
/** A function that verifies the content of the post, returning [[Right]] with the content of the post if the
* verification passes or [[Left]] with the reason why failed.
*/
type ContentVerifier = Async ?=> (Title, Body) => Either[String, PostContent]
type ContentVerifier = (Title, Body) => Either[String, PostContent]

trait AuthorsService:
def by(id: AuthorId)(using Async): Try[Author]
type AuthorsVerifier = (AuthorId) => Author
8 changes: 4 additions & 4 deletions src/main/scala/io/github/tassiLuca/posts/Utils.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import java.time.LocalTime
import scala.util.{Failure, Random, Success}

extension (component: String)
def simulates(action: String)(using Async): Unit =
def simulates(action: String, minDuration: Int = 0, maxDuration: Int = 5_000)(using Async): Unit =
println(s"[$component - ${Thread.currentThread()} @ ${LocalTime.now()}] $action")
AsyncOperations.sleep(Random.nextInt(10_000))
AsyncOperations.sleep(Random.nextInt(maxDuration) + minDuration)
println(s"[$component - ${Thread.currentThread()} @ ${LocalTime.now()}] ended $action")

def simulatesBlocking(action: String): Unit =
def simulatesBlocking(action: String, minDuration: Int = 0, maxDuration: Int = 5_000): Unit =
println(s"[$component - ${Thread.currentThread()} @ ${LocalTime.now()}] $action")
Thread.sleep(Random.nextInt(10_000))
Thread.sleep(Random.nextInt(maxDuration) + minDuration)
println(s"[$component - ${Thread.currentThread()} @ ${LocalTime.now()}] ended $action")
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ trait BlogPostsApp extends PostsServiceComponent with PostsModel with PostsRepos
override type Title = String

val contentVerifier: ContentVerifier
val authorsService: AuthorsService
val authorsVerifier: AuthorsVerifier

override val repository: PostsRepository = PostsRepository()
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ package io.github.tassiLuca.posts.direct
import gears.async.Async
import io.github.tassiLuca.posts.{PostsModel, simulates}

import scala.util.Try

/** The component exposing blog posts repositories. */
trait PostsRepositoryComponent:
context: PostsModel =>
Expand All @@ -15,13 +13,16 @@ trait PostsRepositoryComponent:
/** The repository in charge of storing and retrieving blog posts. */
trait PostsRepository:
/** Save the given [[post]]. */
def save(post: Post)(using Async): Try[Post]
def save(post: Post)(using Async): Post

/** Return true if a post exists with the given title, false otherwise. */
def exists(postTitle: Title)(using Async): Boolean

/** Load the post with the given [[postTitle]]. */
def load(postTitle: Title)(using Async): Try[Post]
def load(postTitle: Title)(using Async): Option[Post]

/** Load all the saved post. */
def loadAll()(using Async): Try[LazyList[Post]]
def loadAll()(using Async): LazyList[Post]

object PostsRepository:
/** Constructs a new [[PostsRepository]]. */
Expand All @@ -30,16 +31,19 @@ trait PostsRepositoryComponent:
private class PostsLocalRepository extends PostsRepository:
private var posts: Set[Post] = Set()

override def save(post: Post)(using Async): Try[Post] = Try:
require(posts.count(_.title == post.title) == 0, "A post with same title has already been saved")
override def save(post: Post)(using Async): Post =
require(!exists(post.title), "A post with same title has already been saved")
"PostsRepository" simulates s"saving post '${post.title}'"
synchronized { posts = posts + post }
post

override def load(postTitle: Title)(using Async): Try[Post] = Try:
override def exists(postTitle: Title)(using Async): Boolean =
posts.exists(_.title == postTitle)

override def load(postTitle: Title)(using Async): Option[Post] =
"PostsRepository" simulates s"loading post '$postTitle'"
posts.find(_.title == postTitle).get
posts.find(_.title == postTitle)

override def loadAll()(using Async): Try[LazyList[Post]] = Try:
override def loadAll()(using Async): LazyList[Post] =
"PostsRepository" simulates s"loading all blog posts"
LazyList.from(posts)
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ package io.github.tassiLuca.posts.direct
import gears.async.default.given
import gears.async.{Async, Task}
import io.github.tassiLuca.boundaries.either
import io.github.tassiLuca.boundaries.either.{?, ThrowableConverter}
import io.github.tassiLuca.boundaries.either.?
import io.github.tassiLuca.boundaries.EitherConversions.given
import io.github.tassiLuca.posts.{PostsModel, simulates}

import java.util.Date
import scala.util.{Failure, Success, Try}

/** The blog posts service component. */
trait PostsServiceComponent:
Expand All @@ -30,30 +30,36 @@ trait PostsServiceComponent:
def all()(using Async): Either[String, LazyList[Post]]

object PostsService:
def apply(contentVerifier: ContentVerifier, authorsService: AuthorsService): PostsService =
def apply(contentVerifier: ContentVerifier, authorsService: AuthorsVerifier): PostsService =
PostsServiceImpl(contentVerifier, authorsService)

private class PostsServiceImpl(
contentVerifier: ContentVerifier,
authorsService: AuthorsService,
authorsVerifier: AuthorsVerifier,
) extends PostsService:

given ThrowableConverter[String] = (t: Throwable) => t.getMessage // TODO put in an object of given instances

override def create(authorId: AuthorId, title: Title, body: Body)(using Async): Either[String, Post] = either:
val (author, content) = authorBy(authorId).run.zip(verifyContent(title, body).run).awaitResult.?
val post = Post(author, content.?._1, content.?._2, Date())
context.repository.save(post).?
post
override def create(authorId: AuthorId, title: Title, body: Body)(using Async): Either[String, Post] =
if context.repository.exists(title)
then Left(s"A post entitled $title already exists")
else
either:
val content = verifyContent(title, body).run
val author = authorBy(authorId).run
val post = Post(author.awaitResult.?, content.await.?._1, content.await.?._2, Date())
context.repository.save(post)

/* Pretending to make a call to the Authorship Service that keeps track of authorized authors. */
private def authorBy(id: AuthorId): Task[Author] = Task:
authorsService.by(id).get
"PostsService".simulates(s"getting author $id info...", maxDuration = 1_000)
authorsVerifier(id)

/* Some local computation that verifies the content of the post is appropriate (e.g. not offensive, ...). */
private def verifyContent(title: Title, body: Body): Task[Either[String, PostContent]] = Task:
"PostsService".simulates(s"verifying content of post '$title'", minDuration = 1_000)
contentVerifier(title, body)

override def get(title: Title)(using Async): Either[String, Post] = either:
context.repository.load(title).?
override def get(title: Title)(using Async): Either[String, Post] =
context.repository.load(title).toRight(s"Post $title not found")

override def all()(using Async): Either[String, LazyList[Post]] = either:
context.repository.loadAll().?
context.repository.loadAll()
14 changes: 10 additions & 4 deletions src/main/scala/io/github/tassiLuca/posts/quo/BlogPostsApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,28 @@ import io.github.tassiLuca.posts.PostsModel
import scala.concurrent.duration.Duration
import scala.concurrent.{Await, ExecutionContext}

object BlogPostsApp$ extends PostsServiceComponent with PostsModel with PostsRepositoryComponent:
trait BlogPostsApp extends PostsServiceComponent with PostsModel with PostsRepositoryComponent:
override type AuthorId = String
override type Body = String
override type Title = String

val contentVerifier: ContentVerifier
val authorsVerifier: AuthorsVerifier

override val repository: PostsRepository = PostsRepository()
override val service: PostsService = PostsService()

@main def usePostsApp(): Unit =
given ExecutionContext = ExecutionContext.global
val app = BlogPostsApp$
val app = new BlogPostsApp:
override val contentVerifier: ContentVerifier = (t, b) => Right((t, b))
override val authorsVerifier: AuthorsVerifier = a =>
require(a == "ltassi@gmail.com", "No author with the given id matches")
Author(a, "Luca", "Tassinari")
override val service: PostsService = PostsService(contentVerifier, authorsVerifier)
val post =
for
_ <- app.service.create("ltassi@gmail.com", "A hello world post", "Hello World!")
p <- app.service.get("A hello world post")
yield p
Await.ready(post, Duration.Inf)
println(post.value)

Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@ trait PostsRepositoryComponent:
/** The repository in charge of storing and retrieving blog posts. */
trait PostsRepository:
/** Save the given [[post]]. */
def save(post: Post)(using ExecutionContext): Future[Unit]
def save(post: Post)(using ExecutionContext): Future[Post]

/** Load the post with the given [[postTitle]]. */
def loadAll()(using ExecutionContext): Future[LazyList[Post]]
/** Return a future completed with true if a post exists with the given title, false otherwise. */
def exists(postTitle: Title)(using ExecutionContext): Future[Boolean]

/** Load all the saved post. */
def load(postTitle: Title)(using ExecutionContext): Future[Post]
def load(postTitle: Title)(using ExecutionContext): Future[Option[Post]]

/** Load the post with the given [[postTitle]]. */
def loadAll()(using ExecutionContext): Future[LazyList[Post]]

object PostsRepository:
/** Constructs a new [[PostsRepository]]. */
Expand All @@ -31,14 +34,18 @@ trait PostsRepositoryComponent:
private class PostsLocalRepository extends PostsRepository:
private var posts: Set[Post] = Set()

override def save(post: Post)(using ExecutionContext): Future[Unit] = Future:
require(posts.count(_.title == post.title) == 0, "A post with same title has already been saved")
"PostsRepository" simulatesBlocking s"saving post ${post.title}"
override def save(post: Post)(using ExecutionContext): Future[Post] = Future:
require(!posts.exists(_.title == post.title), "A post with same title has already been saved")
"PostsRepository" simulatesBlocking s"saving post '${post.title}'"
synchronized { posts = posts + post }
post

override def exists(postTitle: Title)(using ExecutionContext): Future[Boolean] = Future:
posts.exists(_.title == postTitle)

override def load(postTitle: Title)(using ExecutionContext): Future[Post] = Future:
"PostsRepository" simulatesBlocking s"loading post $postTitle"
posts.find(_.title == postTitle).get
override def load(postTitle: Title)(using ExecutionContext): Future[Option[Post]] = Future:
"PostsRepository" simulatesBlocking s"loading post '$postTitle'"
posts.find(_.title == postTitle)

override def loadAll()(using ExecutionContext): Future[LazyList[Post]] = Future:
"PostsRepository" simulatesBlocking s"loading all blog posts"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,31 +25,37 @@ trait PostsServiceComponent:
def all(): Future[LazyList[Post]]

object PostsService:
def apply(): PostsService = PostsServiceImpl()
def apply(contentVerifier: ContentVerifier, authorsVerifier: AuthorsVerifier): PostsService =
PostsServiceImpl(contentVerifier, authorsVerifier)

private class PostsServiceImpl extends PostsService:
private class PostsServiceImpl(
contentVerifier: ContentVerifier,
authorsVerifier: AuthorsVerifier,
) extends PostsService:

opaque type PostContent = (Title, Body)
given ExecutionContext = ExecutionContext.global

override def create(authorId: AuthorId, title: Title, body: Body): Future[Post] =
val author = authorBy(authorId)
val content = verifyContent(title, body)
val authorAsync = authorBy(authorId)
val contentAsync = verifyContent(title, body)
for
a <- author
c <- content
post = Post(a, c._1, c._2, Date())
content <- contentAsync
author <- authorAsync
post = Post(author, content._1, content._2, Date())
_ <- context.repository.save(post)
yield post

/* Pretending to make a call to the Authorship Service that keeps track of authorized authors. */
private def authorBy(id: AuthorId)(using ExecutionContext): Future[Author] = Future:
"PostsService" simulatesBlocking s"getting author $id info..."
Author(id, "Luca", "Tassinari")
"PostsService".simulatesBlocking(s"getting author $id info...", maxDuration = 1_000)
authorsVerifier(id)

private def verifyContent(title: Title, body: Body)(using ExecutionContext): Future[PostContent] = Future:
"PostsService" simulatesBlocking s"verifying content of the post '$title'"
(title, body)
/* Some local computation that verifies the content of the post is appropriate (e.g. not offensive, ...). */
private def verifyContent(title: Title, body: Body)(using ExecutionContext): Future[PostContent] =
Future:
"PostsService".simulatesBlocking(s"verifying content of the post '$title'", minDuration = 1_000)
contentVerifier(title, body) match { case Left(e) => throw RuntimeException(e); case Right(v) => v }

override def get(title: Title): Future[Post] = context.repository.load(title)
override def get(title: Title): Future[Post] = context.repository.load(title).map(_.get)

override def all(): Future[LazyList[Post]] = context.repository.loadAll()
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,10 @@ package io.github.tassiLuca.posts.direct

import gears.async.default.given
import gears.async.{Async, Future}
import io.github.tassiLuca.posts.simulates
import org.scalatest.BeforeAndAfterEach
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers.shouldBe

import scala.util.Try

class BlogPostsServiceTest extends AnyFlatSpec with BeforeAndAfterEach:

private var blogPostsApp: BlogPostsApp = null
Expand All @@ -18,25 +15,18 @@ class BlogPostsServiceTest extends AnyFlatSpec with BeforeAndAfterEach:

override def beforeEach(): Unit =
blogPostsApp = new BlogPostsApp:
override val contentVerifier: ContentVerifier = (t, b) =>
"PostsService" simulates s"verifying content of post '$t'"
Right((t, b))
override val authorsService: AuthorsService = new AuthorsService:
private val authors = Set(
Author("ltassi", "Luca", "Tassinari"),
Author("mrossi", "Mario", "Rossi"),
)
override def by(id: AuthorId)(using Async): Try[Author] =
"PostsService" simulates s"getting author $id info..."
Try(authors.find(_.authorId == id).get)
override val service: PostsService = PostsService(contentVerifier, authorsService)
override val contentVerifier: ContentVerifier = (t, b) => Right((t, b))
override val authorsVerifier: AuthorsVerifier = a =>
require(a == authorId, "No author with the given id matches")
Author(a, "Luca", "Tassinari")
override val service: PostsService = PostsService(contentVerifier, authorsVerifier)

"BlogPostsService" should "create posts correctly if author and content is legit" in {
Async.blocking:
blogPostsApp.service.create(authorId, postTitle, postBody).isRight shouldBe true
val post = blogPostsApp.service.get(postTitle)
post.isRight shouldBe true
post.toOption.get.author shouldBe blogPostsApp.authorsService.by(authorId).get
post.toOption.get.author shouldBe blogPostsApp.authorsVerifier(authorId)
post.toOption.get.body shouldBe postBody
}

Expand All @@ -58,6 +48,7 @@ class BlogPostsServiceTest extends AnyFlatSpec with BeforeAndAfterEach:

"BlogPostsService" should "fail on unauthorized author and cancel the content verification check" in {
Async.blocking:
blogPostsApp.service.create("unauthorized", postTitle, postBody).isLeft shouldBe true
// TODO: check println?
val result = blogPostsApp.service.create("unauthorized", postTitle, postBody)
result.isLeft shouldBe true
// the cancelling can be observed looking at the logs :(
}
Loading

0 comments on commit 8a34be0

Please sign in to comment.