From 7fb620e7ca18c66137db91570c4d0169c9780415 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Wed, 21 Oct 2026 16:54:40 +0000 Subject: [PATCH] Upload via GUI (8346 Dateien) --- mobile/build-release.ps1 | 83 ++--- mobile/build.gradle.kts | 311 +++++++++--------- mobile/google-services.json | 29 ++ mobile/gradle.properties | 1 + .../de/deskhub/staff/push/DeskHubPushTest.kt | 125 +++++++ 5 files changed, 348 insertions(+), 201 deletions(-) create mode 100644 mobile/google-services.json create mode 100644 mobile/src/test/java/de/deskhub/staff/push/DeskHubPushTest.kt diff --git a/mobile/build-release.ps1 b/mobile/build-release.ps1 index 769d37ab..9214c6dd 100644 --- a/mobile/build-release.ps1 +++ b/mobile/build-release.ps1 @@ -1,47 +1,36 @@ -param() - -$ErrorActionPreference = 'Stop' - -$required = @( - 'DESKHUB_RELEASE_STORE_FILE', - 'DESKHUB_RELEASE_STORE_PASSWORD', - 'DESKHUB_RELEASE_KEY_ALIAS', - 'DESKHUB_RELEASE_KEY_PASSWORD' -) - -$missing = @($required | Where-Object { [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($_)) }) -if ($missing.Count -gt 0) { - throw "Fehlende Umgebungsvariablen: $($missing -join ', ')" -} - -$storeFile = [Environment]::GetEnvironmentVariable('DESKHUB_RELEASE_STORE_FILE') -if (-not (Test-Path -LiteralPath $storeFile -PathType Leaf)) { - throw "Keystore nicht gefunden: $storeFile" -} - -$projectRoot = $PSScriptRoot -$javaHome = [Environment]::GetEnvironmentVariable('JAVA_HOME') -if ([string]::IsNullOrWhiteSpace($javaHome)) { - throw 'JAVA_HOME muss auf ein JDK 17 zeigen.' -} - -Push-Location $projectRoot -try { - & "$projectRoot\gradlew.bat" clean assembleRelease --no-daemon --console=plain - if ($LASTEXITCODE -ne 0) { - throw "Release-Build fehlgeschlagen (Exitcode $LASTEXITCODE)." - } - - $source = "$projectRoot\build\outputs\apk\release\DeskHubMobile-release.apk" - if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { - throw 'Signierte Release-APK wurde nicht erzeugt.' - } - - $artifactDirectory = "$projectRoot\artifacts" - New-Item -ItemType Directory -Path $artifactDirectory -Force | Out-Null - $target = "$artifactDirectory\deskhub-employee-release.apk" - Copy-Item -LiteralPath $source -Destination $target -Force - Get-FileHash -Algorithm SHA256 -LiteralPath $target -} finally { - Pop-Location -} +<# +.SYNOPSIS + Baut die signierte Release-App: AAB für den Play Store und APK für direkte Installation. + +.DESCRIPTION + Fragt Keystore-Datei und Passwort ab (oder nimmt DESKHUB_RELEASE_* aus der Umgebung). Das Passwort bleibt nur in diesem Prozess. + Ergebnis: artifacts\deskhub-release.aab (Play Store) und artifacts\deskhub-employee-release.apk. Vorher versionCode in build.gradle.kts erhöhen. +#> +param([string]$StoreFile = (Join-Path $env:USERPROFILE 'deskhub-keys\deskhub-release.jks'), [string]$Alias = 'deskhub') +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($env:DESKHUB_RELEASE_STORE_FILE)) { $env:DESKHUB_RELEASE_STORE_FILE = $StoreFile } +if ([string]::IsNullOrWhiteSpace($env:DESKHUB_RELEASE_KEY_ALIAS)) { $env:DESKHUB_RELEASE_KEY_ALIAS = $Alias } +if (-not (Test-Path -LiteralPath $env:DESKHUB_RELEASE_STORE_FILE -PathType Leaf)) { throw "Keystore nicht gefunden: $($env:DESKHUB_RELEASE_STORE_FILE). Erst .\new-keystore.ps1 ausführen." } +if ([string]::IsNullOrWhiteSpace($env:DESKHUB_RELEASE_STORE_PASSWORD)) { + $s = Read-Host 'Keystore-Passwort' -AsSecureString + $env:DESKHUB_RELEASE_STORE_PASSWORD = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s)) +} +if ([string]::IsNullOrWhiteSpace($env:DESKHUB_RELEASE_KEY_PASSWORD)) { $env:DESKHUB_RELEASE_KEY_PASSWORD = $env:DESKHUB_RELEASE_STORE_PASSWORD } +if (-not (Test-Path (Join-Path $PSScriptRoot 'google-services.json'))) { throw 'google-services.json fehlt im Ordner mobile (aus der Firebase-Konsole).' } + +Push-Location $PSScriptRoot +try { + & "$PSScriptRoot\gradlew.bat" clean bundleRelease assembleRelease --no-daemon --console=plain + if ($LASTEXITCODE -ne 0) { throw "Release-Build fehlgeschlagen (Exitcode $LASTEXITCODE)." } + New-Item -ItemType Directory -Path artifacts -Force | Out-Null + $aab = Get-ChildItem -Recurse -Filter '*.aab' build\outputs\bundle\release | Select-Object -First 1 + $apk = "$PSScriptRoot\build\outputs\apk\release\DeskHubMobile-release.apk" + if (-not $aab -or -not (Test-Path $apk)) { throw 'AAB oder APK wurde nicht erzeugt.' } + Copy-Item $aab.FullName artifacts\deskhub-release.aab -Force + Copy-Item $apk artifacts\deskhub-employee-release.apk -Force + Get-FileHash -Algorithm SHA256 artifacts\deskhub-release.aab, artifacts\deskhub-employee-release.apk +} finally { + Pop-Location + Remove-Item Env:\DESKHUB_RELEASE_STORE_PASSWORD, Env:\DESKHUB_RELEASE_KEY_PASSWORD -ErrorAction SilentlyContinue +} diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts index d9c579c0..180b8338 100644 --- a/mobile/build.gradle.kts +++ b/mobile/build.gradle.kts @@ -1,154 +1,157 @@ -plugins { - id("com.android.application") version "8.4.2" - id("org.jetbrains.kotlin.android") version "1.9.24" -} - -extra["author"] = "M_Viper" -extra["productName"] = "deskhub" - -val releaseStoreFile = providers.environmentVariable("DESKHUB_RELEASE_STORE_FILE").orNull -val releaseStorePassword = providers.environmentVariable("DESKHUB_RELEASE_STORE_PASSWORD").orNull -val releaseKeyAlias = providers.environmentVariable("DESKHUB_RELEASE_KEY_ALIAS").orNull -val releaseKeyPassword = providers.environmentVariable("DESKHUB_RELEASE_KEY_PASSWORD").orNull -val releaseSigningConfigured = listOf( - releaseStoreFile, - releaseStorePassword, - releaseKeyAlias, - releaseKeyPassword, -).all { !it.isNullOrBlank() } - -android { - namespace = "de.deskhub.staff" - compileSdk = 34 - - defaultConfig { - applicationId = "de.deskhub.staff" - minSdk = 26 - targetSdk = 34 - // WICHTIG: Bei jedem neuen Debug-Build erhöhen. Android erkennt eine APK mit - // unverändertem versionCode (+ gleicher Signatur) auf manchen Geräten nicht - // zuverlässig als Update, wodurch eine Neuinstallation still fehlschlagen kann. - versionCode = 19 - versionName = "2.1.0" - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - buildFeatures { - compose = true - buildConfig = true - } - - composeOptions { - kotlinCompilerExtensionVersion = "1.5.14" - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = "17" - } - - signingConfigs { - if (releaseSigningConfigured) { - create("release") { - storeFile = file(requireNotNull(releaseStoreFile)) - storePassword = requireNotNull(releaseStorePassword) - keyAlias = requireNotNull(releaseKeyAlias) - keyPassword = requireNotNull(releaseKeyPassword) - } - } - } - - buildTypes { - release { - isMinifyEnabled = true - if (releaseSigningConfigured) { - signingConfig = signingConfigs.getByName("release") - } - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro", - ) - } - } - - testOptions { - unitTests.isIncludeAndroidResources = true - } - - packaging { - resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" - } -} - -dependencies { - val composeBom = platform("androidx.compose:compose-bom:2024.09.00") - implementation(composeBom) - androidTestImplementation(composeBom) - - implementation("androidx.activity:activity-compose:1.9.2") - implementation("androidx.compose.ui:ui") - implementation("androidx.compose.ui:ui-tooling-preview") - implementation("androidx.compose.material3:material3") - implementation("androidx.compose.material:material-icons-extended") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3") - implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3") - implementation("androidx.navigation:navigation-compose:2.7.7") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") - implementation("com.squareup.okhttp3:okhttp:4.12.0") - implementation("com.google.code.gson:gson:2.11.0") - - testImplementation("junit:junit:4.13.2") - testImplementation("org.robolectric:robolectric:4.13") - testImplementation(composeBom) - testImplementation("androidx.compose.ui:ui-test-junit4") - testImplementation("androidx.test.ext:junit:1.2.1") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") - androidTestImplementation("androidx.test.ext:junit:1.2.1") - androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") - androidTestImplementation("androidx.compose.ui:ui-test-junit4") - debugImplementation("androidx.compose.ui:ui-tooling") - debugImplementation("androidx.compose.ui:ui-test-manifest") -} - -val verifyBranding by tasks.registering { - doLast { - val strings = layout.projectDirectory - .file("src/main/res/values/strings.xml") - .asFile - .readText() - check("deskhub" in strings) { - "app_name muss exakt deskhub lauten." - } - val readme = layout.projectDirectory.file("README.md").asFile.readText() - check("Ersteller: M_Viper" in readme) { - "README.md muss das Feld Ersteller: M_Viper enthalten." - } - check(project.extra["author"] == "M_Viper") { - "Gradle-Projektmetadatum author muss M_Viper sein." - } - } -} - -val exportDebugApk by tasks.registering { - doLast { - val source = layout.buildDirectory.file("outputs/apk/debug/DeskHubMobile-debug.apk").get().asFile - check(source.isFile) { "Debug-APK wurde nicht erzeugt: $source" } - val target = layout.projectDirectory.file("artifacts/deskhub-employee-debug.apk").asFile - target.parentFile.mkdirs() - source.copyTo(target, overwrite = true) - } -} - -tasks.configureEach { - if (name == "preBuild") { - dependsOn(verifyBranding) - } - if (name == "assembleDebug") { - finalizedBy(exportDebugApk) - } -} +plugins { + id("com.android.application") version "8.4.2" + id("org.jetbrains.kotlin.android") version "1.9.24" + id("com.google.gms.google-services") version "4.4.4" +} + +extra["author"] = "M_Viper" +extra["productName"] = "deskhub" + +val releaseStoreFile = providers.environmentVariable("DESKHUB_RELEASE_STORE_FILE").orNull +val releaseStorePassword = providers.environmentVariable("DESKHUB_RELEASE_STORE_PASSWORD").orNull +val releaseKeyAlias = providers.environmentVariable("DESKHUB_RELEASE_KEY_ALIAS").orNull +val releaseKeyPassword = providers.environmentVariable("DESKHUB_RELEASE_KEY_PASSWORD").orNull +val releaseSigningConfigured = listOf( + releaseStoreFile, + releaseStorePassword, + releaseKeyAlias, + releaseKeyPassword, +).all { !it.isNullOrBlank() } + +android { + namespace = "de.deskhub.staff" + compileSdk = 35 + + defaultConfig { + applicationId = "de.deskhub.staff" + minSdk = 26 + targetSdk = 35 + // WICHTIG: Bei jedem neuen Debug-Build erhöhen. Android erkennt eine APK mit + // unverändertem versionCode (+ gleicher Signatur) auf manchen Geräten nicht + // zuverlässig als Update, wodurch eine Neuinstallation still fehlschlagen kann. + versionCode = 26 + versionName = "2.5.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { + compose = true + buildConfig = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.14" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + signingConfigs { + if (releaseSigningConfigured) { + create("release") { + storeFile = file(requireNotNull(releaseStoreFile)) + storePassword = requireNotNull(releaseStorePassword) + keyAlias = requireNotNull(releaseKeyAlias) + keyPassword = requireNotNull(releaseKeyPassword) + } + } + } + + buildTypes { + release { + isMinifyEnabled = true + if (releaseSigningConfigured) { + signingConfig = signingConfigs.getByName("release") + } + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + testOptions { + unitTests.isIncludeAndroidResources = true + } + + packaging { + resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.09.00") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.activity:activity-compose:1.9.2") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3") + implementation("androidx.navigation:navigation-compose:2.7.7") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.google.code.gson:gson:2.11.0") + // Push-Benachrichtigungen (Firebase Cloud Messaging) + implementation("com.google.firebase:firebase-messaging:25.0.1") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.robolectric:robolectric:4.13") + testImplementation(composeBom) + testImplementation("androidx.compose.ui:ui-test-junit4") + testImplementation("androidx.test.ext:junit:1.2.1") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} + +val verifyBranding by tasks.registering { + doLast { + val strings = layout.projectDirectory + .file("src/main/res/values/strings.xml") + .asFile + .readText() + check("deskhub" in strings) { + "app_name muss exakt deskhub lauten." + } + val readme = layout.projectDirectory.file("README.md").asFile.readText() + check("Ersteller: M_Viper" in readme) { + "README.md muss das Feld Ersteller: M_Viper enthalten." + } + check(project.extra["author"] == "M_Viper") { + "Gradle-Projektmetadatum author muss M_Viper sein." + } + } +} + +val exportDebugApk by tasks.registering { + doLast { + val source = layout.buildDirectory.file("outputs/apk/debug/DeskHubMobile-debug.apk").get().asFile + check(source.isFile) { "Debug-APK wurde nicht erzeugt: $source" } + val target = layout.projectDirectory.file("artifacts/deskhub-employee-debug.apk").asFile + target.parentFile.mkdirs() + source.copyTo(target, overwrite = true) + } +} + +tasks.configureEach { + if (name == "preBuild") { + dependsOn(verifyBranding) + } + if (name == "assembleDebug") { + finalizedBy(exportDebugApk) + } +} diff --git a/mobile/google-services.json b/mobile/google-services.json new file mode 100644 index 00000000..d1edcdf6 --- /dev/null +++ b/mobile/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "436755419487", + "project_id": "deskhub-96d3f", + "storage_bucket": "deskhub-96d3f.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:436755419487:android:8bad822266b61db37466ea", + "android_client_info": { + "package_name": "de.deskhub.staff" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBN6ud1flyG_aFJdv25fsFQpHpxpESiDlk" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/mobile/gradle.properties b/mobile/gradle.properties index c70be5a5..ea64512f 100644 --- a/mobile/gradle.properties +++ b/mobile/gradle.properties @@ -1,3 +1,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true kotlin.code.style=official +android.suppressUnsupportedCompileSdk=35 diff --git a/mobile/src/test/java/de/deskhub/staff/push/DeskHubPushTest.kt b/mobile/src/test/java/de/deskhub/staff/push/DeskHubPushTest.kt new file mode 100644 index 00000000..a004b2bc --- /dev/null +++ b/mobile/src/test/java/de/deskhub/staff/push/DeskHubPushTest.kt @@ -0,0 +1,125 @@ +package de.deskhub.staff.push + +import android.app.Application +import android.app.NotificationManager +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** Benachrichtigungen der Push-Nachrichten (auf der JVM mit Robolectric). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class DeskHubPushTest { + private val context: Application get() = ApplicationProvider.getApplicationContext() + + private fun grantPermission() = shadowOf(context).grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS) + + private fun manager() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + @Test + fun createsHighImportanceChannel() { + DeskHubPush.ensureChannel(context) + val channel = manager().getNotificationChannel(DeskHubPush.CHANNEL_TICKETS) + assertNotNull(channel) + assertEquals(NotificationManager.IMPORTANCE_HIGH, channel.importance) + } + + @Test + fun showsNotificationWithTitleTextAndOpensTicket() { + grantPermission() + val shown = DeskHubPush.show(context, mapOf("type" to "ticket_new", "ticket" to "T-123", "title" to "Neues Ticket", "body" to "#T-123")) + assertTrue(shown) + val posted = shadowOf(manager()).allNotifications + assertEquals(1, posted.size) + val n = posted[0] + assertEquals("Neues Ticket", n.extras.getCharSequence("android.title").toString()) + assertEquals("#T-123", n.extras.getCharSequence("android.text").toString()) + // Tipp öffnet die App mit der Ticketnummer + val intent = shadowOf(n.contentIntent).savedIntent + assertEquals("T-123", intent.getStringExtra(DeskHubPush.EXTRA_TICKET)) + } + + @Test + fun sameTicketReplacesEarlierNotification() { + grantPermission() + val data = mapOf("type" to "ticket_reply", "ticket" to "T-7", "title" to "Neue Antwort", "body" to "#T-7") + DeskHubPush.show(context, data) + DeskHubPush.show(context, data) + assertEquals(1, shadowOf(manager()).allNotifications.size) + DeskHubPush.show(context, data + ("ticket" to "T-8")) + assertEquals(2, shadowOf(manager()).allNotifications.size) + } + + @Test + fun ignoresUnknownOrEmptyMessages() { + grantPermission() + assertFalse(DeskHubPush.show(context, mapOf("type" to "irgendwas", "title" to "x"))) + assertFalse(DeskHubPush.show(context, mapOf("type" to "ticket_new", "title" to " "))) + assertFalse(DeskHubPush.show(context, emptyMap())) + assertEquals(0, shadowOf(manager()).allNotifications.size) + } + + @Test + fun showsNothingWithoutPermission() { + shadowOf(context).denyPermissions(android.Manifest.permission.POST_NOTIFICATIONS) + assertFalse(DeskHubPush.canNotify(context)) + assertFalse(DeskHubPush.show(context, mapOf("type" to "ticket_new", "ticket" to "T-1", "title" to "Neues Ticket"))) + assertEquals(0, shadowOf(manager()).allNotifications.size) + } + + @Test + fun customerReplyHasQuickReplyActionOthersDoNot() { + grantPermission() + DeskHubPush.show(context, mapOf("type" to "ticket_reply", "ticket" to "T-5", "title" to "Neue Antwort", "body" to "#T-5")) + DeskHubPush.show(context, mapOf("type" to "ticket_new", "ticket" to "T-6", "title" to "Neues Ticket", "body" to "#T-6")) + val all = shadowOf(manager()).allNotifications + val reply = all.first { it.extras.getCharSequence("android.text").toString() == "#T-5" } + val other = all.first { it.extras.getCharSequence("android.text").toString() == "#T-6" } + assertEquals(1, reply.actions.size) + assertEquals("Antworten", reply.actions[0].title.toString()) + assertNotNull(reply.actions[0].remoteInputs) + assertEquals(PushReplyReceiver.KEY_REPLY, reply.actions[0].remoteInputs[0].resultKey) + assertTrue(other.actions == null || other.actions.isEmpty()) + } + + @Test + fun newEventTypesAreShown() { + grantPermission() + assertTrue(DeskHubPush.show(context, mapOf("type" to "ticket_sla", "ticket" to "T-9", "title" to "SLA überschritten", "body" to "#T-9"))) + assertTrue(DeskHubPush.show(context, mapOf("type" to "ticket_mention", "ticket" to "T-9", "title" to "Erwähnung", "body" to "#T-9"))) + assertEquals(2, shadowOf(manager()).allNotifications.size) + } + + @Test + fun brandNameShowsNextToAppName() { + grantPermission() + DeskHubPush.show(context, mapOf("type" to "ticket_new", "ticket" to "T-20", "title" to "Neues Ticket", "body" to "#T-20", "brand" to "Windel Support")) + DeskHubPush.show(context, mapOf("type" to "ticket_new", "ticket" to "T-21", "title" to "Neues Ticket", "body" to "#T-21")) + val all = shadowOf(manager()).allNotifications + val withBrand = all.first { it.extras.getCharSequence("android.text").toString() == "#T-20" } + val without = all.first { it.extras.getCharSequence("android.text").toString() == "#T-21" } + assertEquals("Windel Support", withBrand.extras.getCharSequence("android.subText").toString()) + assertTrue(without.extras.getCharSequence("android.subText") == null) + } + + @Test + fun quickReplyTextIsEscapedForHtml() { + assertEquals("a <b> & c
d", PushReplyReceiver.toHtml("a & c\nd")) + } + + @Test + fun notificationIdsDifferPerTicketAndType() { + assertTrue(DeskHubPush.notificationId("ticket_new", "T-1") != DeskHubPush.notificationId("ticket_new", "T-2")) + assertTrue(DeskHubPush.notificationId("ticket_new", "T-1") != DeskHubPush.notificationId("ticket_reply", "T-1")) + assertEquals(DeskHubPush.notificationId("ticket_new", "T-1"), DeskHubPush.notificationId("ticket_new", "T-1")) + assertTrue(DeskHubPush.notificationId("ticket_new", "T-1") >= 0) + } +}