158 lines
5.9 KiB
Kotlin
158 lines
5.9 KiB
Kotlin
package com.ponzischeme89.memby.data
|
||
|
||
import com.ponzischeme89.memby.data.model.GatewayHome
|
||
import com.ponzischeme89.memby.data.model.GatewayPlayback
|
||
import kotlinx.serialization.json.Json
|
||
import org.junit.Assert.assertEquals
|
||
import org.junit.Assert.assertTrue
|
||
import org.junit.Test
|
||
|
||
/**
|
||
* Contract tests for the gateway wire format.
|
||
*
|
||
* The fixtures below are the exact shape `server/internal/api` emits: camelCase envelope
|
||
* fields wrapping Emby's own PascalCase item JSON. If someone renames a field on either
|
||
* side, this fails before a TV ever sees it.
|
||
*/
|
||
class GatewayPayloadTest {
|
||
|
||
private val json = Json {
|
||
ignoreUnknownKeys = true
|
||
coerceInputValues = true
|
||
isLenient = true
|
||
explicitNulls = false
|
||
}
|
||
|
||
@Test
|
||
fun `decodes a home payload with emby-shaped items`() {
|
||
val payload = """
|
||
{
|
||
"continueWatching": [
|
||
{"Id":"1","Name":"Dune","Type":"Movie","UserData":{"PlaybackPositionTicks":36000000000}}
|
||
],
|
||
"nextUp": [
|
||
{"Id":"2","Name":"Pilot","Type":"Episode","SeriesName":"Severance"}
|
||
],
|
||
"favorites": [
|
||
{"Id":"3","Name":"Arrival","Type":"Movie","UserData":{"IsFavorite":true}}
|
||
],
|
||
"latestMovies": [],
|
||
"partial": false
|
||
}
|
||
""".trimIndent()
|
||
|
||
val home = json.decodeFromString<GatewayHome>(payload)
|
||
|
||
assertEquals("Dune", home.continueWatching.single().name)
|
||
assertEquals(3_600_000L, home.continueWatching.single().userData!!.playbackPositionTicks / 10_000L)
|
||
assertEquals("Severance", home.nextUp.single().seriesName)
|
||
assertTrue(home.favorites.single().isFavorite)
|
||
assertTrue(home.latestMovies.isEmpty())
|
||
assertEquals(false, home.partial)
|
||
}
|
||
|
||
@Test
|
||
fun `decodes server-composed rows including recommendations`() {
|
||
val payload = """
|
||
{
|
||
"rows": [
|
||
{"id":"continue","title":"Continue Watching","kind":"continue","items":[{"Id":"1","Name":"Dune","Type":"Movie"}]},
|
||
{"id":"favorites","title":"Favourites","kind":"favorites","items":[{"Id":"3","Name":"Arrival","Type":"Movie"}]},
|
||
{"id":"similar:sev","title":"Because you watched Severance","kind":"similar","items":[{"Id":"7","Name":"Devs","Type":"Series"}]},
|
||
{"id":"recommended","title":"Recommended from your watching history","kind":"recommended","items":[{"Id":"8","Name":"Solaris","Type":"Movie"}]}
|
||
],
|
||
"continueWatching": [{"Id":"1","Name":"Dune","Type":"Movie"}],
|
||
"nextUp": [],
|
||
"favorites": [{"Id":"3","Name":"Arrival","Type":"Movie"}],
|
||
"latestMovies": [],
|
||
"partial": false
|
||
}
|
||
""".trimIndent()
|
||
|
||
val home = json.decodeFromString<GatewayHome>(payload)
|
||
|
||
assertEquals(
|
||
listOf("continue", "favorites", "similar:sev", "recommended"),
|
||
home.rows.map { it.id },
|
||
)
|
||
assertEquals("Because you watched Severance", home.rows[2].title)
|
||
assertEquals("Solaris", home.rows.last().items.single().name)
|
||
}
|
||
|
||
@Test
|
||
fun `decodes the informational Sonarr schedule row`() {
|
||
val payload = """
|
||
{
|
||
"rows": [{
|
||
"id":"sonarr-airing-today",
|
||
"title":"Shows airing today",
|
||
"kind":"schedule",
|
||
"items":[{
|
||
"Id":"sonarr:7:42",
|
||
"Name":"Northbound",
|
||
"Type":"MembySonarrEpisode",
|
||
"ImageTags":{"Primary":"sonarr"},
|
||
"MembySource":"sonarr",
|
||
"MembyEpisodeTitle":"The Crossing",
|
||
"MembyEpisodeCode":"S02E04",
|
||
"MembyAirLabel":"Airs today at 8:00 PM",
|
||
"MembyAvailability":"downloading",
|
||
"MembyAvailabilityText":"Downloading",
|
||
"MembyPlayable":false
|
||
}]
|
||
}],
|
||
"continueWatching":[],
|
||
"nextUp":[],
|
||
"favorites":[],
|
||
"latestMovies":[],
|
||
"partial":false
|
||
}
|
||
""".trimIndent()
|
||
|
||
val item = json.decodeFromString<GatewayHome>(payload).rows.single().items.single()
|
||
|
||
assertTrue(item.isSonarrSchedule)
|
||
assertEquals("S02E04", item.membyEpisodeCode)
|
||
assertEquals("Downloading", item.membyAvailabilityText)
|
||
assertEquals(false, item.membyPlayable)
|
||
}
|
||
|
||
@Test
|
||
fun `a home payload without rows still decodes`() {
|
||
// The gateway omits recommendation rows while they are still building, and an
|
||
// older gateway would not send `rows` at all.
|
||
val home = json.decodeFromString<GatewayHome>(
|
||
"""{"continueWatching":[],"nextUp":[],"favorites":[],"latestMovies":[],"partial":false}""",
|
||
)
|
||
assertTrue(home.rows.isEmpty())
|
||
}
|
||
|
||
@Test
|
||
fun `a partial home payload still decodes`() {
|
||
val home = json.decodeFromString<GatewayHome>(
|
||
"""{"continueWatching":[],"nextUp":[],"favorites":[],"latestMovies":[],"partial":true}""",
|
||
)
|
||
assertTrue(home.partial)
|
||
}
|
||
|
||
@Test
|
||
fun `decodes a playback response`() {
|
||
val playback = json.decodeFromString<GatewayPlayback>(
|
||
"""{"itemId":"9","title":"Severance – Pilot","url":"https://emby.example/Videos/9/stream?static=true","resumePositionMs":42000}""",
|
||
)
|
||
assertEquals("9", playback.itemId)
|
||
assertEquals(42_000L, playback.resumePositionMs)
|
||
assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream"))
|
||
}
|
||
|
||
@Test
|
||
fun `device allowance response becomes a specific sign-in error`() {
|
||
val error = parseDeviceLimit(
|
||
"""{"error":"device_limit_reached","activeClients":3,"maxClientsPerUser":3}""",
|
||
)
|
||
|
||
assertEquals(3, error?.activeClients)
|
||
assertEquals(3, error?.maxClients)
|
||
}
|
||
}
|