Solution: CommentService

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 ) } } } } }

Example solution in playground

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" Not registering this token with hasToken, so it will be invalid // 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" Add a comment with a 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) No observers set up for the collection 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 Create three different comments with the same user ID but different comment IDs 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 Verify that the operation took exactly the time of one user lookup, not three assertEquals(1000, endTime - startTime) Verify the result contains the expected number of comments assertEquals(collectionKey1, result.collectionKey) assertEquals(3, result.elements.size) Verify all comments have the same user result.elements.forEach { element -> assertEquals(user1, element.user) } Verify the comments have the expected IDs 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) Set up multiple observers for the collection val observers = listOf(user2.id, user3.id, user4.id) commentsRepository.setObservers(collectionKey1, observers) Set a delay for notifications to ensure we can detect concurrency emailService.notificationDelay = 1000 // when commentService.addComment(aToken, collectionKey1, AddComment(commentModel1.comment)) Use the testDispatcher to run all pending tasks runCurrent() advanceUntilIdle() // then Verify all emails were sent 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()) Verify that notifications were called concurrently If they were called sequentially, maxConcurrentNotifications would be 1 If they were called concurrently, maxConcurrentNotifications should be equal to the number of observers 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) Set up observers for the collection val observers = listOf(user2.id) commentsRepository.setObservers(collectionKey1, observers) Set a long delay for notifications to ensure we can detect if we're waiting 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) Verify that the notification process is started but not completed runCurrent() // Run the initial part of the background task assertTrue(emailService.isNotificationStarted()) assertFalse(emailService.isNotificationCompleted()) assertEquals(emptyList(), emailService.getEmailsSent()) Advance time to complete the notification process advanceUntilIdle() Verify that the notification process is completed and emails are sent 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 Create multiple comments with the same user IDs to test deduplication 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 Verify we got all 4 comments assertEquals(4, result.elements.size) Verify that findUserById was called exactly once for each unique user ID assertEquals(1, userService.getFindUserByIdCalls(user1.id)) assertEquals(1, userService.getFindUserByIdCalls(user2.id)) Verify the total number of calls matches the number of unique users 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() Update max concurrent notifications synchronized(this) { if (currentConcurrent > maxConcurrentNotifications) { maxConcurrentNotifications = currentConcurrent } } Simulate work with delay delay(notificationDelay) val body = "New comment in collection $collectionKey: $comment" Add the email to the list of sent emails 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 } } }