Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Notifications #2839

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package io.tolgee.api.v2.controllers

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import io.tolgee.hateoas.notification.NotificationEnhancer
import io.tolgee.hateoas.notification.NotificationModel
import io.tolgee.hateoas.notification.NotificationModelAssembler
import io.tolgee.model.Notification
import io.tolgee.security.authentication.AllowApiAccess
import io.tolgee.security.authentication.AuthenticationFacade
import io.tolgee.service.notification.NotificationService
import org.springdoc.core.annotations.ParameterObject
import org.springframework.data.domain.Pageable
import org.springframework.data.web.PagedResourcesAssembler
import org.springframework.hateoas.PagedModel
import org.springframework.web.bind.annotation.CrossOrigin
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@CrossOrigin(origins = ["*"])
@RequestMapping(
value = [
"/v2/notifications",
],
)
@Tag(name = "Notifications", description = "Manipulates notifications")
class NotificationController(
private val notificationService: NotificationService,
private val authenticationFacade: AuthenticationFacade,
private val enhancers: List<NotificationEnhancer>,
private val pagedResourcesAssembler: PagedResourcesAssembler<Notification>,
) {
@GetMapping
@Operation(summary = "Gets notifications of the currently logged in user, newest is first.")
@AllowApiAccess
fun getNotifications(
@ParameterObject pageable: Pageable,
): PagedModel<NotificationModel> {
val notifications = notificationService.getNotifications(authenticationFacade.authenticatedUser.id, pageable)
return pagedResourcesAssembler.toModel(notifications, NotificationModelAssembler(enhancers, notifications))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package io.tolgee.hateoas.notification

import io.tolgee.model.Notification

/**
* Dynamic component enhancing notifications by additional information if eligible,
* e.g. adding linked task.
*/
fun interface NotificationEnhancer {
/**
* Takes list of input Notification and output NotificationModel.
* It iterates over the pairs and alters the output NotificationModel by enhancing it of the new information.
*/
fun enhanceNotifications(notifications: Map<Notification, NotificationModel>)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.tolgee.hateoas.notification

import io.tolgee.hateoas.project.SimpleProjectModel
import io.tolgee.hateoas.task.TaskModel
import org.springframework.hateoas.RepresentationModel
import java.io.Serializable

data class NotificationModel(
val id: Long,
var project: SimpleProjectModel? = null,
var linkedTask: TaskModel? = null,
) : RepresentationModel<NotificationModel>(), Serializable
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package io.tolgee.hateoas.notification

import io.tolgee.api.v2.controllers.NotificationController
import io.tolgee.model.Notification
import org.springframework.data.domain.Page
import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport

class NotificationModelAssembler(
private val enhancers: List<NotificationEnhancer>,
private val notifications: Page<Notification>,
) : RepresentationModelAssemblerSupport<Notification, NotificationModel>(
NotificationController::class.java,
NotificationModel::class.java,
) {
private val prefetchedNotifications =
run {
val notificationsWithModel =
notifications.content.associateWith { notification ->
NotificationModel(
id = notification.id,
)
}
enhancers.forEach { enhancer ->
enhancer.enhanceNotifications(notificationsWithModel)
}
notificationsWithModel
}

override fun toModel(view: Notification): NotificationModel {
return prefetchedNotifications[view] ?: throw IllegalStateException("Notification $view was not found")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package io.tolgee.hateoas.notification

import io.tolgee.hateoas.project.SimpleProjectModelAssembler
import io.tolgee.model.Notification
import io.tolgee.service.project.ProjectService
import org.springframework.stereotype.Component

@Component
class ProjectNotificationEnhancer(
private val projectService: ProjectService,
private val simpleProjectModelAssembler: SimpleProjectModelAssembler,
) : NotificationEnhancer {
override fun enhanceNotifications(notifications: Map<Notification, NotificationModel>) {
val projectIds = notifications.mapNotNull { (source, _) -> source.project?.id }.distinct()
val projects = projectService.findAll(projectIds).associateBy { it.id }

notifications.forEach { (source, target) ->
target.project = source.project?.id.let { projects[it] }?.let { simpleProjectModelAssembler.toModel(it) }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package io.tolgee.api.v2.controllers

import io.tolgee.development.testDataBuilder.data.NotificationsTestData
import io.tolgee.fixtures.andAssertThatJson
import io.tolgee.testing.AuthorizedControllerTest
import org.junit.jupiter.api.Test

class NotificationControllerTest : AuthorizedControllerTest() {
@Test
fun `gets notifications from newest`() {
val testData = NotificationsTestData()

(101L..103).forEach { i ->
executeInNewTransaction {
val task =
testData.projectBuilder.addTask {
this.name = "Notification task $i"
this.language = testData.englishLanguage
this.author = testData.originatingUser.self
this.number = i
}

testData.userAccountBuilder.addNotification {
this.user = testData.user
this.project = testData.project
this.linkedTask = task.self
this.originatingUser = testData.originatingUser.self
}
}
}

testDataService.saveTestData(testData.root)
loginAsUser(testData.user.username)

performAuthGet("/v2/notifications").andAssertThatJson {
node("_embedded.notificationModelList[0].linkedTask.name").isEqualTo("Notification task 103")
node("_embedded.notificationModelList[1].linkedTask.name").isEqualTo("Notification task 102")
node("_embedded.notificationModelList[2].linkedTask.name").isEqualTo("Notification task 101")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ class BatchJobTestUtil(
port,
jwtService.emitToken(testData.user.id),
testData.projectBuilder.self.id,
testData.user.id,
)
websocketHelper.listenForBatchJobProgress()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,18 @@ import io.tolgee.development.testDataBuilder.data.BaseTestData
import io.tolgee.fixtures.andIsOk
import io.tolgee.fixtures.isValidId
import io.tolgee.fixtures.node
import io.tolgee.fixtures.waitFor
import io.tolgee.model.Notification
import io.tolgee.model.UserAccount
import io.tolgee.model.key.Key
import io.tolgee.model.translation.Translation
import io.tolgee.service.notification.NotificationService
import io.tolgee.testing.WebsocketTest
import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod
import io.tolgee.testing.assert
import net.javacrumbs.jsonunit.assertj.assertThatJson
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.*
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.web.server.LocalServerPort

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
Expand All @@ -24,33 +25,45 @@ abstract class AbstractWebsocketTest : ProjectAuthControllerTest("/v2/projects/"
lateinit var testData: BaseTestData
lateinit var translation: Translation
lateinit var key: Key
lateinit var notPermittedUser: UserAccount
lateinit var helper: WebsocketTestHelper
lateinit var anotherUser: UserAccount
lateinit var currentUserWebsocket: WebsocketTestHelper
lateinit var anotherUserWebsocket: WebsocketTestHelper

@Autowired
lateinit var notificationService: NotificationService

@LocalServerPort
private val port: Int? = null

@BeforeEach
fun beforeEach() {
prepareTestData()
helper =
currentUserWebsocket =
WebsocketTestHelper(
port,
jwtService.emitToken(testData.user.id),
testData.projectBuilder.self.id,
testData.user.id,
)
anotherUserWebsocket =
WebsocketTestHelper(
port,
jwtService.emitToken(anotherUser.id),
testData.projectBuilder.self.id,
anotherUser.id,
)
helper.listenForTranslationDataModified()
}

@AfterEach
fun after() {
helper.stop()
currentUserWebsocket.stop()
}

@Test
@ProjectJWTAuthTestMethod
fun `notifies on key modification`() {
helper.assertNotified(
currentUserWebsocket.listenForTranslationDataModified()
currentUserWebsocket.assertNotified(
{
performProjectAuthPut("keys/${key.id}", mapOf("name" to "name edited"))
},
Expand Down Expand Up @@ -85,7 +98,8 @@ abstract class AbstractWebsocketTest : ProjectAuthControllerTest("/v2/projects/"
@Test
@ProjectJWTAuthTestMethod
fun `notifies on key deletion`() {
helper.assertNotified(
currentUserWebsocket.listenForTranslationDataModified()
currentUserWebsocket.assertNotified(
{
performProjectAuthDelete("keys/${key.id}")
},
Expand Down Expand Up @@ -113,7 +127,8 @@ abstract class AbstractWebsocketTest : ProjectAuthControllerTest("/v2/projects/"
@Test
@ProjectJWTAuthTestMethod
fun `notifies on key creation`() {
helper.assertNotified(
currentUserWebsocket.listenForTranslationDataModified()
currentUserWebsocket.assertNotified(
{
performProjectAuthPost("keys", mapOf("name" to "new key"))
},
Expand Down Expand Up @@ -141,7 +156,8 @@ abstract class AbstractWebsocketTest : ProjectAuthControllerTest("/v2/projects/"
@Test
@ProjectJWTAuthTestMethod
fun `notifies on translation modification`() {
helper.assertNotified(
currentUserWebsocket.listenForTranslationDataModified()
currentUserWebsocket.assertNotified(
{
performProjectAuthPut(
"translations",
Expand Down Expand Up @@ -184,39 +200,80 @@ abstract class AbstractWebsocketTest : ProjectAuthControllerTest("/v2/projects/"
}
}

@Test
@ProjectJWTAuthTestMethod
fun `notifies user on change of his notifications`() {
currentUserWebsocket.listenForNotificationsChanged()
anotherUserWebsocket.listenForNotificationsChanged()

currentUserWebsocket.assertNotified(
{
saveNotificationForCurrentUser()
},
) {
assertThatJson(it.poll()).apply {
node("timestamp").isNotNull
}
}

anotherUserWebsocket.receivedMessages.assert.isEmpty()
}

/**
* The request is made by permitted user, but user without permission tries to listen, so they shell
* not be notified
*/
@Test
@ProjectJWTAuthTestMethod
fun `doesn't subscribe without permissions`() {
val notPermittedSubscriptionHelper =
WebsocketTestHelper(
port,
jwtService.emitToken(notPermittedUser.id),
testData.projectBuilder.self.id,
)
notPermittedSubscriptionHelper.listenForTranslationDataModified()
currentUserWebsocket.listenForTranslationDataModified()
anotherUserWebsocket.listenForTranslationDataModified()
performProjectAuthPut(
"translations",
mapOf(
"key" to key.name,
"translations" to mapOf("en" to "haha"),
),
).andIsOk
Thread.sleep(1000)
notPermittedSubscriptionHelper.receivedMessages.assert.isEmpty()

// but authorized user received the message
helper.receivedMessages.assert.isNotEmpty
assertCurrentUserReceivedMessage()
anotherUserWebsocket.receivedMessages.assert.isEmpty()
}

@Test
@ProjectJWTAuthTestMethod
fun `doesn't subscribe as another user`() {
currentUserWebsocket.listenForNotificationsChanged()
val spyingUserWebsocket =
WebsocketTestHelper(
port,
jwtService.emitToken(anotherUser.id),
testData.projectBuilder.self.id,
// anotherUser trying to spy on other user's websocket
testData.user.id,
)
spyingUserWebsocket.listenForNotificationsChanged()
saveNotificationForCurrentUser()

assertCurrentUserReceivedMessage()
spyingUserWebsocket.receivedMessages.assert.isEmpty()
}

private fun assertCurrentUserReceivedMessage() {
waitFor { currentUserWebsocket.receivedMessages.isNotEmpty() }
}

private fun saveNotificationForCurrentUser() {
executeInNewTransaction {
notificationService.save(Notification().apply { user = testData.user })
}
}

private fun prepareTestData() {
testData = BaseTestData()
testData.root.addUserAccount {
username = "franta"
notPermittedUser = this
username = "anotherUser"
anotherUser = this
}
testData.projectBuilder.apply {
addKey {
Expand Down
Loading
Loading