Skip to content

Commit 41e3213

Browse files
authored
Merge pull request #1095 from ably/chore/liveobject-tests
[ECO-5338] Liveobject unit/integration test setup
2 parents e00af51 + 1a7fafd commit 41e3213

15 files changed

Lines changed: 432 additions & 10 deletions

File tree

.github/workflows/check.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ jobs:
1919
distribution: 'temurin'
2020
- name: Set up Gradle
2121
uses: gradle/actions/setup-gradle@v3
22-
- run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests
22+
- run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests runLiveObjectUnitTests

.github/workflows/integration-test.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,21 @@ jobs:
9090
uses: gradle/actions/setup-gradle@v3
9191

9292
- run: ./gradlew :java:testRealtimeSuite -Pokhttp
93+
94+
check-liveobjects:
95+
runs-on: ubuntu-latest
96+
steps:
97+
- uses: actions/checkout@v4
98+
with:
99+
submodules: 'recursive'
100+
101+
- name: Set up the JDK
102+
uses: actions/setup-java@v4
103+
with:
104+
java-version: '17'
105+
distribution: 'temurin'
106+
107+
- name: Set up Gradle
108+
uses: gradle/actions/setup-gradle@v3
109+
110+
- run: ./gradlew runLiveObjectIntegrationTests

gradle/libs.versions.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[versions]
22
agp = "8.5.2"
3-
junit = "4.12"
3+
junit = "4.13.2"
44
gson = "2.9.0"
55
msgpack = "0.8.11"
66
java-websocket = "1.5.3"
@@ -21,7 +21,9 @@ okhttp = "4.12.0"
2121
test-retry = "1.6.0"
2222
kotlin = "2.1.10"
2323
coroutine = "1.9.0"
24+
mockk = "1.14.2"
2425
turbine = "1.2.0"
26+
ktor = "3.1.3"
2527
jetbrains-annoations = "26.0.2"
2628

