Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import com.onesignal.session.internal.outcomes.impl.OutcomeEventsPreferences
import com.onesignal.session.internal.outcomes.impl.OutcomeEventsRepository
import com.onesignal.session.internal.session.ISessionService
import com.onesignal.session.internal.session.SessionModelStore
import com.onesignal.session.internal.session.backend.ISessionsBackendService
import com.onesignal.session.internal.session.backend.impl.SessionsBackendService
import com.onesignal.session.internal.session.impl.SessionListener
import com.onesignal.session.internal.session.impl.SessionService

Expand All @@ -43,5 +45,6 @@ internal class SessionModule : IModule {
.provides<IBootstrapService>()
builder.register<SessionListener>().provides<IStartableService>()
builder.register<SessionManager>().provides<ISessionManager>()
builder.register<SessionsBackendService>().provides<ISessionsBackendService>()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.onesignal.session.internal.session.backend

import com.onesignal.common.exceptions.BackendException

/**
* The backend service for the public sessions API.
*
* If there is a non-successful response from the backend, a [BackendException] will be thrown with response data.
*/
internal interface ISessionsBackendService {
/**
* Create a session.
*
* @return The backend session ID, or null if the success response did not include one.
*/
suspend fun createSession(
appId: String,
request: CreateSessionRequest,
): String?

/**
* Update an existing session with its cumulative duration, and end it when [UpdateSessionRequest.endTime] is set.
*/
suspend fun updateSession(
appId: String,
sessionId: String,
request: UpdateSessionRequest,
)
}

internal data class CreateSessionRequest(
val onesignalId: String,
val subscriptionId: String,
val deviceType: Int,
/** Unix time in seconds, sent as ISO 8601 UTC. */
val startTime: Long,
val idempotencyKey: String,
val directAttributionId: String? = null,
)

internal data class UpdateSessionRequest(
val onesignalId: String,
val subscriptionId: String,
val durationSeconds: Long,
val idempotencyKey: String,
/** Unix time in seconds, sent as ISO 8601 UTC. */
val endTime: Long? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.onesignal.session.internal.session.backend.impl

import com.onesignal.common.DateUtils
import com.onesignal.common.exceptions.BackendException
import com.onesignal.core.internal.http.IHttpClient
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.session.internal.session.backend.CreateSessionRequest
import com.onesignal.session.internal.session.backend.ISessionsBackendService
import com.onesignal.session.internal.session.backend.UpdateSessionRequest
import org.json.JSONException
import org.json.JSONObject
import java.util.Date

internal class SessionsBackendService(
private val httpClient: IHttpClient,
) : ISessionsBackendService {
override suspend fun createSession(
appId: String,
request: CreateSessionRequest,
): String? {
val body =
JSONObject()
.put("onesignal_id", request.onesignalId)
.put("subscription_id", request.subscriptionId)
.put("device_type", request.deviceType)
.put("start_time", toIso8601(request.startTime))
.put("idempotency_key", request.idempotencyKey)
request.directAttributionId?.let { body.put("direct_attribution_id", it) }

val response = httpClient.post("apps/$appId/sessions", body)
if (!response.isSuccess) {
throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds)
}

return parseSessionId(response.payload)
}

override suspend fun updateSession(
appId: String,
sessionId: String,
request: UpdateSessionRequest,
) {
val body =
JSONObject()
.put("onesignal_id", request.onesignalId)
.put("subscription_id", request.subscriptionId)
.put("duration_seconds", request.durationSeconds)
.put("idempotency_key", request.idempotencyKey)
request.endTime?.let { body.put("end_time", toIso8601(it)) }

val response = httpClient.patch("apps/$appId/sessions/$sessionId", body)
if (!response.isSuccess) {
throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds)
}
}

private fun toIso8601(epochSeconds: Long): String = DateUtils.iso8601Format().format(Date(epochSeconds * MILLIS_PER_SECOND))

private fun parseSessionId(payload: String?): String? =
try {
val sessionId = payload?.let { JSONObject(it) }?.optJSONObject("data")?.opt("session_id") as? String
sessionId?.takeIf { it.isNotEmpty() }
} catch (e: JSONException) {
Logging.warn("SessionsBackendService: unable to parse create session response", e)
null
}

private companion object {
const val MILLIS_PER_SECOND = 1000L
}
Comment thread
fadi-george marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package com.onesignal.session.internal.session.backend

import com.onesignal.common.exceptions.BackendException
import com.onesignal.core.internal.http.HttpResponse
import com.onesignal.core.internal.http.IHttpClient
import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.session.internal.session.backend.impl.SessionsBackendService
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk

private val createRequest =
CreateSessionRequest(
onesignalId = "onesignalId",
subscriptionId = "subscriptionId",
deviceType = 1,
startTime = 1_700_000_000L,
idempotencyKey = "create-key",
)

private val updateRequest =
UpdateSessionRequest(
onesignalId = "onesignalId",
subscriptionId = "subscriptionId",
durationSeconds = 42L,
idempotencyKey = "update-key",
)

class SessionsBackendServiceTests : FunSpec({
beforeAny {
Logging.logLevel = LogLevel.NONE
}

test("create session posts the request body") {
// Given
val http = mockk<IHttpClient>()
coEvery { http.post(any(), any()) } returns HttpResponse(202, """{"data":{"session_id":"server-id"}}""")
val service = SessionsBackendService(http)

// When
service.createSession("appId", createRequest)

// Then
coVerify {
http.post(
"apps/appId/sessions",
withArg {
it.getString("onesignal_id") shouldBe "onesignalId"
it.getString("subscription_id") shouldBe "subscriptionId"
it.getInt("device_type") shouldBe 1
it.getString("start_time") shouldBe "2023-11-14T22:13:20.000Z"
it.getString("idempotency_key") shouldBe "create-key"
it.has("direct_attribution_id") shouldBe false
},
)
}
}

test("create session includes direct attribution when set") {
// Given
val http = mockk<IHttpClient>()
coEvery { http.post(any(), any()) } returns HttpResponse(202, """{"data":{"session_id":"server-id"}}""")
val service = SessionsBackendService(http)

// When
service.createSession("appId", createRequest.copy(directAttributionId = "notificationId"))

// Then
coVerify {
http.post(
"apps/appId/sessions",
withArg { it.getString("direct_attribution_id") shouldBe "notificationId" },
)
}
}

test("create session returns the session ID from a 202") {
// Given
val http = mockk<IHttpClient>()
coEvery { http.post(any(), any()) } returns HttpResponse(202, """{"data":{"session_id":"server-id"}}""")
val service = SessionsBackendService(http)

// When
val result = service.createSession("appId", createRequest)

// Then
result shouldBe "server-id"
}

test("create session returns null for a success response without a session ID") {
listOf(
null,
"",
"not json",
"{}",
"""{"data":{}}""",
"""{"data":{"session_id":""}}""",
"""{"data":{"session_id":null}}""",
"""{"data":{"session_id":123}}""",
"""{"data":"server-id"}""",
).forEach { payload ->
// Given
val http = mockk<IHttpClient>()
coEvery { http.post(any(), any()) } returns HttpResponse(202, payload)
val service = SessionsBackendService(http)

// When
val result = service.createSession("appId", createRequest)

// Then
result shouldBe null
}
}

test("update session patches the request body") {
// Given
val http = mockk<IHttpClient>()
coEvery { http.patch(any(), any()) } returns HttpResponse(202, null)
val service = SessionsBackendService(http)

// When
service.updateSession("appId", "server-id", updateRequest)

// Then
coVerify {
http.patch(
"apps/appId/sessions/server-id",
withArg {
it.getString("onesignal_id") shouldBe "onesignalId"
it.getString("subscription_id") shouldBe "subscriptionId"
it.getLong("duration_seconds") shouldBe 42L
it.getString("idempotency_key") shouldBe "update-key"
it.has("end_time") shouldBe false
},
)
}
}

test("update session includes end time when set") {
// Given
val http = mockk<IHttpClient>()
coEvery { http.patch(any(), any()) } returns HttpResponse(202, null)
val service = SessionsBackendService(http)

// When
service.updateSession("appId", "server-id", updateRequest.copy(endTime = 1_700_000_042L))

// Then
coVerify {
http.patch(
"apps/appId/sessions/server-id",
withArg { it.getString("end_time") shouldBe "2023-11-14T22:14:02.000Z" },
)
}
}

test("failures throw BackendException with the status code and Retry-After") {
listOf(-1, 0, 400, 404, 422, 429, 503).forEach { statusCode ->
// Given
val http = mockk<IHttpClient>()
coEvery { http.post(any(), any()) } returns HttpResponse(statusCode, "error", retryAfterSeconds = 30)
coEvery { http.patch(any(), any()) } returns HttpResponse(statusCode, "error", retryAfterSeconds = 30)
val service = SessionsBackendService(http)

// When
val createException = shouldThrow<BackendException> { service.createSession("appId", createRequest) }
val updateException = shouldThrow<BackendException> { service.updateSession("appId", "server-id", updateRequest) }

// Then
listOf(createException, updateException).forEach {
it.statusCode shouldBe statusCode
it.response shouldBe "error"
it.retryAfterSeconds shouldBe 30
}
}
}
})
Loading