diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 3fc3a47e..d4e7a9b3 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -66,6 +66,9 @@ extra["commons-lang3.version"] = "3.18.0" // logback-core 1.5.34 -> 1.5.35 fixes CVE (object injection via HardenedObjectInputStream, Dependabot #79). // Spring Boot 3.5.16 BOM pins 1.5.34; override the shared property so logback-core AND logback-classic move together. extra["logback.version"] = "1.5.35" +// postgresql 42.7.11 -> 42.7.12 fixes CVE-2026-54291 (HIGH). Spring Boot 3.5.16 BOM pins 42.7.11; +// override the managed property so the runtime JDBC driver picks up the patched release. +extra["postgresql.version"] = "42.7.12" // Override Spring Boot BOM version for Testcontainers to support Docker Desktop 4.x on Windows dependencyManagement { diff --git a/backend/gradle.lockfile b/backend/gradle.lockfile index 42f0cf5b..aa7f49e2 100644 --- a/backend/gradle.lockfile +++ b/backend/gradle.lockfile @@ -141,7 +141,7 @@ org.mockito:mockito-junit-jupiter:5.17.0=testCompileClasspath,testRuntimeClasspa org.objenesis:objenesis:3.4=testCompileClasspath,testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath org.ow2.asm:asm:9.7.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.postgresql:postgresql:42.7.11=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.postgresql:postgresql:42.7.12=productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.reactivestreams:reactive-streams:1.0.4=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.rnorth.duct-tape:duct-tape:1.0.8=testCompileClasspath,testRuntimeClasspath org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath diff --git a/backend/src/main/kotlin/com/taskowolf/automation/application/ActionExecutor.kt b/backend/src/main/kotlin/com/taskowolf/automation/application/ActionExecutor.kt index 539fc134..16e91e4d 100644 --- a/backend/src/main/kotlin/com/taskowolf/automation/application/ActionExecutor.kt +++ b/backend/src/main/kotlin/com/taskowolf/automation/application/ActionExecutor.kt @@ -55,9 +55,10 @@ class ActionExecutor( notificationService.createDirect( userId = recipientId, type = NotificationType.AUTOMATION, - title = "Automation: ${issue.key}", - body = message, - link = "/p/${issue.project.key}/issues/${issue.key}" + titleKey = "notification.automation.title", + link = "/p/${issue.project.key}/issues/${issue.key}", + titleArgs = arrayOf(issue.key), + rawBody = message, ) } ActionType.CREATE_COMMENT -> { diff --git a/backend/src/main/kotlin/com/taskowolf/notifications/application/EmailService.kt b/backend/src/main/kotlin/com/taskowolf/notifications/application/EmailService.kt index fb0ebfe7..681b3d1c 100644 --- a/backend/src/main/kotlin/com/taskowolf/notifications/application/EmailService.kt +++ b/backend/src/main/kotlin/com/taskowolf/notifications/application/EmailService.kt @@ -1,6 +1,7 @@ package com.taskowolf.notifications.application import com.taskowolf.comments.domain.events.MentionEvent +import com.taskowolf.core.infrastructure.LocalizedMessages import com.taskowolf.issues.domain.events.IssueFieldChangedEvent import com.taskowolf.notifications.domain.NotificationChannel import com.taskowolf.notifications.domain.NotificationType @@ -15,7 +16,8 @@ class EmailService( private val preferences: NotificationPreferenceService, private val mailSender: JavaMailSender, @Value("\${spring.mail.host:}") private val mailHost: String, - @Value("\${taskowolf.smtp.from:TaskWolf }") private val fromAddress: String + @Value("\${taskowolf.smtp.from:TaskWolf }") private val fromAddress: String, + private val localizedMessages: LocalizedMessages ) { private val enabled get() = mailHost.isNotBlank() @@ -23,15 +25,15 @@ class EmailService( fun onMention(event: MentionEvent) { if (!enabled) return if (!preferences.isEnabled(event.mentionedUser.id, NotificationType.COMMENT_MENTION, NotificationChannel.EMAIL)) return + val locale = localizedMessages.localeOf(event.mentionedUser) val msg = SimpleMailMessage().apply { from = fromAddress setTo(event.mentionedUser.email) - subject = "You were mentioned in ${event.issue.key}" - text = """ - |${event.issue.key}: ${event.issue.title} - | - |${event.comment.body.take(500)} - """.trimMargin() + subject = localizedMessages.get("email.mention.subject", locale, event.issue.key) + text = localizedMessages.get( + "email.mention.body", locale, + event.issue.key, event.issue.title, event.comment.body.take(500) + ) } mailSender.send(msg) } @@ -41,14 +43,12 @@ class EmailService( if (!enabled || event.field != "assignee") return val assignee = event.issue.assignee ?: return if (!preferences.isEnabled(assignee.id, NotificationType.ISSUE_ASSIGNED, NotificationChannel.EMAIL)) return + val locale = localizedMessages.localeOf(assignee) val msg = SimpleMailMessage().apply { from = fromAddress setTo(assignee.email) - subject = "You were assigned to ${event.issue.key}" - text = """ - |You have been assigned to: ${event.issue.key} - |${event.issue.title} - """.trimMargin() + subject = localizedMessages.get("email.assigned.subject", locale, event.issue.key) + text = localizedMessages.get("email.assigned.body", locale, event.issue.key, event.issue.title) } mailSender.send(msg) } diff --git a/backend/src/main/kotlin/com/taskowolf/notifications/application/NotificationService.kt b/backend/src/main/kotlin/com/taskowolf/notifications/application/NotificationService.kt index 53a3ecfc..b278280a 100644 --- a/backend/src/main/kotlin/com/taskowolf/notifications/application/NotificationService.kt +++ b/backend/src/main/kotlin/com/taskowolf/notifications/application/NotificationService.kt @@ -1,6 +1,8 @@ package com.taskowolf.notifications.application +import com.taskowolf.auth.infrastructure.UserRepository import com.taskowolf.comments.domain.events.MentionEvent +import com.taskowolf.core.infrastructure.LocalizedMessages import com.taskowolf.core.infrastructure.NotFoundException import com.taskowolf.issues.domain.events.IssueFieldChangedEvent import com.taskowolf.notifications.domain.Notification @@ -12,23 +14,27 @@ import org.springframework.data.domain.Page import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.util.Locale import java.util.UUID @Service class NotificationService( private val repository: NotificationRepository, - private val preferences: NotificationPreferenceService + private val preferences: NotificationPreferenceService, + private val localizedMessages: LocalizedMessages, + private val userRepository: UserRepository ) { @EventListener @Transactional fun onMention(event: MentionEvent) { if (!preferences.isEnabled(event.mentionedUser.id, NotificationType.COMMENT_MENTION, NotificationChannel.IN_APP)) return + val locale = localizedMessages.localeOf(event.mentionedUser) repository.save( Notification( userId = event.mentionedUser.id, type = NotificationType.COMMENT_MENTION, - title = "You were mentioned in ${event.issue.key}", + title = localizedMessages.get("notification.mention.title", locale, event.issue.key), body = event.comment.body.take(200), link = "/issues/${event.issue.key}" ) @@ -41,11 +47,12 @@ class NotificationService( if (event.field != "assignee") return val assignee = event.issue.assignee ?: return if (!preferences.isEnabled(assignee.id, NotificationType.ISSUE_ASSIGNED, NotificationChannel.IN_APP)) return + val locale = localizedMessages.localeOf(assignee) repository.save( Notification( userId = assignee.id, type = NotificationType.ISSUE_ASSIGNED, - title = "You were assigned to ${event.issue.key}", + title = localizedMessages.get("notification.assigned.title", locale, event.issue.key), body = event.issue.title, link = "/issues/${event.issue.key}" ) @@ -70,9 +77,27 @@ class NotificationService( @Transactional(readOnly = true) fun countUnread(userId: UUID): Long = repository.countByUserIdAndReadFalse(userId) + /** + * Creates a notification, rendering [titleKey] (and optional [bodyKey]) in the recipient's + * stored language. Pass [rawBody] for a user-content body that must not be translated; if + * neither [bodyKey] nor [rawBody] is set, the body is empty. + */ @Transactional - fun createDirect(userId: UUID, type: NotificationType, title: String, body: String, link: String) { + fun createDirect( + userId: UUID, + type: NotificationType, + titleKey: String, + link: String, + titleArgs: Array = emptyArray(), + bodyKey: String? = null, + bodyArgs: Array = emptyArray(), + rawBody: String? = null, + ) { if (!preferences.isEnabled(userId, type, NotificationChannel.IN_APP)) return + val locale = userRepository.findById(userId) + .map(localizedMessages::localeOf).orElse(Locale.ENGLISH) + val title = localizedMessages.get(titleKey, locale, *titleArgs) + val body = if (bodyKey != null) localizedMessages.get(bodyKey, locale, *bodyArgs) else rawBody.orEmpty() repository.save(Notification(userId = userId, type = type, title = title, body = body, link = link)) } } diff --git a/backend/src/main/kotlin/com/taskowolf/servicedesk/application/IncidentService.kt b/backend/src/main/kotlin/com/taskowolf/servicedesk/application/IncidentService.kt index acbd624d..cb303ac1 100644 --- a/backend/src/main/kotlin/com/taskowolf/servicedesk/application/IncidentService.kt +++ b/backend/src/main/kotlin/com/taskowolf/servicedesk/application/IncidentService.kt @@ -2,6 +2,7 @@ package com.taskowolf.servicedesk.application import com.taskowolf.comments.domain.Comment import com.taskowolf.comments.infrastructure.CommentRepository +import com.taskowolf.issues.infrastructure.IssueRepository import com.taskowolf.notifications.application.NotificationService import com.taskowolf.notifications.domain.NotificationType import com.taskowolf.servicedesk.domain.Incident @@ -16,7 +17,8 @@ import java.util.UUID class IncidentService( private val incidentRepo: IncidentRepository, private val commentRepo: CommentRepository, - private val notificationService: NotificationService + private val notificationService: NotificationService, + private val issueRepository: IssueRepository ) { companion object { /** Null authorId used for system-generated comments (no FK violation since author_id is nullable). */ @@ -26,14 +28,19 @@ class IncidentService( @Transactional fun create(issueId: UUID, severity: IncidentSeverity, onCallAssigneeId: UUID?, notifyUserIds: List): Incident { val incident = incidentRepo.save(Incident(issueId = issueId, severity = severity, onCallAssigneeId = onCallAssigneeId)) - notifyUserIds.forEach { uid -> - notificationService.createDirect( - userId = uid, - type = NotificationType.AUTOMATION, - title = "Incident declared: ${severity.name} on issue $issueId", - body = "A ${severity.name} incident has been declared for issue $issueId.", - link = "/issues/$issueId" - ) + if (notifyUserIds.isNotEmpty()) { + val issueKey = issueRepository.findById(issueId).map { it.key }.orElse(issueId.toString()) + notifyUserIds.forEach { uid -> + notificationService.createDirect( + userId = uid, + type = NotificationType.AUTOMATION, + titleKey = "notification.incident.title", + link = "/issues/$issueKey", + titleArgs = arrayOf(severity.name, issueKey), + bodyKey = "notification.incident.body", + bodyArgs = arrayOf(severity.name, issueKey), + ) + } } return incident } diff --git a/backend/src/main/kotlin/com/taskowolf/servicedesk/application/SlaMonitorJob.kt b/backend/src/main/kotlin/com/taskowolf/servicedesk/application/SlaMonitorJob.kt index 36c0f19e..f06f7cf1 100644 --- a/backend/src/main/kotlin/com/taskowolf/servicedesk/application/SlaMonitorJob.kt +++ b/backend/src/main/kotlin/com/taskowolf/servicedesk/application/SlaMonitorJob.kt @@ -50,9 +50,11 @@ class SlaMonitorJob( notificationService.createDirect( userId = uid, type = NotificationType.SLA_BREACHED, - title = "SLA Breached: ${issue.key}", - body = "Issue ${issue.key} has exceeded its SLA resolution time of ${policy.resolutionMinutes} minutes.", - link = "/issues/${issue.key}" + titleKey = "notification.slaBreached.title", + link = "/issues/${issue.key}", + titleArgs = arrayOf(issue.key), + bodyKey = "notification.slaBreached.body", + bodyArgs = arrayOf(issue.key, policy.resolutionMinutes.toString()), ) } } diff --git a/backend/src/main/resources/messages.properties b/backend/src/main/resources/messages.properties index e518a395..c8d6b7f4 100644 --- a/backend/src/main/resources/messages.properties +++ b/backend/src/main/resources/messages.properties @@ -140,3 +140,17 @@ serviceDesk.notEnabled=Service desk not enabled for project: {0} serviceDesk.invalidPriority=Invalid priority: {0}. Valid values: {1} # --- incident --- incident.invalidSeverity=Invalid severity ''{0}''. Must be one of: {1} + +# --- email --- +email.mention.subject=You were mentioned in {0} +email.mention.body={0}: {1}\n\n{2} +email.assigned.subject=You were assigned to {0} +email.assigned.body=You have been assigned to: {0}\n{1} +# --- notification titles + templated bodies --- +notification.mention.title=You were mentioned in {0} +notification.assigned.title=You were assigned to {0} +notification.incident.title=Incident declared: {0} on issue {1} +notification.incident.body=A {0} incident has been declared for issue {1}. +notification.slaBreached.title=SLA Breached: {0} +notification.slaBreached.body=Issue {0} has exceeded its SLA resolution time of {1} minutes. +notification.automation.title=Automation: {0} diff --git a/backend/src/main/resources/messages_de.properties b/backend/src/main/resources/messages_de.properties index 9b6b1c76..62f5b060 100644 --- a/backend/src/main/resources/messages_de.properties +++ b/backend/src/main/resources/messages_de.properties @@ -140,3 +140,17 @@ serviceDesk.notEnabled=Servicedesk ist f\u00fcr Projekt {0} nicht aktiviert serviceDesk.invalidPriority=Ung\u00fcltige Priorit\u00e4t: {0}. G\u00fcltige Werte: {1} # --- incident --- incident.invalidSeverity=Ung\u00fcltiger Schweregrad ''{0}''. Muss einer von: {1} + +# --- email --- +email.mention.subject=Sie wurden in {0} erw\u00e4hnt +email.mention.body={0}: {1}\n\n{2} +email.assigned.subject=Ihnen wurde {0} zugewiesen +email.assigned.body=Sie wurden zugewiesen zu: {0}\n{1} +# --- notification titles + templated bodies --- +notification.mention.title=Sie wurden in {0} erw\u00e4hnt +notification.assigned.title=Ihnen wurde {0} zugewiesen +notification.incident.title=Incident gemeldet: {0} f\u00fcr Vorgang {1} +notification.incident.body=Ein {0}-Incident wurde f\u00fcr Vorgang {1} gemeldet. +notification.slaBreached.title=SLA verletzt: {0} +notification.slaBreached.body=Vorgang {0} hat seine SLA-L\u00f6sungszeit von {1} Minuten \u00fcberschritten. +notification.automation.title=Automatisierung: {0} diff --git a/backend/src/test/kotlin/com/taskowolf/automation/ActionExecutorTest.kt b/backend/src/test/kotlin/com/taskowolf/automation/ActionExecutorTest.kt index bb30a801..e4edbb25 100644 --- a/backend/src/test/kotlin/com/taskowolf/automation/ActionExecutorTest.kt +++ b/backend/src/test/kotlin/com/taskowolf/automation/ActionExecutorTest.kt @@ -60,6 +60,17 @@ class ActionExecutorTest { fun `SEND_NOTIFICATION calls notificationService`() { val issue = makeIssue() executor.execute(listOf(makeAction(ActionType.SEND_NOTIFICATION, """{"message":"Hello"}""")), issue) - verify { notificationService.createDirect(any(), NotificationType.AUTOMATION, any(), any(), any()) } + verify { + notificationService.createDirect( + userId = any(), + type = NotificationType.AUTOMATION, + titleKey = "notification.automation.title", + link = any(), + titleArgs = any(), + bodyKey = any(), + bodyArgs = any(), + rawBody = "Hello", + ) + } } } diff --git a/backend/src/test/kotlin/com/taskowolf/i18n/EmailNotificationMessagesTest.kt b/backend/src/test/kotlin/com/taskowolf/i18n/EmailNotificationMessagesTest.kt new file mode 100644 index 00000000..30793756 --- /dev/null +++ b/backend/src/test/kotlin/com/taskowolf/i18n/EmailNotificationMessagesTest.kt @@ -0,0 +1,56 @@ +package com.taskowolf.i18n + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.springframework.context.support.ResourceBundleMessageSource +import java.util.Locale + +class EmailNotificationMessagesTest { + private val src = ResourceBundleMessageSource().apply { + setBasename("messages"); setDefaultEncoding("UTF-8"); setFallbackToSystemLocale(false) + } + private fun en(key: String, vararg a: Any?) = src.getMessage(key, a, Locale.ENGLISH) + private fun de(key: String, vararg a: Any?) = src.getMessage(key, a, Locale.GERMAN) + + @Test + fun `email keys render en and de`() { + assertEquals("You were mentioned in WOLF-1", en("email.mention.subject", "WOLF-1")) + assertEquals("Sie wurden in WOLF-1 erwähnt", de("email.mention.subject", "WOLF-1")) + assertEquals("WOLF-1: My Issue\n\nGreat comment", + en("email.mention.body", "WOLF-1", "My Issue", "Great comment")) + assertEquals("WOLF-1: My Issue\n\nGreat comment", + de("email.mention.body", "WOLF-1", "My Issue", "Great comment")) + assertEquals("You were assigned to WOLF-1", en("email.assigned.subject", "WOLF-1")) + assertEquals("Ihnen wurde WOLF-1 zugewiesen", de("email.assigned.subject", "WOLF-1")) + assertEquals("You have been assigned to: WOLF-1\nMy Issue", + en("email.assigned.body", "WOLF-1", "My Issue")) + assertEquals("Sie wurden zugewiesen zu: WOLF-1\nMy Issue", + de("email.assigned.body", "WOLF-1", "My Issue")) + } + + @Test + fun `notification title keys render en and de`() { + assertEquals("You were mentioned in WOLF-1", en("notification.mention.title", "WOLF-1")) + assertEquals("Sie wurden in WOLF-1 erwähnt", de("notification.mention.title", "WOLF-1")) + assertEquals("You were assigned to WOLF-1", en("notification.assigned.title", "WOLF-1")) + assertEquals("Ihnen wurde WOLF-1 zugewiesen", de("notification.assigned.title", "WOLF-1")) + assertEquals("Automation: WOLF-1", en("notification.automation.title", "WOLF-1")) + assertEquals("Automatisierung: WOLF-1", de("notification.automation.title", "WOLF-1")) + } + + @Test + fun `incident and sla keys render en and de (issue key + numeric-as-string)`() { + assertEquals("Incident declared: P1 on issue WOLF-1", en("notification.incident.title", "P1", "WOLF-1")) + assertEquals("Incident gemeldet: P1 für Vorgang WOLF-1", de("notification.incident.title", "P1", "WOLF-1")) + assertEquals("A P1 incident has been declared for issue WOLF-1.", + en("notification.incident.body", "P1", "WOLF-1")) + assertEquals("Ein P1-Incident wurde für Vorgang WOLF-1 gemeldet.", + de("notification.incident.body", "P1", "WOLF-1")) + assertEquals("SLA Breached: WOLF-1", en("notification.slaBreached.title", "WOLF-1")) + assertEquals("SLA verletzt: WOLF-1", de("notification.slaBreached.title", "WOLF-1")) + assertEquals("Issue WOLF-1 has exceeded its SLA resolution time of 1440 minutes.", + en("notification.slaBreached.body", "WOLF-1", "1440")) + assertEquals("Vorgang WOLF-1 hat seine SLA-Lösungszeit von 1440 Minuten überschritten.", + de("notification.slaBreached.body", "WOLF-1", "1440")) + } +} diff --git a/backend/src/test/kotlin/com/taskowolf/notifications/EmailServiceTest.kt b/backend/src/test/kotlin/com/taskowolf/notifications/EmailServiceTest.kt index 274103d8..933f11da 100644 --- a/backend/src/test/kotlin/com/taskowolf/notifications/EmailServiceTest.kt +++ b/backend/src/test/kotlin/com/taskowolf/notifications/EmailServiceTest.kt @@ -3,78 +3,127 @@ package com.taskowolf.notifications import com.taskowolf.auth.domain.User import com.taskowolf.comments.domain.Comment import com.taskowolf.comments.domain.events.MentionEvent +import com.taskowolf.core.infrastructure.LocalizedMessages import com.taskowolf.issues.domain.Issue import com.taskowolf.issues.domain.IssueType import com.taskowolf.issues.domain.events.IssueFieldChangedEvent import com.taskowolf.notifications.application.EmailService +import com.taskowolf.notifications.application.NotificationPreferenceService +import com.taskowolf.notifications.domain.NotificationChannel +import com.taskowolf.notifications.domain.NotificationType import com.taskowolf.projects.domain.Project import com.taskowolf.workflows.domain.StatusCategory import com.taskowolf.workflows.domain.Workflow import com.taskowolf.workflows.domain.WorkflowStatus import io.mockk.every import io.mockk.mockk +import io.mockk.slot import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import org.springframework.context.support.ResourceBundleMessageSource import org.springframework.mail.SimpleMailMessage import org.springframework.mail.javamail.JavaMailSender class EmailServiceTest { + private val preferences = mockk() private val mailSender = mockk(relaxed = true) - private val preferences = mockk(relaxed = true) + private val messageSource = ResourceBundleMessageSource().apply { + setBasename("messages"); setDefaultEncoding("UTF-8"); setFallbackToSystemLocale(false) + } + private val localizedMessages = LocalizedMessages(messageSource) + private val service = EmailService(preferences, mailSender, "smtp.test", "TaskWolf ", localizedMessages) private val owner = User(email = "owner@test.com", displayName = "Owner") - private val assignee = User(email = "assignee@test.com", displayName = "Assignee") - private val actor = User(email = "actor@test.com", displayName = "Actor") - - private val project = Project(key = "WOLF", name = "TaskWolf", owner = owner, workflow = null) - private val workflow = Workflow(name = "Default", project = project) + private val workflow = Workflow(name = "Default", project = Project(key = "WOLF", name = "TaskWolf", owner = owner, workflow = null)) private val status = WorkflowStatus("To Do", StatusCategory.TODO, "#6c8fef", 0, workflow) - private val issue = Issue( - key = "WOLF-1", keyNumber = 1, title = "My Issue", - type = IssueType.TASK, status = status, project = project, - reporter = owner, assignee = assignee - ) + private val project = Project(key = "WOLF", name = "TaskWolf", owner = owner, workflow = workflow) + + private fun recipient(language: String?) = User(email = "r@test.com", displayName = "R").apply { this.language = language } + private fun issueFor(assignee: User?) = Issue(key = "WOLF-1", keyNumber = 1, title = "My Issue", + type = IssueType.TASK, status = status, project = project, reporter = owner, assignee = assignee) + + private fun captureMail(): SimpleMailMessage { + val slot = slot() + verify { mailSender.send(capture(slot)) } + return slot.captured + } @Test - fun `onMention sends email when SMTP host is configured`() { + fun `onMention renders subject and body in recipient German`() { every { preferences.isEnabled(any(), any(), any()) } returns true - val service = EmailService(preferences, mailSender, mailHost = "smtp.example.com", fromAddress = "noreply@example.com") - val comment = Comment(issueId = issue.id, authorId = actor.id, body = "Hey @Assignee look at this") - val event = MentionEvent(mentionedUser = assignee, comment = comment, issue = issue) + val user = recipient("de") + val issue = issueFor(assignee = null) + val comment = Comment(issueId = issue.id, authorId = owner.id, body = "Great comment") - service.onMention(event) + service.onMention(MentionEvent(mentionedUser = user, comment = comment, issue = issue)) - verify { mailSender.send(any()) } + val mail = captureMail() + assertEquals("Sie wurden in WOLF-1 erwähnt", mail.subject) + assertEquals("WOLF-1: My Issue\n\nGreat comment", mail.text) } @Test - fun `onMention skips when SMTP host is blank`() { - val service = EmailService(preferences, mailSender, mailHost = "", fromAddress = "noreply@example.com") - val comment = Comment(issueId = issue.id, authorId = actor.id, body = "Mention") - val event = MentionEvent(mentionedUser = assignee, comment = comment, issue = issue) + fun `onMention renders subject in English when language null`() { + every { preferences.isEnabled(any(), any(), any()) } returns true + val user = recipient(null) + val issue = issueFor(assignee = null) + val comment = Comment(issueId = issue.id, authorId = owner.id, body = "Great comment") - service.onMention(event) + service.onMention(MentionEvent(mentionedUser = user, comment = comment, issue = issue)) - verify(exactly = 0) { mailSender.send(any()) } + assertEquals("You were mentioned in WOLF-1", captureMail().subject) } @Test - fun `onAssigned sends email when assignee changes`() { + fun `onAssigned renders subject and body in recipient German`() { every { preferences.isEnabled(any(), any(), any()) } returns true - val service = EmailService(preferences, mailSender, mailHost = "smtp.example.com", fromAddress = "noreply@example.com") - val event = IssueFieldChangedEvent(issue = issue, actor = actor, + val assignee = recipient("de") + val issue = issueFor(assignee = assignee) + val event = IssueFieldChangedEvent(issue = issue, actor = owner, field = "assignee", oldValue = null, newValue = assignee.displayName) service.onAssigned(event) - verify { mailSender.send(any()) } + val mail = captureMail() + assertEquals("Ihnen wurde WOLF-1 zugewiesen", mail.subject) + assertEquals("Sie wurden zugewiesen zu: WOLF-1\nMy Issue", mail.text) + } + + @Test + fun `onAssigned renders subject in English when language en`() { + every { preferences.isEnabled(any(), any(), any()) } returns true + val assignee = recipient("en") + val issue = issueFor(assignee = assignee) + val event = IssueFieldChangedEvent(issue = issue, actor = owner, + field = "assignee", oldValue = null, newValue = assignee.displayName) + + service.onAssigned(event) + + assertEquals("You were assigned to WOLF-1", captureMail().subject) + } + + // --- Pre-existing guard-condition coverage (predates Phase 3; retained so the + // LocalizedMessages constructor change doesn't silently drop this coverage) --- + + @Test + fun `onMention skips when SMTP host is blank`() { + val blankHostService = EmailService(preferences, mailSender, "", "TaskWolf ", localizedMessages) + val user = recipient(null) + val issue = issueFor(assignee = null) + val comment = Comment(issueId = issue.id, authorId = owner.id, body = "Mention") + + blankHostService.onMention(MentionEvent(mentionedUser = user, comment = comment, issue = issue)) + + verify(exactly = 0) { mailSender.send(any()) } } @Test fun `onAssigned skips when field is not assignee`() { - val service = EmailService(preferences, mailSender, mailHost = "smtp.example.com", fromAddress = "noreply@example.com") - val event = IssueFieldChangedEvent(issue = issue, actor = actor, + val assignee = recipient(null) + val issue = issueFor(assignee = assignee) + val event = IssueFieldChangedEvent(issue = issue, actor = owner, field = "title", oldValue = "Old", newValue = "New") service.onAssigned(event) @@ -83,15 +132,13 @@ class EmailServiceTest { } @Test - fun `onMention skips email when email preference disabled`() { - every { preferences.isEnabled(assignee.id, - com.taskowolf.notifications.domain.NotificationType.COMMENT_MENTION, - com.taskowolf.notifications.domain.NotificationChannel.EMAIL) } returns false - val service = EmailService(preferences, mailSender, mailHost = "smtp.example.com", fromAddress = "noreply@example.com") - val comment = Comment(issueId = issue.id, authorId = actor.id, body = "Hey @Assignee") - val event = MentionEvent(mentionedUser = assignee, comment = comment, issue = issue) - - service.onMention(event) + fun `onMention skips email when preference disabled`() { + val user = recipient(null) + every { preferences.isEnabled(user.id, NotificationType.COMMENT_MENTION, NotificationChannel.EMAIL) } returns false + val issue = issueFor(assignee = null) + val comment = Comment(issueId = issue.id, authorId = owner.id, body = "Mention") + + service.onMention(MentionEvent(mentionedUser = user, comment = comment, issue = issue)) verify(exactly = 0) { mailSender.send(any()) } } diff --git a/backend/src/test/kotlin/com/taskowolf/notifications/NotificationServiceTest.kt b/backend/src/test/kotlin/com/taskowolf/notifications/NotificationServiceTest.kt index a23c9b4d..d0b32ce7 100644 --- a/backend/src/test/kotlin/com/taskowolf/notifications/NotificationServiceTest.kt +++ b/backend/src/test/kotlin/com/taskowolf/notifications/NotificationServiceTest.kt @@ -1,14 +1,17 @@ package com.taskowolf.notifications import com.taskowolf.auth.domain.User +import com.taskowolf.auth.infrastructure.UserRepository import com.taskowolf.comments.domain.Comment import com.taskowolf.comments.domain.events.MentionEvent +import com.taskowolf.core.infrastructure.LocalizedMessages import com.taskowolf.core.infrastructure.NotFoundException import com.taskowolf.issues.domain.Issue import com.taskowolf.issues.domain.IssueType import com.taskowolf.issues.domain.events.IssueFieldChangedEvent import com.taskowolf.notifications.application.NotificationService import com.taskowolf.notifications.domain.Notification +import com.taskowolf.notifications.domain.NotificationChannel import com.taskowolf.notifications.domain.NotificationType import com.taskowolf.notifications.infrastructure.NotificationRepository import com.taskowolf.projects.domain.Project @@ -19,9 +22,10 @@ import io.mockk.every import io.mockk.mockk import io.mockk.slot import io.mockk.verify -import org.junit.jupiter.api.Test import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import org.springframework.context.support.ResourceBundleMessageSource import java.util.Optional import java.util.UUID @@ -29,13 +33,17 @@ class NotificationServiceTest { private val repository = mockk() private val preferences = mockk() - private val service = NotificationService(repository, preferences) + private val userRepository = mockk() + private val messageSource = ResourceBundleMessageSource().apply { + setBasename("messages"); setDefaultEncoding("UTF-8"); setFallbackToSystemLocale(false) + } + private val localizedMessages = LocalizedMessages(messageSource) + private val service = NotificationService(repository, preferences, localizedMessages, userRepository) private val user = User(email = "user@test.com", displayName = "User") private val actor = User(email = "actor@test.com", displayName = "Actor") private val owner = User(email = "owner@test.com", displayName = "Owner") - // Minimal issue for events — check Workflow constructor before using private val workflow = buildWorkflow(owner) private val status = WorkflowStatus("To Do", StatusCategory.TODO, "#6c8fef", 0, workflow) private val project = Project(key = "WOLF", name = "TaskWolf", owner = owner, workflow = workflow) @@ -43,7 +51,7 @@ class NotificationServiceTest { type = IssueType.TASK, status = status, project = project, reporter = owner, assignee = user) @Test - fun `onMention saves COMMENT_MENTION notification for mentioned user`() { + fun `onMention saves COMMENT_MENTION notification with English title for null language`() { every { preferences.isEnabled(any(), any(), any()) } returns true every { repository.save(any()) } returnsArgument 0 val comment = Comment(issueId = issue.id, authorId = actor.id, body = "Hey @User check this") @@ -55,10 +63,27 @@ class NotificationServiceTest { verify { repository.save(capture(slot)) } assertEquals(user.id, slot.captured.userId) assertEquals(NotificationType.COMMENT_MENTION, slot.captured.type) + assertEquals("You were mentioned in WOLF-1", slot.captured.title) + assertEquals("Hey @User check this", slot.captured.body) // user-content body unchanged } @Test - fun `onIssueFieldChanged saves ISSUE_ASSIGNED notification when assignee changes`() { + fun `onMention renders title in recipient German`() { + every { preferences.isEnabled(any(), any(), any()) } returns true + every { repository.save(any()) } returnsArgument 0 + val germanUser = User(email = "de@test.com", displayName = "DE").apply { language = "de" } + val comment = Comment(issueId = issue.id, authorId = actor.id, body = "Hallo") + val event = MentionEvent(mentionedUser = germanUser, comment = comment, issue = issue) + + service.onMention(event) + + val slot = slot() + verify { repository.save(capture(slot)) } + assertEquals("Sie wurden in WOLF-1 erwähnt", slot.captured.title) + } + + @Test + fun `onIssueFieldChanged saves ISSUE_ASSIGNED with localized title, body unchanged`() { every { preferences.isEnabled(any(), any(), any()) } returns true every { repository.save(any()) } returnsArgument 0 val event = IssueFieldChangedEvent(issue = issue, actor = actor, @@ -70,12 +95,13 @@ class NotificationServiceTest { verify { repository.save(capture(slot)) } assertEquals(user.id, slot.captured.userId) assertEquals(NotificationType.ISSUE_ASSIGNED, slot.captured.type) + assertEquals("You were assigned to WOLF-1", slot.captured.title) + assertEquals("Test Issue", slot.captured.body) // issue.title stays as user-content } @Test fun `onMention skips save when in-app preference disabled`() { - every { preferences.isEnabled(user.id, NotificationType.COMMENT_MENTION, - com.taskowolf.notifications.domain.NotificationChannel.IN_APP) } returns false + every { preferences.isEnabled(user.id, NotificationType.COMMENT_MENTION, NotificationChannel.IN_APP) } returns false val comment = Comment(issueId = issue.id, authorId = actor.id, body = "Hey @User") val event = MentionEvent(mentionedUser = user, comment = comment, issue = issue) @@ -84,14 +110,84 @@ class NotificationServiceTest { verify(exactly = 0) { repository.save(any()) } } + @Test + fun `createDirect renders templated body in recipient German`() { + val germanUser = User(email = "de@test.com", displayName = "DE").apply { language = "de" } + every { preferences.isEnabled(germanUser.id, NotificationType.SLA_BREACHED, NotificationChannel.IN_APP) } returns true + every { userRepository.findById(germanUser.id) } returns Optional.of(germanUser) + every { repository.save(any()) } returnsArgument 0 + + service.createDirect( + userId = germanUser.id, + type = NotificationType.SLA_BREACHED, + titleKey = "notification.slaBreached.title", + link = "/issues/WOLF-1", + titleArgs = arrayOf("WOLF-1"), + bodyKey = "notification.slaBreached.body", + bodyArgs = arrayOf("WOLF-1", "60"), + ) + + val slot = slot() + verify { repository.save(capture(slot)) } + assertEquals("SLA verletzt: WOLF-1", slot.captured.title) + assertEquals("Vorgang WOLF-1 hat seine SLA-Lösungszeit von 60 Minuten überschritten.", slot.captured.body) + } + + @Test + fun `createDirect keeps rawBody as user-content and renders title`() { + every { preferences.isEnabled(user.id, NotificationType.AUTOMATION, NotificationChannel.IN_APP) } returns true + every { userRepository.findById(user.id) } returns Optional.of(user) + every { repository.save(any()) } returnsArgument 0 + + service.createDirect( + userId = user.id, + type = NotificationType.AUTOMATION, + titleKey = "notification.automation.title", + link = "/l", + titleArgs = arrayOf("WOLF-1"), + rawBody = "Custom automation message", + ) + + val slot = slot() + verify { repository.save(capture(slot)) } + assertEquals("Automation: WOLF-1", slot.captured.title) + assertEquals("Custom automation message", slot.captured.body) + } + + @Test + fun `createDirect falls back to English when recipient user not found`() { + every { preferences.isEnabled(user.id, NotificationType.AUTOMATION, NotificationChannel.IN_APP) } returns true + every { userRepository.findById(user.id) } returns Optional.empty() + every { repository.save(any()) } returnsArgument 0 + + service.createDirect( + userId = user.id, + type = NotificationType.AUTOMATION, + titleKey = "notification.automation.title", + link = "/l", + titleArgs = arrayOf("WOLF-1"), + rawBody = "msg", + ) + + val slot = slot() + verify { repository.save(capture(slot)) } + assertEquals("Automation: WOLF-1", slot.captured.title) + } + @Test fun `createDirect skips save when in-app preference disabled`() { - every { preferences.isEnabled(user.id, NotificationType.AUTOMATION, - com.taskowolf.notifications.domain.NotificationChannel.IN_APP) } returns false + every { preferences.isEnabled(user.id, NotificationType.AUTOMATION, NotificationChannel.IN_APP) } returns false - service.createDirect(user.id, NotificationType.AUTOMATION, "t", "b", "/l") + service.createDirect( + userId = user.id, + type = NotificationType.AUTOMATION, + titleKey = "notification.automation.title", + link = "/l", + titleArgs = arrayOf("WOLF-1"), + ) verify(exactly = 0) { repository.save(any()) } + verify(exactly = 0) { userRepository.findById(any()) } } @Test diff --git a/backend/src/test/kotlin/com/taskowolf/servicedesk/IncidentServiceTest.kt b/backend/src/test/kotlin/com/taskowolf/servicedesk/IncidentServiceTest.kt index deb9c21f..18f5fd4b 100644 --- a/backend/src/test/kotlin/com/taskowolf/servicedesk/IncidentServiceTest.kt +++ b/backend/src/test/kotlin/com/taskowolf/servicedesk/IncidentServiceTest.kt @@ -2,6 +2,8 @@ package com.taskowolf.servicedesk import com.taskowolf.comments.domain.Comment import com.taskowolf.comments.infrastructure.CommentRepository +import com.taskowolf.issues.domain.Issue +import com.taskowolf.issues.infrastructure.IssueRepository import com.taskowolf.notifications.application.NotificationService import com.taskowolf.notifications.domain.NotificationType import com.taskowolf.servicedesk.application.IncidentService @@ -24,34 +26,49 @@ class IncidentServiceTest { private val incidentRepo = mockk() private val commentRepo = mockk() private val notificationService = mockk(relaxed = true) - private val service = IncidentService(incidentRepo, commentRepo, notificationService) + private val issueRepository = mockk() + private val service = IncidentService(incidentRepo, commentRepo, notificationService, issueRepository) private val issueId = UUID.randomUUID() @Test - fun `create saves incident and notifies users`() { + fun `create saves incident and notifies users with issue key`() { val onCallId = UUID.randomUUID() val notifyId1 = UUID.randomUUID() val notifyId2 = UUID.randomUUID() every { incidentRepo.save(any()) } returnsArgument 0 + val issue = mockk { every { key } returns "WOLF-1" } + every { issueRepository.findById(issueId) } returns Optional.of(issue) val incident = service.create(issueId, IncidentSeverity.P1, onCallId, listOf(notifyId1, notifyId2)) assertEquals(issueId, incident.issueId) assertEquals(IncidentSeverity.P1, incident.severity) assertEquals(onCallId, incident.onCallAssigneeId) - verify(exactly = 2) { notificationService.createDirect(any(), NotificationType.AUTOMATION, any(), any(), any()) } + verify(exactly = 2) { + notificationService.createDirect( + userId = any(), + type = NotificationType.AUTOMATION, + titleKey = "notification.incident.title", + link = "/issues/WOLF-1", + titleArgs = match { it.contains("WOLF-1") }, + bodyKey = "notification.incident.body", + bodyArgs = match { it.contains("WOLF-1") }, + rawBody = any(), + ) + } } @Test - fun `create without notify users saves incident without notifications`() { + fun `create without notify users saves incident without notifications or issue lookup`() { every { incidentRepo.save(any()) } returnsArgument 0 val incident = service.create(issueId, IncidentSeverity.P2, null, emptyList()) assertEquals(issueId, incident.issueId) assertEquals(IncidentSeverity.P2, incident.severity) - verify(exactly = 0) { notificationService.createDirect(any(), any(), any(), any(), any()) } + verify(exactly = 0) { notificationService.createDirect(any(), any(), any(), any()) } + verify(exactly = 0) { issueRepository.findById(any()) } } @Test diff --git a/backend/src/test/kotlin/com/taskowolf/servicedesk/SlaMonitorJobTest.kt b/backend/src/test/kotlin/com/taskowolf/servicedesk/SlaMonitorJobTest.kt index cc072fb6..9d6f0eb9 100644 --- a/backend/src/test/kotlin/com/taskowolf/servicedesk/SlaMonitorJobTest.kt +++ b/backend/src/test/kotlin/com/taskowolf/servicedesk/SlaMonitorJobTest.kt @@ -72,7 +72,7 @@ class SlaMonitorJobTest { job.run() - verify(exactly = 0) { notificationService.createDirect(any(), any(), any(), any(), any()) } + verify(exactly = 0) { notificationService.createDirect(any(), any(), any(), any()) } verify(exactly = 0) { auditService.log(any(), any(), any()) } } @@ -96,11 +96,14 @@ class SlaMonitorJobTest { verify(exactly = 1) { notificationService.createDirect( - notifyUserId, - NotificationType.SLA_BREACHED, - match { it.contains("WOLF-1") }, - any(), - any() + userId = notifyUserId, + type = NotificationType.SLA_BREACHED, + titleKey = "notification.slaBreached.title", + link = "/issues/WOLF-1", + titleArgs = match { it.contains("WOLF-1") }, + bodyKey = "notification.slaBreached.body", + bodyArgs = any(), + rawBody = any(), ) } verify(exactly = 1) { @@ -122,7 +125,7 @@ class SlaMonitorJobTest { job.run() - verify(exactly = 0) { notificationService.createDirect(any(), any(), any(), any(), any()) } + verify(exactly = 0) { notificationService.createDirect(any(), any(), any(), any()) } } @Test @@ -136,7 +139,7 @@ class SlaMonitorJobTest { job.run() - verify(exactly = 0) { notificationService.createDirect(any(), any(), any(), any(), any()) } + verify(exactly = 0) { notificationService.createDirect(any(), any(), any(), any()) } } @Test @@ -145,7 +148,7 @@ class SlaMonitorJobTest { job.run() - verify(exactly = 0) { notificationService.createDirect(any(), any(), any(), any(), any()) } + verify(exactly = 0) { notificationService.createDirect(any(), any(), any(), any()) } verify(exactly = 0) { auditService.log(any(), any(), any()) } }