diff --git a/plugins/core/core/src/software/aws/toolkits/core/utils/RemoteResourceResolver.kt b/plugins/core/core/src/software/aws/toolkits/core/utils/RemoteResourceResolver.kt index ce0603bf0fe..c4efe685884 100644 --- a/plugins/core/core/src/software/aws/toolkits/core/utils/RemoteResourceResolver.kt +++ b/plugins/core/core/src/software/aws/toolkits/core/utils/RemoteResourceResolver.kt @@ -32,9 +32,11 @@ class DefaultRemoteResourceResolver( private fun internalResolve(resource: RemoteResource): Path { val expectedLocation = cacheBasePath.resolve(resource.name) val current = expectedLocation.existsOrNull() - if (current != null && !isExpired(current, resource)) { - LOG.debug { "Existing file ($current) for ${resource.name} is present and not expired - using it." } - return current + if (resource.name != "notifications.json") { + if ((current != null && !isExpired(current, resource))) { + LOG.debug { "Existing file ($current) for ${resource.name} is present and not expired - using it." } + return current + } } LOG.debug { "Current file for ${resource.name} does not exist or is expired. Attempting to fetch from ${resource.urls}" } diff --git a/plugins/core/jetbrains-community/resources/META-INF/aws.toolkit.core.xml b/plugins/core/jetbrains-community/resources/META-INF/aws.toolkit.core.xml index 2ae2fdc75ab..ec52967c5b7 100644 --- a/plugins/core/jetbrains-community/resources/META-INF/aws.toolkit.core.xml +++ b/plugins/core/jetbrains-community/resources/META-INF/aws.toolkit.core.xml @@ -66,6 +66,7 @@ + @@ -77,6 +78,9 @@ restartRequired="true"/> + + diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationCustomDeserializers.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationCustomDeserializers.kt index 2b6adce2720..ef0316b3187 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationCustomDeserializers.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationCustomDeserializers.kt @@ -114,6 +114,12 @@ class NotConditionDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): NotificationScheduleType = + NotificationScheduleType.fromString(p.valueAsString) +} + private fun JsonNode.toNotificationExpressions(p: JsonParser): List = this.map { element -> val parser = element.traverse(p.codec) parser.nextToken() diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtils.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtils.kt index 425eaf4c00a..f582ce7d320 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtils.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtils.kt @@ -27,8 +27,11 @@ data class NotificationData( ) data class NotificationSchedule( - val type: String, -) + @JsonDeserialize(using = NotificationTypeDeserializer::class) + val type: NotificationScheduleType, +) { + constructor(type: String) : this(NotificationScheduleType.fromString(type)) +} enum class NotificationSeverity { INFO, @@ -36,6 +39,20 @@ enum class NotificationSeverity { CRITICAL, } +enum class NotificationScheduleType { + STARTUP, + EMERGENCY, + ; + + companion object { + fun fromString(value: String): NotificationScheduleType = + when (value.lowercase()) { + "startup" -> STARTUP + else -> EMERGENCY + } + } +} + data class NotificationContentDescriptionLocale( @JsonProperty("en-US") val locale: NotificationContentDescription, diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPanel.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPanel.kt index 8658d67df27..e9b6564adc8 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPanel.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPanel.kt @@ -15,13 +15,14 @@ class NotificationPanel : BorderLayoutPanel() { init { isOpaque = false addToCenter(wrapper) - ProcessNotificationsBase.showBannerNotification.forEach { - updateNotificationPanel(it.value) + BannerNotificationService.getInstance().getNotifications().forEach { (_, content) -> + updateNotificationPanel(content) } } private fun removeNotificationPanel(notificationId: String) = runInEdt { - ProcessNotificationsBase.showBannerNotification.remove(notificationId) // TODO: add id to dismissed notification list + BannerNotificationService.getInstance().removeNotification(notificationId) + NotificationDismissalState.getInstance().dismissNotification(notificationId) wrapper.removeAll() } diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt new file mode 100644 index 00000000000..357cf7e8d99 --- /dev/null +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt @@ -0,0 +1,149 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.util.registry.Registry +import com.intellij.util.Alarm +import com.intellij.util.AlarmFactory +import com.intellij.util.io.HttpRequests +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import software.aws.toolkits.core.utils.RemoteResolveParser +import software.aws.toolkits.core.utils.RemoteResource +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.DefaultRemoteResourceResolverProvider +import software.aws.toolkits.jetbrains.core.RemoteResourceResolverProvider +import software.aws.toolkits.telemetry.Component +import software.aws.toolkits.telemetry.ToolkitTelemetry +import java.io.InputStream +import java.time.Duration +import java.util.concurrent.atomic.AtomicBoolean + +private const val MAX_RETRIES = 3 +private const val RETRY_DELAY_MS = 1000L + +object NotificationFileValidator : RemoteResolveParser { + override fun canBeParsed(data: InputStream): Boolean = + try { + NotificationMapperUtil.mapper.readValue(data) + true + } catch (e: Exception) { + false + } +} + +object NotificationEndpoint { + fun getEndpoint(): String = + Registry.get("aws.toolkit.notification.endpoint").asString() +} + +@Service(Service.Level.APP) +internal final class NotificationPollingService : Disposable { + private val isFirstPoll = AtomicBoolean(true) + private val observers = mutableListOf<() -> Unit>() + private val alarm = AlarmFactory.getInstance().create(Alarm.ThreadToUse.POOLED_THREAD, this) + private val pollingIntervalMs = Duration.ofMinutes(10).toMillis() + private val resourceResolver: RemoteResourceResolverProvider = DefaultRemoteResourceResolverProvider() + private val notificationsResource = object : RemoteResource { + override val name: String = "notifications.json" + override val urls: List = listOf(NotificationEndpoint.getEndpoint()) + override val remoteResolveParser: RemoteResolveParser = NotificationFileValidator + } + + fun startPolling() { + val newNotifications = runBlocking { pollForNotifications() } + if (newNotifications) { + notifyObservers() + } + alarm.addRequest( + { startPolling() }, + pollingIntervalMs + ) + } + + /** + * Main polling function that checks for updates and downloads if necessary + * Returns the parsed notifications if successful, null otherwise + */ + private suspend fun pollForNotifications(): Boolean { + var retryCount = 0 + var lastException: Exception? = null + + while (retryCount < MAX_RETRIES) { + try { + val newETag = getNotificationETag() + if (newETag == NotificationEtagState.getInstance().etag) { + // for when we need to notify on first poll even when there's no new ETag + if (isFirstPoll.compareAndSet(true, false)) { + notifyObservers() + } + return false + } + resourceResolver.get() + .resolve(notificationsResource) + .toCompletableFuture() + .get() + NotificationEtagState.getInstance().etag = newETag + return true + } catch (e: Exception) { + lastException = e + LOG.error(e) { "Failed to poll for notifications (attempt ${retryCount + 1}/$MAX_RETRIES)" } + retryCount++ + if (retryCount < MAX_RETRIES) { + val backoffDelay = RETRY_DELAY_MS * (1L shl (retryCount - 1)) + delay(backoffDelay) + } + } + } + emitFailureMetric(lastException) + return false + } + + private fun getNotificationETag(): String = + try { + HttpRequests.request(NotificationEndpoint.getEndpoint()) + .userAgent("AWS Toolkit for JetBrains") + .connect { request -> + request.connection.headerFields["ETag"]?.firstOrNull().orEmpty() + } + } catch (e: Exception) { + LOG.warn { "Failed to fetch notification ETag: $e.message" } + throw e + } + + private fun emitFailureMetric(e: Exception?) { + ToolkitTelemetry.showNotification( + project = null, + component = Component.Filesystem, + id = "", + reason = "Failed to poll for notifications", + success = false, + reasonDesc = "${e?.javaClass?.simpleName ?: "Unknown"}: ${e?.message ?: "No message"}", + ) + } + + fun addObserver(observer: () -> Unit) = observers.add(observer) + + private fun notifyObservers() { + observers.forEach { observer -> + observer() + } + } + + override fun dispose() { + alarm.dispose() + } + + companion object { + private val LOG = getLogger() + fun getInstance(): NotificationPollingService = + ApplicationManager.getApplication().getService(NotificationPollingService::class.java) + } +} diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationServiceInitializer.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationServiceInitializer.kt new file mode 100644 index 00000000000..c8b84909ce5 --- /dev/null +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationServiceInitializer.kt @@ -0,0 +1,22 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity +import java.util.concurrent.atomic.AtomicBoolean + +internal class NotificationServiceInitializer : ProjectActivity { + + private val initialized = AtomicBoolean(false) + + override suspend fun execute(project: Project) { + if (ApplicationManager.getApplication().isUnitTestMode) return + if (initialized.compareAndSet(false, true)) { + val service = NotificationPollingService.getInstance() + service.startPolling() + } + } +} diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationStateUtils.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationStateUtils.kt new file mode 100644 index 00000000000..ce53118e249 --- /dev/null +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationStateUtils.kt @@ -0,0 +1,87 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.RoamingType +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service + +@Service +@State(name = "notificationDismissals", storages = [Storage("aws.xml", roamingType = RoamingType.DISABLED)]) +class NotificationDismissalState : PersistentStateComponent { + private val state = NotificationDismissalConfiguration() + + override fun getState(): NotificationDismissalConfiguration = state + + override fun loadState(state: NotificationDismissalConfiguration) { + this.state.dismissedNotificationIds.clear() + this.state.dismissedNotificationIds.addAll(state.dismissedNotificationIds) + } + + fun isDismissed(notificationId: String): Boolean = + state.dismissedNotificationIds.contains(notificationId) + + fun dismissNotification(notificationId: String) { + state.dismissedNotificationIds.add(notificationId) + } + + companion object { + fun getInstance(): NotificationDismissalState = + service() + } +} + +data class NotificationDismissalConfiguration( + var dismissedNotificationIds: MutableSet = mutableSetOf(), +) + +@Service +@State(name = "notificationEtag", storages = [Storage("aws.xml", roamingType = RoamingType.DISABLED)]) +class NotificationEtagState : PersistentStateComponent { + private val state = NotificationEtagConfiguration() + + override fun getState(): NotificationEtagConfiguration = state + + override fun loadState(state: NotificationEtagConfiguration) { + this.state.etag = state.etag + } + + var etag: String? + get() = state.etag + set(value) { + state.etag = value + } + + companion object { + fun getInstance(): NotificationEtagState = + service() + } +} + +data class NotificationEtagConfiguration( + var etag: String? = null, +) + +@Service +class BannerNotificationService { + private val notifications = mutableMapOf() + + fun addNotification(id: String, content: BannerContent) { + notifications[id] = content + } + + fun getNotifications(): Map = notifications + + fun removeNotification(id: String) { + notifications.remove(id) + } + + companion object { + fun getInstance(): BannerNotificationService = + service() + } +} diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt index 8911c79db76..5bbf34bba00 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt @@ -3,27 +3,64 @@ package software.aws.toolkits.jetbrains.core.notifications +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue import com.intellij.notification.NotificationType import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.application.PathManager import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project +import software.aws.toolkits.core.utils.inputStream import software.aws.toolkits.jetbrains.utils.notifyStickyWithData +import java.nio.file.Paths +import java.util.concurrent.atomic.AtomicBoolean + +object NotificationMapperUtil { + val mapper: ObjectMapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) +} +private var isStartup: AtomicBoolean = AtomicBoolean(true) @Service(Service.Level.PROJECT) -class ProcessNotificationsBase { +class ProcessNotificationsBase( + private val project: Project, +) { private val notifListener = mutableListOf() init { - // TODO: install a listener for the polling class + NotificationPollingService.getInstance().addObserver { + retrieveStartupAndEmergencyNotifications() + } } - fun getNotificationsFromFile() { - // TODO: returns a notification list + private fun getNotificationsFromFile(): NotificationsList? { + val path = Paths.get(PathManager.getSystemPath(), NOTIFICATIONS_PATH) + val content = path.inputStream().bufferedReader().use { it.readText() } + if (content.isEmpty()) { + return null + } + return NotificationMapperUtil.mapper.readValue(content) } fun retrieveStartupAndEmergencyNotifications() { - // TODO: separates notifications into startup and emergency - // iterates through the 2 lists and processes each notification(if it isn't dismissed) + val isStartupPoll = isStartup.compareAndSet(true, false) + val notifications = getNotificationsFromFile() + notifications?.let { notificationsList -> + val activeNotifications = notificationsList.notifications + ?.filter { notification -> + // Keep notification if: + // - it's not a startup notification, OR + // - it is a startup notification AND this is the first poll + notification.schedule.type != NotificationScheduleType.STARTUP || isStartupPoll + } + ?.filter { notification -> + !NotificationDismissalState.getInstance().isDismissed(notification.id) + } + .orEmpty() + + activeNotifications.forEach { processNotification(project, it) } + } } fun processNotification(project: Project, notificationData: NotificationData) { @@ -46,7 +83,7 @@ class ProcessNotificationsBase { ) if (severity == "Critical") { val bannerContent = BannerContent(notificationContent.title, notificationContent.description, followupActions, notificationData.id) - showBannerNotification[notificationData.id] = bannerContent + BannerNotificationService.getInstance().addNotification(notificationData.id, bannerContent) notifyListenerForNotification(bannerContent) } } @@ -70,7 +107,7 @@ class ProcessNotificationsBase { companion object { fun getInstance(project: Project): ProcessNotificationsBase = project.service() - val showBannerNotification = mutableMapOf() + private const val NOTIFICATIONS_PATH = "aws-static-resources/notifications.json" } } diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/utils/NotificationUtils.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/utils/NotificationUtils.kt index 12377a83792..5040a199f55 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/utils/NotificationUtils.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/utils/NotificationUtils.kt @@ -17,7 +17,8 @@ import com.intellij.ui.ScrollPaneFactory import org.slf4j.LoggerFactory import software.aws.toolkits.core.utils.warn import software.aws.toolkits.jetbrains.core.help.HelpIds -import software.aws.toolkits.jetbrains.core.notifications.ProcessNotificationsBase +import software.aws.toolkits.jetbrains.core.notifications.BannerNotificationService +import software.aws.toolkits.jetbrains.core.notifications.NotificationDismissalState import software.aws.toolkits.resources.AwsCoreBundle import javax.swing.JLabel import javax.swing.JTextArea @@ -66,8 +67,8 @@ fun notifyStickyWithData( createNotificationExpiringAction( object : AnAction("Dismiss") { override fun actionPerformed(e: AnActionEvent) { - ProcessNotificationsBase.showBannerNotification.remove(id) - // TODO: add id to dismissed notification list + BannerNotificationService.getInstance().removeNotification(id) + NotificationDismissalState.getInstance().dismissNotification(id) } } ) diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingServiceTest.kt b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingServiceTest.kt new file mode 100644 index 00000000000..3e16c4dd28e --- /dev/null +++ b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingServiceTest.kt @@ -0,0 +1,111 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.intellij.testFramework.ApplicationExtension +import com.intellij.util.io.HttpRequests +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import software.aws.toolkits.core.utils.RemoteResourceResolver +import software.aws.toolkits.jetbrains.core.RemoteResourceResolverProvider +import java.nio.file.Path +import java.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicBoolean + +@ExtendWith(ApplicationExtension::class) +class NotificationPollingServiceTest { + private lateinit var sut: NotificationPollingService + private lateinit var mockResolver: RemoteResourceResolver + private lateinit var mockProvider: RemoteResourceResolverProvider + private lateinit var observer: () -> Unit + private val testPath = Path.of("/test/path") + + @BeforeEach + fun setUp() { + sut = NotificationPollingService() + + mockResolver = mockk { + every { resolve(any()) } returns CompletableFuture.completedFuture(testPath) + } + + mockProvider = mockk { + every { get() } returns mockResolver + } + + val providerField = NotificationPollingService::class.java + .getDeclaredField("resourceResolver") + providerField.isAccessible = true + providerField.set(sut, mockProvider) + + // Create mock observers + observer = mockk<() -> Unit>() + every { observer.invoke() } just Runs + + val observersField = NotificationPollingService::class.java + .getDeclaredField("observers") + .apply { isAccessible = true } + + observersField.set(sut, mutableListOf(observer)) + } + + @AfterEach + fun tearDown() { + sut.dispose() + } + + @Test + fun `test pollForNotifications when ETag matches - no new notifications`() { + NotificationEtagState.getInstance().etag = "same" + val firstPollField = NotificationPollingService::class.java + .getDeclaredField("isFirstPoll") + .apply { isAccessible = true } + firstPollField.set(sut, AtomicBoolean(false)) + + mockkStatic(HttpRequests::class) { + every { + HttpRequests.request(any()) + .userAgent(any()) + .connect(any()) + } returns "same" + sut.startPolling() + } + verify(exactly = 0) { observer.invoke() } + } + + @Test + fun `test pollForNotifications when ETag matches on startup - notify observers`() { + NotificationEtagState.getInstance().etag = "same" + mockkStatic(HttpRequests::class) { + every { + HttpRequests.request(any()) + .userAgent(any()) + .connect(any()) + } returns "same" + sut.startPolling() + } + verify(exactly = 1) { observer.invoke() } + } + + @Test + fun `test pollForNotifications when ETag different - notify observers`() { + NotificationEtagState.getInstance().etag = "oldETag" + mockkStatic(HttpRequests::class) { + every { + HttpRequests.request(any()) + .userAgent(any()) + .connect(any()) + } returns "newEtag" + sut.startPolling() + } + verify(exactly = 1) { observer.invoke() } + } +} diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBaseTest.kt b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBaseTest.kt new file mode 100644 index 00000000000..01b77f2234a --- /dev/null +++ b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBaseTest.kt @@ -0,0 +1,161 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.intellij.openapi.project.Project +import com.intellij.testFramework.ApplicationExtension +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.spyk +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import java.util.concurrent.atomic.AtomicBoolean + +@ExtendWith(ApplicationExtension::class) +class ProcessNotificationsBaseTest { + private lateinit var sut: ProcessNotificationsBase + private lateinit var project: Project + private lateinit var dismissalState: NotificationDismissalState + + @BeforeEach + fun setUp() { + project = mockk() + dismissalState = spyk(NotificationDismissalState()) + + mockkObject(NotificationDismissalState) + every { NotificationDismissalState.getInstance() } returns dismissalState + + sut = spyk( + objToCopy = ProcessNotificationsBase(project) + ) + } + + @Test + fun `startup notifications are only processed on first poll`() { + resetIsStartup() + val startupNotification = createNotification("startup-1", NotificationScheduleType.STARTUP) + every { sut["getNotificationsFromFile"]() } returns createNotificationsList(startupNotification) + every { dismissalState.isDismissed(any()) } returns false + + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 1) { sut.processNotification(project, startupNotification) } + + // Second poll + sut.retrieveStartupAndEmergencyNotifications() + + // Verify processNotification wasn't called again + verify(exactly = 1) { sut.processNotification(project, any()) } + } + + @Test + fun `non startup notifications are processed on every poll`() { + val emergencyNotification = createNotification("emergency-1", NotificationScheduleType.EMERGENCY) + every { sut["getNotificationsFromFile"]() } returns createNotificationsList(emergencyNotification) + every { dismissalState.isDismissed(any()) } returns false + + // First poll + sut.retrieveStartupAndEmergencyNotifications() + // Second poll + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 2) { sut.processNotification(project, emergencyNotification) } + } + + @Test + fun `dismissed notifications are not processed`() { + val notification = createNotification("toBeDismissed-1", NotificationScheduleType.EMERGENCY) + every { sut["getNotificationsFromFile"]() } returns createNotificationsList(notification) + + // first poll results in showing/dismissal + sut.retrieveStartupAndEmergencyNotifications() + NotificationDismissalState.getInstance().dismissNotification(notification.id) + + // second poll skips processing + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 1) { sut.processNotification(project, any()) } + } + + @Test + fun `null notifications list is handled gracefully`() { + every { sut["getNotificationsFromFile"]() } returns null + + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 0) { sut.processNotification(project, any()) } + } + + @Test + fun `empty notifications list is handled gracefully`() { + every { sut["getNotificationsFromFile"]() } returns createNotificationsList() + + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 0) { sut.processNotification(project, any()) } + } + + @Test + fun `multiple notifications are processed correctly`() { + val startupNotification = createNotification("startup-1", NotificationScheduleType.STARTUP) + val emergencyNotification = createNotification("emergency-1", NotificationScheduleType.EMERGENCY) + + every { sut["getNotificationsFromFile"]() } returns createNotificationsList( + startupNotification, + emergencyNotification + ) + every { dismissalState.isDismissed(any()) } returns false + + // First poll - both should be processed + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 1) { sut.processNotification(project, startupNotification) } + verify(exactly = 1) { sut.processNotification(project, emergencyNotification) } + + // Second poll - only emergency should be processed + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 1) { sut.processNotification(project, startupNotification) } + verify(exactly = 2) { sut.processNotification(project, emergencyNotification) } + } + + // Helper functions to create test data + private fun createNotification(id: String, type: NotificationScheduleType) = NotificationData( + id = id, + schedule = NotificationSchedule(type = type), + severity = "INFO", + condition = null, + content = NotificationContentDescriptionLocale( + NotificationContentDescription( + title = "Look at this!", + description = "Some bug is there" + ) + ), + actions = emptyList() + ) + + private fun createNotificationsList(vararg notifications: NotificationData) = NotificationsList( + schema = Schema("1.0"), + notifications = notifications.toList() + ) + + private fun resetIsStartup() { + val clazz = Class.forName("software.aws.toolkits.jetbrains.core.notifications.ProcessNotificationsBaseKt") + val field = clazz.getDeclaredField("isStartup") + field.isAccessible = true + + val value = field.get(null) as AtomicBoolean + value.set(true) + } + + @AfterEach + fun tearDown() { + unmockkAll() + } +} diff --git a/plugins/core/src/main/resources/META-INF/plugin.xml b/plugins/core/src/main/resources/META-INF/plugin.xml index 86639d85020..9703141c302 100644 --- a/plugins/core/src/main/resources/META-INF/plugin.xml +++ b/plugins/core/src/main/resources/META-INF/plugin.xml @@ -22,6 +22,8 @@ + + diff --git a/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml b/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml index 5172c1fc686..969365d6d9c 100644 --- a/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml +++ b/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml @@ -196,7 +196,6 @@ -