remove raiseEvent for notifications

Add Test for EventNotification
Add application.conf for test
This commit is contained in:
2021-01-26 23:58:25 +01:00
parent aa95de7a6a
commit 1c644768e6
6 changed files with 225 additions and 81 deletions

View File

@@ -34,7 +34,6 @@ import fr.dcproject.component.vote.voteKoinModule
import fr.dcproject.component.workgroup.routes.installWorkgroupRoutes import fr.dcproject.component.workgroup.routes.installWorkgroupRoutes
import fr.dcproject.component.workgroup.workgroupKoinModule import fr.dcproject.component.workgroup.workgroupKoinModule
import fr.dcproject.event.EventNotification import fr.dcproject.event.EventNotification
import fr.dcproject.event.EventSubscriber
import fr.dcproject.routes.definition import fr.dcproject.routes.definition
import fr.dcproject.routes.notificationArticle import fr.dcproject.routes.notificationArticle
import fr.dcproject.security.AccessDeniedException import fr.dcproject.security.AccessDeniedException
@@ -124,9 +123,7 @@ fun Application.module(env: Env = PROD) {
masking = false masking = false
} }
install(EventSubscriber) { EventNotification(get(), get(), get(), get(), get(), Configuration.exchangeNotificationName, get()).config()
EventNotification(this, get(), get(), get(), get(), get(), Configuration.exchangeNotificationName).config()
}
install(Authentication, jwtInstallation(get())) install(Authentication, jwtInstallation(get()))

View File