2729
[libraries]
@@ -47,12 +49,16 @@ android-retrostreams = { group = "net.sourceforge.streamsupport", name = "androi
4749
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
4850
coroutine-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutine" }
4951
coroutine-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutine" }
52+
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
5053
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
54+
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
55+
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
5156
jetbrains = { group = "org.jetbrains", name = "annotations", version.ref = "jetbrains-annoations" }
5257

5358
[bundles]
5459
common = ["msgpack", "vcdiff-core"]
5560
tests = ["junit", "hamcrest-all", "nanohttpd", "nanohttpd-nanolets", "nanohttpd-websocket", "mockito-core", "concurrentunit", "slf4j-simple"]
61+
kotlin-tests = ["junit", "mockk", "coroutine-test", "nanohttpd", "turbine", "ktor-client-cio", "ktor-client-core"]
5662
instrumental-android = ["android-test-runner", "android-test-rules", "dexmaker", "dexmaker-dx", "dexmaker-mockito", "android-retrostreams"]
5763

5864
[plugins]

lib/src/main/java/io/ably/lib/objects/Adapter.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public void setChannelSerial(@NotNull String channelName, @NotNull String channe
2525
}
2626

2727
@Override
28-
public void send(ProtocolMessage msg, CompletionListener listener) throws AblyException {
28+
public void send(@NotNull ProtocolMessage msg, @NotNull CompletionListener listener) throws AblyException {
2929
// Always queue LiveObjects messages to ensure reliable state synchronization and proper acknowledgment
3030
ably.connection.connectionManager.send(msg, true, listener);
3131
}

lib/src/main/java/io/ably/lib/objects/LiveObjectsAdapter.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public interface LiveObjectsAdapter {
1515
* @param listener a listener to be notified of the success or failure of the send operation.
1616
* @throws AblyException if an error occurs during the send operation.
1717
*/
18-
void send(ProtocolMessage msg, CompletionListener listener) throws AblyException;
18+
void send(@NotNull ProtocolMessage msg, @NotNull CompletionListener listener) throws AblyException;
1919

2020
/**
2121
* Sets the channel serial for a specific channel.

live-objects/build.gradle.kts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
2+
13
plugins {
24
`java-library`
35
alias(libs.plugins.kotlin.jvm)
@@ -9,14 +11,34 @@ repositories {
911

1012
dependencies {
1113
implementation(project(":java"))
12-
testImplementation(kotlin("test"))
1314
implementation(libs.coroutine.core)
1415

15-
testImplementation(libs.coroutine.test)
16+
testImplementation(kotlin("test"))
17+
testImplementation(libs.bundles.kotlin.tests)
18+
}
19+
20+
tasks.withType<Test>().configureEach {
21+
testLogging {
22+
exceptionFormat = TestExceptionFormat.FULL
23+
}
24+
jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED")
25+
jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED")
26+
beforeTest(closureOf<TestDescriptor> { logger.lifecycle("-> $this") })
27+
outputs.upToDateWhen { false }
28+
}
29+
30+
tasks.register<Test>("runLiveObjectUnitTests") {
31+
filter {
32+
includeTestsMatching("io.ably.lib.objects.unit.*")
33+
}
1634
}
1735

18-
tasks.test {
19-
useJUnitPlatform()
36+
tasks.register<Test>("runLiveObjectIntegrationTests") {
37+
filter {
38+
includeTestsMatching("io.ably.lib.objects.integration.*")
39+
// Exclude the base integration test class
40+
excludeTestsMatching("io.ably.lib.objects.integration.setup.IntegrationTest")
41+
}
2042
}
2143

2244
kotlin {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package io.ably.lib.objects
2+
3+
internal enum class ErrorCode(public val code: Int) {
4+
BadRequest(40_000),
5+
InternalError(50_000),
6+
}
7+
8+
internal enum class HttpStatusCode(public val code: Int) {
9+
BadRequest(400),
10+
InternalServerError(500),
11+
}

live-objects/src/main/kotlin/io/ably/lib/objects/Helpers.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package io.ably.lib.objects
22

33
import io.ably.lib.realtime.CompletionListener
4-
import io.ably.lib.types.AblyException
54
import io.ably.lib.types.ErrorInfo
65
import io.ably.lib.types.ProtocolMessage
76
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -16,7 +15,7 @@ internal suspend fun LiveObjectsAdapter.sendAsync(message: ProtocolMessage) = su
1615
}
1716

1817
override fun onError(reason: ErrorInfo) {
19-
continuation.resumeWithException(AblyException.fromErrorInfo(reason))
18+
continuation.resumeWithException(ablyException(reason))
2019
}
2120
})
2221
} catch (e: Exception) {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package io.ably.lib.objects
2+
3+
import io.ably.lib.types.AblyException
4+
import io.ably.lib.types.ErrorInfo
5+
6+
internal fun ablyException(
7+
errorMessage: String,
8+
errorCode: ErrorCode,
9+
statusCode: HttpStatusCode = HttpStatusCode.BadRequest,
10+
cause: Throwable? = null,
11+
): AblyException {
12+
val errorInfo = createErrorInfo(errorMessage, errorCode, statusCode)
13+
return createAblyException(errorInfo, cause)
14+
}
15+
16+
internal fun ablyException(
17+
errorInfo: ErrorInfo,
18+
cause: Throwable? = null,
19+
): AblyException = createAblyException(errorInfo, cause)
20+
21+
private fun createErrorInfo(
22+
errorMessage: String,
23+
errorCode: ErrorCode,
24+
statusCode: HttpStatusCode,
25+
) = ErrorInfo(errorMessage, statusCode.code, errorCode.code)
26+
27+
private fun createAblyException(
28+
errorInfo: ErrorInfo,
29+
cause: Throwable?,
30+
) = cause?.let { AblyException.fromErrorInfo(it, errorInfo) }
31+
?: AblyException.fromErrorInfo(errorInfo)
32+
33+
internal fun clientError(errorMessage: String) = ablyException(errorMessage, ErrorCode.BadRequest, HttpStatusCode.BadRequest)
34+
35+
internal fun serverError(errorMessage: String) = ablyException(errorMessage, ErrorCode.InternalError, HttpStatusCode.InternalServerError)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package io.ably.lib.objects
2+
3+
import java.lang.reflect.Field
4+
import kotlinx.coroutines.Dispatchers
5+
import kotlinx.coroutines.delay
6+
import kotlinx.coroutines.suspendCancellableCoroutine
7+
import kotlinx.coroutines.withContext
8+
import kotlinx.coroutines.withTimeout
9+
10+
suspend fun assertWaiter(timeoutInMs: Long = 10_000, block: suspend () -> Boolean) {
11+
withContext(Dispatchers.Default) {
12+
withTimeout(timeoutInMs) {
13+
do {
14+
val success = block()
15+
delay(100)
16+
} while (!success)
17+
}
18+
}
19+
}
20+
21+
fun Any.setPrivateField(name: String, value: Any?) {
22+
val valueField = javaClass.findField(name)
23+
valueField.isAccessible = true
24+
valueField.set(this, value)
25+
}
26+
27+
fun <T>Any.getPrivateField(name: String): T {
28+
val valueField = javaClass.findField(name)
29+
valueField.isAccessible = true
30+
@Suppress("UNCHECKED_CAST")
31+
return valueField.get(this) as T
32+
}
33+
34+
private fun Class<*>.findField(name: String): Field {
35+
var result = kotlin.runCatching { getDeclaredField(name) }
36+
var currentClass = this
37+
while (result.isFailure && currentClass.superclass != null) // stop when we got field or reached top of class hierarchy
38+
{
39+
currentClass = currentClass.superclass!!
40+
result = kotlin.runCatching { currentClass.getDeclaredField(name) }
41+
}
42+
if (result.isFailure) {
43+
throw result.exceptionOrNull() as Exception
44+
}
45+
return result.getOrNull() as Field
46+
}
47+
48+
suspend fun <T> Any.invokePrivateSuspendMethod(methodName: String, vararg args: Any?): T = suspendCancellableCoroutine { cont ->
49+
val suspendMethod = javaClass.declaredMethods.find { it.name == methodName }
50+
?: error("Method '$methodName' not found")
51+
suspendMethod.isAccessible = true
52+
suspendMethod.invoke(this, *args, cont)
53+
}
54+
55+
fun <T> Any.invokePrivateMethod(methodName: String, vararg args: Any?): T {
56+
val method = javaClass.declaredMethods.find { it.name == methodName } ?: error("Method '$methodName' not found")
57+
method.isAccessible = true
58+
@Suppress("UNCHECKED_CAST")
59+
return method.invoke(this, *args) as T
60+
}

0 commit comments

Comments
 (0)