import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
import org.junit.After
import org.junit.Before
import org.junit.Test
import java.time.Instant
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class CommentService(
private val commentRepository: CommentRepository,
private val userService: UserService,
private val commentModelFactory: CommentModelFactory,
private val commentValidator: CommentValidator,
private val emailService: EmailService,
private val backgroundScope: CoroutineScope,
) {
suspend fun addComment(
token: String,
collectionKey: String,
body: AddComment
) {
val userId = userService.readUserId(token)
if (!commentValidator.validate(body.comment)) {
throw CommentValidationException("Invalid comment")
}
val commentModel = commentModelFactory
.toCommentModel(userId, collectionKey, body)
commentRepository.addComment(commentModel)
notifyAddCommentObservers(collectionKey, body)
}
suspend fun getComments(
collectionKey: String
) = coroutineScope {
val commentDocuments = commentRepository
.getComments(collectionKey)
val users: Map<String, User> = commentDocuments
.map { it.userId }
.toSet()
.map { async { userService.findUserById(it) } }
.awaitAll()
.associateBy { it.id }
CommentsCollection(
collectionKey = collectionKey,
elements = commentDocuments.map { commentModel ->
CommentElement(
id = commentModel.id,
collectionKey = commentModel.collectionKey,
user = users[commentModel.userId],
comment = commentModel.comment,
date = commentModel.date,
)
}
)
}
// For legacy blocking calls
fun addCommentBlocking(
token: String,
collectionKey: String,
body: AddComment
) = runBlocking {
addComment(token, collectionKey, body)
}
// For legacy blocking calls
fun getCommentsBlocking(
collectionKey: String
): CommentsCollection = runBlocking {
getComments(collectionKey)
}
private fun notifyAddCommentObservers(collectionKey: String, body: AddComment) {
backgroundScope.launch {
val observerIds = commentRepository.getCollectionKeyObservers(collectionKey)
val users = observerIds.map { userService.findUserById(it) }
users.forEach { user ->
launch {
emailService.notifyAboutCommentInObservedCollection(
email = user.email,
collectionKey = collectionKey,
comment = body.comment
)
}
}
}
}
}
interface CommentRepository {
suspend fun getComments(collectionKey: String): List<CommentModel>
suspend fun getComment(id: String): CommentModel?
suspend fun addComment(comment: CommentModel)
suspend fun deleteComment(commentId: String)
suspend fun getCollectionKeyObservers(collectionKey: String): List<String>
}
class CommentModelFactory(
private val uuidProvider: UuidProvider,
private val timeProvider: TimeProvider,
) {
fun toCommentModel(userId: String, collectionKey: String, body: AddComment) = CommentModel(
id = uuidProvider.next(),
collectionKey = collectionKey,
userId = userId,
comment = body.comment,
date = timeProvider.now()
)
}
interface EmailService {
suspend fun notifyAboutCommentInObservedCollection(email: String, collectionKey: String, comment: String?)
}
interface TimeProvider {
fun now(): Instant
}
interface UuidProvider {
fun next(): String
}
interface CommentValidator {
@Throws(CommentValidationException::class)
fun validate(comment: String?): Boolean
}
class CommentValidationException(message: String) : Exception(message)
data class CommentModel(
val id: String,
val collectionKey: String,
val userId: String,
val comment: String?,
val date: Instant,
)
interface UserService {
fun readUserId(token: String): String
suspend fun findUser(token: String): User
suspend fun findUserById(id: String): User
}
object NoSuchUserException: Exception("No such user")
data class CommentsCollection(
val collectionKey: String,
val elements: List<CommentElement>,
)
data class CommentElement(
val id: String,
val collectionKey: String,
val user: User?,
val comment: String?,
val date: Instant,
)
data class AddComment(
val comment: String?,
)
data class EditComment(
val comment: String?,
)
data class User(
val id: String,
val email: String,
val imageUrl: String,
val displayName: String? = null,
val bio: String? = null,
)
data class UserDocument(
val _id: String,
val email: String,
val imageUrl: String,
val displayName: String? = null,
val bio: String? = null,
)
fun UserDocument.toUser() = User(
id = _id,
email = email,
imageUrl = imageUrl,
displayName = displayName,
bio = bio
)
fun User.toUserDocument() = UserDocument(
_id = id,
email = email,
imageUrl = imageUrl,
displayName = displayName,
bio = bio
)
class CommentServiceTests {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val commentsRepository = FakeCommentRepository()
private val userService = FakeUserService()
private val uuidProvider = FakeUuidProvider()
private val timeProvider = FakeTimeProvider()
private val commentValidator = FakeCommentValidator()
private val emailService = FakeEmailService()
private val commentsFactory: CommentModelFactory = CommentModelFactory(uuidProvider, timeProvider)
private val commentService: CommentService = CommentService(
commentsRepository,
userService,
commentsFactory,
commentValidator,
emailService,
testScope
)
@Before
fun setup() {
Initialize common test data
userService.hasUsers(user1, user2)
commentValidator.setShouldValidate(true)
}
@After
fun cleanup() {
timeProvider.clean()
uuidProvider.clean()
commentsRepository.clean()
userService.clear()
commentValidator.reset()
emailService.clear()
}
@Test
fun `Should add comment`() = runTest {
// given
commentsRepository.has(
commentModel1,
)
userService.hasUsers(
user1,
user2
)
userService.hasToken(aToken, user2.id)
uuidProvider.alwaysReturn(commentModel2.id)
timeProvider.advanceTimeTo(commentModel2.date)
// when
commentService.addComment(aToken, collectionKey2, AddComment(commentModel2.comment))
// then
assertEquals(commentModel2, commentsRepository.getComment(commentModel2.id))
}
@Test
fun `Should get comments by collection key`() = runTest {
// given
commentsRepository.has(
commentModel1,
commentModel2,
commentDocument3
)
userService.hasUsers(
user1,
user2
)
// when
val result: CommentsCollection = commentService.getComments(collectionKey1)
// then
with(result) {
assertEquals(collectionKey1, collectionKey)
assertEquals(listOf(commentElement1, commentElement3), elements)
}
}
@Test
fun `Should concurrently find users when getting comments`() = runTest {
// given
commentsRepository.has(
commentModel1,
commentModel1,
commentModel1,
commentModel2,
commentDocument3,
)
userService.hasUsers(
user1,
user2
)
userService.findUserDelay = 1000
// when
commentService.getComments(collectionKey1)
// then
assertEquals(1000, currentTime)
}
@Test
fun `Should add comment using blocking method`() {
// given
commentsRepository.has(
commentModel1,
)
userService.hasUsers(
user1,
user2
)
userService.hasToken(aToken, user2.id)
uuidProvider.alwaysReturn(commentModel2.id)
timeProvider.advanceTimeTo(commentModel2.date)
// when
commentService.addCommentBlocking(aToken, collectionKey2, AddComment(commentModel2.comment))
// then
runBlocking {
assertEquals(commentModel2, commentsRepository.getComment(commentModel2.id))
}
}
@Test
fun `Should get comments by collection key using blocking method`() {
// given
commentsRepository.has(
commentModel1,
commentModel2,
commentDocument3
)
userService.hasUsers(
user1,
user2
)
// when
val result: CommentsCollection = commentService.getCommentsBlocking(collectionKey1)
// then
with(result) {
assertEquals(collectionKey1, collectionKey)
assertEquals(listOf(commentElement1, commentElement3), elements)
}
}
Fake Data
private val aToken = "SOME_TOKEN"
private val collectionKey1 = "SOME_COLLECTION_KEY_1"
private val collectionKey2 = "SOME_COLLECTION_KEY_2"
private val date1 = Instant.parse("2018-11-30T18:35:24.00Z")
private val date2 = Instant.parse("2019-11-30T18:35:24.00Z")
private val userDocument1 = UserDocument(
_id = "U_ID_1",
email = "user1@email.com",
imageUrl = "some_image_1",
displayName = "some_display_name_1",
bio = "some bio 1"
)
private val userDocument2 = UserDocument(
_id = "U_ID_2",
email = "user2@email.com",
imageUrl = "some_image_2",
displayName = "some_display_name_2",
bio = "some bio 2"
)
private val user1 = userDocument1.toUser()
private val user2 = userDocument2.toUser()
private val commentModel1 = CommentModel(
id = "C_ID_1",
collectionKey = collectionKey1,
userId = user1.id,
comment = "Some comment 1",
date = date1,
)
private val commentModel2 = CommentModel(
id = "C_ID_2",
collectionKey = collectionKey2,
userId = user2.id,
comment = "Some comment 2",
date = date2,
)
private val commentDocument3 = CommentModel(
id = "C_ID_3",
collectionKey = collectionKey1,
userId = user2.id,
comment = "Some comment 3",
date = date2,
)
private val commentElement1 = CommentElement(
id = "C_ID_1",
collectionKey = collectionKey1,
user = user1,
comment = "Some comment 1",
date = date1,
)
private val commentElement2 = CommentElement(
id = "C_ID_2",
collectionKey = collectionKey2,
user = user2,
comment = "Some comment 2",
date = date2,
)
private val commentElement3 = CommentElement(
id = "C_ID_3",
collectionKey = collectionKey1,
user = user2,
comment = "Some comment 3",
date = date2,
)
@Test
fun `Should add comment when validation passes`() = runTest {
// given
commentValidator.setShouldValidate(true)
userService.hasToken(aToken, user2.id)
uuidProvider.alwaysReturn(commentModel2.id)
timeProvider.advanceTimeTo(commentModel2.date)
// when
commentService.addComment(aToken, collectionKey2, AddComment(commentModel2.comment))
// then
assertEquals(commentModel2, commentsRepository.getComment(commentModel2.id))
}
@Test
fun `Should throw exception when validation fails`() = runTest {
// given
commentValidator.setShouldValidate(false)
userService.hasToken(aToken, user2.id)
// when/then
assertFailsWith<CommentValidationException> {
commentService.addComment(aToken, collectionKey2, AddComment(commentModel2.comment))
}
}
@Test
fun `Should throw exception when validation fails with custom predicate`() = runTest {
// given
commentValidator.setValidationPredicate { comment ->
comment != null && comment.length >= 5
}
userService.hasToken(aToken, user2.id)
// when/then
assertFailsWith<CommentValidationException> {
commentService.addComment(aToken, collectionKey2, AddComment("abc"))
}
}
@Test
fun `Should send emails to observers when comment is added`() = runTest(testDispatcher) {
// given
userService.hasUsers(user1, user2)
userService.hasToken(aToken, user1.id)
uuidProvider.alwaysReturn(commentModel1.id)
timeProvider.advanceTimeTo(commentModel1.date)
Set up observers for the collection
val observers = listOf(user2.id)
commentsRepository.setObservers(collectionKey1, observers)
// when
commentService.addComment(aToken, collectionKey1, AddComment(commentModel1.comment))
Use the testDispatcher to run all pending tasks
runCurrent()
advanceUntilIdle()
// then
val expectedEmails = listOf(
user2.email to "New comment in collection $collectionKey1: ${commentModel1.comment}"
)
assertEquals(expectedEmails, emailService.getEmailsSent())
}
@Test
fun `Should send emails to multiple observers when comment is added`() = runTest(testDispatcher) {
// given
val user3 = User(
id = "U_ID_3",
email = "user3@email.com",
imageUrl = "some_image_3",
displayName = "some_display_name_3",
bio = "some bio 3"
)
userService.hasUsers(user1, user2, user3)
userService.hasToken(aToken, user1.id)
uuidProvider.alwaysReturn(commentModel1.id)
timeProvider.advanceTimeTo(commentModel1.date)
Set up multiple observers for the collection
val observers = listOf(user2.id, user3.id)
commentsRepository.setObservers(collectionKey1, observers)
// when
commentService.addComment(aToken, collectionKey1, AddComment(commentModel1.comment))
Use the testDispatcher to run all pending tasks
runCurrent()
advanceUntilIdle()
// then
val expectedEmails = listOf(
user2.email to "New comment in collection $collectionKey1: ${commentModel1.comment}",
user3.email to "New comment in collection $collectionKey1: ${commentModel1.comment}"
)
assertEquals(expectedEmails, emailService.getEmailsSent())
}
@Test
fun `Should throw exception when token is invalid`() = runTest {
// given
val invalidToken = "INVALID_TOKEN"
// when/then
assertFailsWith<NoSuchUserException> {
commentService.addComment(invalidToken, collectionKey1, AddComment("Some comment"))
}
}
@Test
fun `Should throw exception when user ID does not exist`() = runTest {
// given
val nonExistentUserId = "NON_EXISTENT_USER_ID"
val commentWithNonExistentUser = CommentModel(
id = "C_ID_NON_EXISTENT",
collectionKey = collectionKey1,
userId = nonExistentUserId,
comment = "Comment from non-existent user",
date = date1
)
commentsRepository.has(commentWithNonExistentUser)
// when/then
assertFailsWith<NoSuchUserException> {
commentService.getComments(collectionKey1) // This will try to find the non-existent user
}
}
@Test
fun `Should return empty list when getting comments from empty collection`() = runTest {
// given
val emptyCollectionKey = "EMPTY_COLLECTION_KEY"
// when
val result = commentService.getComments(emptyCollectionKey)
// then
assertEquals(emptyCollectionKey, result.collectionKey)
assertEquals(emptyList(), result.elements)
}
@Test
fun `Should not send emails when there are no observers`() = runTest {
// given
userService.hasToken(aToken, user1.id)
uuidProvider.alwaysReturn(commentModel1.id)
timeProvider.advanceTimeTo(commentModel1.date)
commentsRepository.setObservers(collectionKey1, emptyList())
// when
commentService.addComment(aToken, collectionKey1, AddComment(commentModel1.comment))
Use the testDispatcher to run all pending tasks
testDispatcher.scheduler.runCurrent()
advanceUntilIdle()
// then
assertEquals(emptyList(), emailService.getEmailsSent())
}
@Test
fun `Should handle null comment when validator allows it`() = runTest {
// given
userService.hasToken(aToken, user1.id)
uuidProvider.alwaysReturn(commentModel1.id)
timeProvider.advanceTimeTo(commentModel1.date)
Default validator allows null comments
// when
commentService.addComment(aToken, collectionKey1, AddComment(null))
// then
val comment = commentsRepository.getComment(commentModel1.id)
assertEquals(null, comment?.comment)
}
@Test
fun `Should throw exception when null comment is rejected by validator`() = runTest {
// given
userService.hasToken(aToken, user1.id)
Configure validator to reject null comments
commentValidator.setValidationPredicate { comment -> comment != null }
// when/then
assertFailsWith<CommentValidationException> {
commentService.addComment(aToken, collectionKey1, AddComment(null))
}
}
@Test
fun `Should improve concurrent test with better assertions`() = runTest {
// given
val comment1 = CommentModel(
id = "C_ID_CONCURRENT_1",
collectionKey = collectionKey1,
userId = user1.id,
comment = "Concurrent comment 1",
date = date1
)
val comment2 = CommentModel(
id = "C_ID_CONCURRENT_2",
collectionKey = collectionKey1,
userId = user1.id,
comment = "Concurrent comment 2",
date = date1
)
val comment3 = CommentModel(
id = "C_ID_CONCURRENT_3",
collectionKey = collectionKey1,
userId = user1.id,
comment = "Concurrent comment 3",
date = date1
)
commentsRepository.has(comment1, comment2, comment3)
userService.findUserDelay = 1000
// when
val startTime = currentTime
val result = commentService.getComments(collectionKey1)
val endTime = currentTime
// then
assertEquals(1000, endTime - startTime)
assertEquals(collectionKey1, result.collectionKey)
assertEquals(3, result.elements.size)
result.elements.forEach { element ->
assertEquals(user1, element.user)
}
val commentIds = result.elements.map { it.id }.toSet()
assertEquals(setOf("C_ID_CONCURRENT_1", "C_ID_CONCURRENT_2", "C_ID_CONCURRENT_3"), commentIds)
}
@Test
fun `Should call each notification in a separate coroutine`() = runTest(testDispatcher) {
// given
val user3 = User(
id = "U_ID_3",
email = "user3@email.com",
imageUrl = "some_image_3",
displayName = "some_display_name_3",
bio = "some bio 3"
)
val user4 = User(
id = "U_ID_4",
email = "user4@email.com",
imageUrl = "some_image_4",
displayName = "some_display_name_4",
bio = "some bio 4"
)
userService.hasUsers(user1, user2, user3, user4)
userService.hasToken(aToken, user1.id)
uuidProvider.alwaysReturn(commentModel1.id)
timeProvider.advanceTimeTo(commentModel1.date)
val observers = listOf(user2.id, user3.id, user4.id)
commentsRepository.setObservers(collectionKey1, observers)
emailService.notificationDelay = 1000
// when
commentService.addComment(aToken, collectionKey1, AddComment(commentModel1.comment))
runCurrent()
advanceUntilIdle()
// then
val expectedEmails = listOf(
user2.email to "New comment in collection $collectionKey1: ${commentModel1.comment}",
user3.email to "New comment in collection $collectionKey1: ${commentModel1.comment}",
user4.email to "New comment in collection $collectionKey1: ${commentModel1.comment}"
)
assertEquals(expectedEmails, emailService.getEmailsSent())
assertEquals(3, emailService.getMaxConcurrentNotifications())
}
@Test
fun `Should not wait for notification process to complete`() = runTest(testDispatcher) {
// given
userService.hasUsers(user1, user2)
userService.hasToken(aToken, user1.id)
uuidProvider.alwaysReturn(commentModel1.id)
timeProvider.advanceTimeTo(commentModel1.date)
val observers = listOf(user2.id)
commentsRepository.setObservers(collectionKey1, observers)
emailService.notificationDelay = 10000
// when
val startTime = currentTime
commentService.addComment(aToken, collectionKey1, AddComment(commentModel1.comment))
val endTime = currentTime
// then
Verify that addComment returns immediately, without waiting for the notification process to complete
assertEquals(0, endTime - startTime)
runCurrent() // Run the initial part of the background task
assertTrue(emailService.isNotificationStarted())
assertFalse(emailService.isNotificationCompleted())
assertEquals(emptyList(), emailService.getEmailsSent())
advanceUntilIdle()
assertTrue(emailService.isNotificationCompleted())
val expectedEmails = listOf(
user2.email to "New comment in collection $collectionKey1: ${commentModel1.comment}"
)
assertEquals(expectedEmails, emailService.getEmailsSent())
}
@Test
fun `Should not fetch the same user more than once when getting comments`() = runTest {
// given
val comment1 = CommentModel(
id = "C_ID_DEDUP_1",
collectionKey = collectionKey1,
userId = user1.id,
comment = "Comment 1 from user1",
date = date1
)
val comment2 = CommentModel(
id = "C_ID_DEDUP_2",
collectionKey = collectionKey1,
userId = user1.id, // Same user as comment1
comment = "Comment 2 from user1",
date = date1
)
val comment3 = CommentModel(
id = "C_ID_DEDUP_3",
collectionKey = collectionKey1,
userId = user2.id,
comment = "Comment from user2",
date = date1
)
val comment4 = CommentModel(
id = "C_ID_DEDUP_4",
collectionKey = collectionKey1,
userId = user2.id, // Same user as comment3
comment = "Another comment from user2",
date = date1
)
commentsRepository.has(comment1, comment2, comment3, comment4)
userService.hasUsers(user1, user2)
// when
val result = commentService.getComments(collectionKey1)
// then
assertEquals(4, result.elements.size)
assertEquals(1, userService.getFindUserByIdCalls(user1.id))
assertEquals(1, userService.getFindUserByIdCalls(user2.id))
val allCalls = userService.getAllFindUserByIdCalls()
assertEquals(2, allCalls.size)
assertEquals(2, allCalls.values.sum())
}
class FakeTimeProvider : TimeProvider {
private var currentTime = DEFAULT_START
override fun now(): Instant = currentTime
fun advanceTimeTo(instant: Instant) {
currentTime = instant
}
fun advanceTimeByDays(days: Int) {
currentTime = currentTime.plusSeconds(1L * days * 60 * 60 * 24)
}
fun clean() {
currentTime = DEFAULT_START
}
fun advanceTime() {
currentTime = currentTime.plusSeconds(10)
}
companion object {
val DEFAULT_START = Instant.parse("2018-11-30T18:35:24.00Z")
}
}
class FakeCommentRepository: CommentRepository {
private var comments = listOf<CommentModel>()
private var collectionObservers = mapOf<String, List<String>>()
fun has(vararg comment: CommentModel) {
comments = comments + comment
}
fun clean() {
comments = emptyList()
collectionObservers = emptyMap()
}
fun setObservers(collectionKey: String, observers: List<String>) {
collectionObservers = collectionObservers + (collectionKey to observers)
}
override suspend fun getComments(collectionKey: String): List<CommentModel> =
comments.filter { it.collectionKey == collectionKey }
override suspend fun getComment(id: String): CommentModel? =
comments.find { it.id == id }
override suspend fun addComment(comment: CommentModel) {
comments = comments + comment
}
override suspend fun deleteComment(commentId: String) {
TODO("Not yet implemented")
}
override suspend fun getCollectionKeyObservers(collectionKey: String): List<String> =
collectionObservers[collectionKey] ?: emptyList()
}
class FakeCommentValidator : CommentValidator {
private var shouldValidate = true
private var validationPredicate: (String?) -> Boolean = { true }
fun setShouldValidate(shouldValidate: Boolean) {
this.shouldValidate = shouldValidate
}
fun setValidationPredicate(predicate: (String?) -> Boolean) {
this.validationPredicate = predicate
}
fun reset() {
shouldValidate = true
validationPredicate = { true }
}
override fun validate(comment: String?): Boolean {
return shouldValidate && validationPredicate(comment)
}
}
class FakeEmailService : EmailService {
private var emailsSent = mutableListOf<Pair<String, String>>()
var notificationDelay: Long = 1000 // Default delay of 1 second
private val concurrentNotifications = AtomicInteger(0)
private var maxConcurrentNotifications = 0
private val notificationStarted = AtomicBoolean(false)
private val notificationCompleted = AtomicBoolean(false)
override suspend fun notifyAboutCommentInObservedCollection(email: String, collectionKey: String, comment: String?) {
notificationStarted.set(true)
val currentConcurrent = concurrentNotifications.incrementAndGet()
synchronized(this) {
if (currentConcurrent > maxConcurrentNotifications) {
maxConcurrentNotifications = currentConcurrent
}
}
delay(notificationDelay)
val body = "New comment in collection $collectionKey: $comment"
synchronized(this) {
emailsSent.add(email to body)
}
concurrentNotifications.decrementAndGet()
notificationCompleted.set(true)
}
fun getEmailsSent(): List<Pair<String, String>> = emailsSent.toList()
fun getMaxConcurrentNotifications(): Int = maxConcurrentNotifications
fun isNotificationStarted(): Boolean = notificationStarted.get()
fun isNotificationCompleted(): Boolean = notificationCompleted.get()
fun clear() {
synchronized(this) {
emailsSent.clear()
}
maxConcurrentNotifications = 0
concurrentNotifications.set(0)
notificationStarted.set(false)
notificationCompleted.set(false)
}
}
class FakeUserService : UserService {
var findUserDelay: Long? = null
private var users = listOf<User>()
private var tokens = mapOf<String, String>()
private val findUserByIdCalls = mutableMapOf<String, Int>()
fun hasUsers(vararg user: User) {
users = users + user
}
fun hasToken(token: String, userId: String) {
tokens = tokens + (token to userId)
}
fun clear() {
users = emptyList()
tokens = mapOf()
findUserDelay = null
findUserByIdCalls.clear()
}
fun getFindUserByIdCalls(id: String): Int {
return findUserByIdCalls[id] ?: 0
}
fun getAllFindUserByIdCalls(): Map<String, Int> {
return findUserByIdCalls.toMap()
}
override fun readUserId(token: String): String =
tokens[token] ?: throw NoSuchUserException
override suspend fun findUser(token: String): User {
findUserDelay?.let { delay(it) }
return findUserById(readUserId(token))
}
override suspend fun findUserById(id: String): User {
findUserDelay?.let { delay(it) }
findUserByIdCalls[id] = (findUserByIdCalls[id] ?: 0) + 1
return users.find { it.id == id } ?: throw NoSuchUserException
}
}
class FakeUuidProvider: UuidProvider {
private var counter = 1
private var constantReturn: String? = null
override fun next(): String = constantReturn ?: "UUID#" + (counter++)
fun clean() {
counter = 1
constantReturn = null
}
fun alwaysReturn(value: String) {
constantReturn = value
}
}
}