@@ -8,9 +8,8 @@ import fr.dcproject.component.article.routes.UpsertArticle.UpsertArticleRequest.
import fr.dcproject.component.auth.citizen import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.workgroup.WorkgroupRef import fr.dcproject.component.workgroup.WorkgroupRef
import fr.dcproject.component.workgroup.WorkgroupRepository
import fr.dcproject.event.ArticleUpdate import fr.dcproject.event.ArticleUpdate
import fr.dcproject.event.raiseEvent import fr.dcproject.event.publisher.Publisher
import fr.dcproject.security.assert import fr.dcproject.security.assert
import io.ktor.application.ApplicationCall import io.ktor.application.ApplicationCall
import io.ktor.application.call import io.ktor.application.call
@@ -35,11 +34,11 @@ object UpsertArticle {
val tags: List<String> = emptyList(), val tags: List<String> = emptyList(),
val draft: Boolean = false, val draft: Boolean = false,
val versionId: UUID, val versionId: UUID,
val workgroup: WorkgroupRef? = null val workgroup: WorkgroupRef? = null,
) )
} }
fun Route.upsertArticle(repo: ArticleRepository, workgroupRepository: WorkgroupRepository, ac: ArticleAccessControl) { fun Route.upsertArticle(repo: ArticleRepository, publisher: Publisher, ac: ArticleAccessControl) {
suspend fun ApplicationCall.convertRequestToEntity(): ArticleForUpdate = receive<Input>().run { suspend fun ApplicationCall.convertRequestToEntity(): ArticleForUpdate = receive<Input>().run {
ArticleForUpdate( ArticleForUpdate(
id = id ?: UUID.randomUUID(), id = id ?: UUID.randomUUID(),
@@ -60,7 +59,7 @@ object UpsertArticle {
ac.assert { canUpsert(article, citizenOrNull) } ac.assert { canUpsert(article, citizenOrNull) }
val newArticle: ArticleForView = repo.upsert(article) ?: error("Article not updated") val newArticle: ArticleForView = repo.upsert(article) ?: error("Article not updated")
call.respond(newArticle) call.respond(newArticle)
raiseEvent(ArticleUpdate.event, ArticleUpdate(newArticle)) publisher.publish(ArticleUpdate(newArticle))
} }
} }
} }

View File

@@ -1,5 +1,8 @@
package fr.dcproject.event package fr.dcproject.event
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.rabbitmq.client.AMQP import com.rabbitmq.client.AMQP
import com.rabbitmq.client.BuiltinExchangeType.DIRECT import com.rabbitmq.client.BuiltinExchangeType.DIRECT
import com.rabbitmq.client.ConnectionFactory import com.rabbitmq.client.ConnectionFactory
@@ -9,24 +12,18 @@ import com.rabbitmq.client.Envelope
import fr.dcproject.common.entity.TargetRef import fr.dcproject.common.entity.TargetRef
import fr.dcproject.component.article.ArticleForView import fr.dcproject.component.article.ArticleForView
import fr.dcproject.component.citizen.CitizenRef import fr.dcproject.component.citizen.CitizenRef
import fr.dcproject.component.follow.FollowRepository import fr.dcproject.component.follow.FollowArticleRepository
import fr.dcproject.component.follow.FollowConstitutionRepository
import fr.dcproject.component.follow.FollowSimple import fr.dcproject.component.follow.FollowSimple
import fr.dcproject.event.publisher.Publisher import fr.dcproject.event.publisher.Publisher
import fr.dcproject.messages.NotificationEmailSender import fr.dcproject.messages.NotificationEmailSender
import fr.postgresjson.serializer.deserialize
import io.ktor.application.ApplicationCall
import io.ktor.application.EventDefinition import io.ktor.application.EventDefinition
import io.ktor.application.application
import io.ktor.util.pipeline.PipelineContext
import io.ktor.utils.io.errors.IOException import io.ktor.utils.io.errors.IOException
import io.lettuce.core.api.async.RedisAsyncCommands import io.lettuce.core.api.async.RedisAsyncCommands
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import fr.dcproject.component.follow.FollowArticleRepository as FollowArticleRepository
class ArticleUpdate( class ArticleUpdate(
target: ArticleForView target: ArticleForView
@@ -36,18 +33,18 @@ class ArticleUpdate(
} }
} }
fun <T : Event> PipelineContext<Unit, ApplicationCall>.raiseEvent(definition: EventDefinition<T>, value: T) =
application.environment.monitor.raise(definition, value)
class EventNotification( class EventNotification(
private val config: EventSubscriber.Configuration,
private val rabbitFactory: ConnectionFactory, private val rabbitFactory: ConnectionFactory,
private val redis: RedisAsyncCommands<String, String>, private val redis: RedisAsyncCommands<String, String>,
private val followRepo: FollowArticleRepository, private val followConstitutionRepo: FollowConstitutionRepository,
private val publisher: Publisher, private val followArticleRepo: FollowArticleRepository,
private val notificationEmailSender: NotificationEmailSender, private val notificationEmailSender: NotificationEmailSender,
private val exchangeName: String, private val exchangeName: String,
mapper: ObjectMapper,
) { ) {
private val mapper: ObjectMapper = mapper.copy()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
private val logger: Logger = LoggerFactory.getLogger(Publisher::class.qualifiedName) private val logger: Logger = LoggerFactory.getLogger(Publisher::class.qualifiedName)
fun config() { fun config() {
@@ -62,70 +59,61 @@ class EventNotification(
} }
} }
/* Declare publisher on event */ /* Define Consumer */
config.subscribe(ArticleUpdate.event) { val rabbitChannel = rabbitFactory.newConnection().createChannel()
publisher.publish(it)
val consumerPush: Consumer = object : DefaultConsumer(rabbitChannel) {
@Throws(IOException::class)
override fun handleDelivery(
consumerTag: String,
envelope: Envelope,
properties: AMQP.BasicProperties,
body: ByteArray
) = runBlocking {
decodeEvent(body) {
redis.zadd(
"notification:${it.follow.createdBy.id}",
it.event.id,
it.rawEvent
)
}
rabbitChannel.basicAck(envelope.deliveryTag, false)
}
} }
/* Launch Consumer */ val consumerEmail: Consumer = object : DefaultConsumer(rabbitChannel) {
GlobalScope.launch { @Throws(IOException::class)
val rabbitChannel = rabbitFactory.newConnection().createChannel() override fun handleDelivery(
consumerTag: String,
val consumerPush: Consumer = object : DefaultConsumer(rabbitChannel) { envelope: Envelope,
@Throws(IOException::class) properties: AMQP.BasicProperties,
override fun handleDelivery( body: ByteArray
consumerTag: String, ) {
envelope: Envelope, runBlocking {
properties: AMQP.BasicProperties,
body: ByteArray
) = runBlocking {
decodeEvent(body) { decodeEvent(body) {
redis.zadd( notificationEmailSender.sendEmail(it.follow)
"notification:${follow.createdBy.id}", logger.debug("EmailSend to: ${it.follow.createdBy.id}")
event.id,
rawEvent
)
} }
rabbitChannel.basicAck(envelope.deliveryTag, false)
} }
rabbitChannel.basicAck(envelope.deliveryTag, false)
} }
val consumerEmail: Consumer = object : DefaultConsumer(rabbitChannel) {
@Throws(IOException::class)
override fun handleDelivery(
consumerTag: String,
envelope: Envelope,
properties: AMQP.BasicProperties,
body: ByteArray
) {
runBlocking {
decodeEvent(body) {
logger.debug("EmailSend to: ${follow.createdBy.id}")
notificationEmailSender.sendEmail(follow)
}
}
rabbitChannel.basicAck(envelope.deliveryTag, false)
}
}
rabbitChannel.basicConsume("push", false, consumerPush) // The front consume the redis via Websocket
rabbitChannel.basicConsume("email", false, consumerEmail)
} }
/* Launch Consumer */
rabbitChannel.basicConsume("push", false, consumerPush) // The front consume the redis via Websocket
rabbitChannel.basicConsume("email", false, consumerEmail)
} }
private suspend fun decodeEvent(body: ByteArray, action: suspend Msg.() -> Unit) { private suspend fun decodeEvent(body: ByteArray, action: suspend (Msg) -> Unit) {
val rawEvent = body.toString(Charsets.UTF_8) val rawEvent: String = body.toString(Charsets.UTF_8)
val event = rawEvent.deserialize<EntityEvent>() ?: error("Unable to unserialise event message from rabbit") val event: EntityEvent = mapper.readValue(rawEvent) ?: error("Unable to deserialize event message from rabbit")
val repo = when (event.type) { val targets = when (event.type) {
"article" -> followRepo "article" -> followArticleRepo.findFollowsByTarget(event.target)
"constitution" -> followConstitutionRepo.findFollowsByTarget(event.target)
else -> error("event '${event.type}' not implemented") else -> error("event '${event.type}' not implemented")
} as FollowRepository<*, *> }
repo targets.collect { action(Msg(event, rawEvent, it)) }
.findFollowsByTarget(event.target)
.collect {
Msg(event, rawEvent, it).action()
}
} }
private class Msg( private class Msg(

View File

@@ -3,9 +3,9 @@ package fr.dcproject.event.publisher
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.rabbitmq.client.ConnectionFactory import com.rabbitmq.client.ConnectionFactory
import fr.dcproject.event.EntityEvent import fr.dcproject.event.EntityEvent
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.coroutineScope
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@@ -15,8 +15,8 @@ class Publisher(
private val logger: Logger = LoggerFactory.getLogger(Publisher::class.qualifiedName), private val logger: Logger = LoggerFactory.getLogger(Publisher::class.qualifiedName),
private val exchangeName: String, private val exchangeName: String,
) { ) {
fun <T : EntityEvent> publish(it: T): Job { suspend fun <T : EntityEvent> publish(it: T): Deferred<Unit> = coroutineScope {
return GlobalScope.launch { async {
factory.newConnection().use { connection -> factory.newConnection().use { connection ->
connection.createChannel().use { channel -> connection.createChannel().use { channel ->
channel.basicPublish(exchangeName, "", null, it.serialize().toByteArray()) channel.basicPublish(exchangeName, "", null, it.serialize().toByteArray())

View File

@@ -0,0 +1,116 @@
package functional
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.datatype.joda.JodaModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.rabbitmq.client.ConnectionFactory
import fr.dcproject.application.Configuration
import fr.dcproject.component.article.ArticleForView
import fr.dcproject.component.article.ArticleRef
import fr.dcproject.component.citizen.CitizenRef
import fr.dcproject.component.follow.FollowArticleRepository
import fr.dcproject.component.follow.FollowSimple
import fr.dcproject.event.ArticleUpdate
import fr.dcproject.event.EventNotification
import fr.dcproject.event.publisher.Publisher
import fr.dcproject.messages.NotificationEmailSender
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.util.KtorExperimentalAPI
import io.lettuce.core.RedisClient
import io.lettuce.core.api.async.RedisAsyncCommands
import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.InternalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.koin.test.AutoCloseKoinTest
import org.koin.test.KoinTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class EventNotificationTest : KoinTest, AutoCloseKoinTest() {
@InternalCoroutinesApi
@KtorExperimentalLocationsAPI
@KtorExperimentalAPI
@ExperimentalCoroutinesApi
@Test
@Tag("functional")
fun `can be send notification`() = runBlocking {
/* Create mocks and spy's */
val emailSender = mockk<NotificationEmailSender>() {
every { sendEmail(any()) } returns Unit
}
val redisClient = spyk<RedisAsyncCommands<String, String>> {
RedisClient.create(Configuration.redis).connect().async() ?: error("Unable to connect to redis")
}
val rabbitFactory: ConnectionFactory = spyk {
ConnectionFactory().apply { setUri(Configuration.rabbitmq) }
}
val followArticleRepo = mockk<FollowArticleRepository> {
every { findFollowsByTarget(any()) } returns flow {
FollowSimple(
createdBy = CitizenRef(),
target = ArticleRef(),
).let { emit(it) }
}
}
val mapper = jacksonObjectMapper().apply {
registerModule(SimpleModule())
propertyNamingStrategy = PropertyNamingStrategies.SNAKE_CASE
registerModule(JodaModule())
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true)
}
/* Purge rabbit notification queues */
rabbitFactory.newConnection().createChannel().apply {
queuePurge("push")
queuePurge("email")
}
/* Config consumer */
EventNotification(
rabbitFactory = rabbitFactory,
redis = redisClient,
followArticleRepo = followArticleRepo,
followConstitutionRepo = mockk(),
notificationEmailSender = emailSender,
exchangeName = "notification_test",
mapper = mapper,
).config()
verify { rabbitFactory.newConnection() }
/* Push message */
Publisher(
mapper = mapper,
factory = rabbitFactory,
exchangeName = "notification_test",
).publish(
ArticleUpdate(
ArticleForView(
title = "MyTitle",
content = "myContent",
description = "myDescription",
createdBy = CitizenRef()
)
)
).await()
/* Wait to receive message */
delay(300)
/* Check if notifications sent */
verify { followArticleRepo.findFollowsByTarget(any()) }
verify { emailSender.sendEmail(any()) }
verify { redisClient.zadd(any<String>(), any<Double>(), any<String>()) }
}
}

View File

@@ -0,0 +1,44 @@
ktor {
deployment {
port = 8080
port = ${?PORT}
}
application {
modules = [ fr.dcproject.ApplicationKt.module ]
}
}
app {
envName = prod
domain = dc-project.fr
}
db {
host = localhost
host = ${?DB_HOST}
database = test
username = test
password = test
port = 5432
}
redis {
connection = "redis://localhost:6379"
connection = ${?REDIS_CONNECTION}
}
rabbitmq {
connection = "amqp://localhost:5672"
connection = ${?RABBITMQ_CONNECTION}
}
elasticsearch {
connection = "http://localhost:9200"
connection = ${?ELASTICSEARCH_CONNECTION}
}
mail {
sendGrid {
key = "abcd"
}
}