package dev.castarr.tv.data import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** * Shapes taken from the household's own Dispatcharr fork. The backend path * had no coverage at all, and the first run against a real instance found * three bugs — these are the shapes that produced them. */ class DispatcharrJsonTest { private fun streamUrl(uuid: String) = "https://tv.example/proxy/ts/stream/$uuid" private fun logoUrl(id: Int) = "https://tv.example/api/channels/logos/$id/cache/" private val groups = mapOf(1 to "Free TV / HD+", 2 to "Sky Sport") @Test fun `a paginated page yields its channels`() { val body = """ {"count": 2, "next": null, "results": [ {"id": 12, "uuid": "aaa", "name": "Das Erste", "effective_name": "Das Erste HD", "channel_group_id": 1, "effective_logo_id": 7, "effective_tvg_id": "ard.de"}, {"id": 13, "uuid": "bbb", "name": "ZDF HD", "channel_group_id": 1, "logo_id": 8} ]} """.trimIndent() val channels = DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl) assertEquals(2, channels.size) val first = channels.first() assertEquals("Das Erste HD", first.name) assertEquals("Free TV / HD+", first.group) assertEquals("ard.de", first.tvgId) assertEquals(12, first.backendId) assertEquals("https://tv.example/proxy/ts/stream/aaa", first.url) assertEquals("https://tv.example/api/channels/logos/7/cache/", first.logo) // Without an effective name the plain one is used, same for the logo. assertEquals("ZDF HD", channels[1].name) assertEquals("https://tv.example/api/channels/logos/8/cache/", channels[1].logo) } /** The proxy resolves by uuid; an integer id gives a 404. */ @Test fun `a channel without uuid is dropped`() { val body = """{"results": [{"id": 1, "uuid": "", "name": "Kaputt"}]}""" assertTrue(DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).isEmpty()) } @Test fun `a channel hidden from output is dropped`() { val body = """ {"results": [{"id": 1, "uuid": "aaa", "name": "Intern", "hidden_from_output": true}]} """.trimIndent() assertTrue(DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).isEmpty()) } @Test fun `an unknown group leaves the group empty instead of failing`() { val body = """{"results": [{"id": 1, "uuid": "aaa", "name": "X", "channel_group_id": 99}]}""" assertEquals("", DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).first().group) } @Test fun `a bare array works like a paginated page`() { val body = """[{"id": 5, "uuid": "ccc", "name": "Bar"}]""" assertEquals(1, DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).size) } @Test fun `the next page is followed only while there is one`() { assertEquals( "https://tv.example/api/channels/channels/?page=2", DispatcharrJson.nextPage("""{"next": "https://tv.example/api/channels/channels/?page=2"}"""), ) assertNull(DispatcharrJson.nextPage("""{"next": null}""")) assertNull(DispatcharrJson.nextPage("""{"next": ""}""")) assertNull(DispatcharrJson.nextPage("""[{"id": 1}]""")) } @Test fun `groups come back as id to name`() { val body = """{"results": [{"id": 1, "name": "Free TV / HD+"}, {"id": 2, "name": "Sky Sport"}]}""" assertEquals(groups, DispatcharrJson.parseGroups(body)) } @Test fun `favourites are the ids under channels`() { assertEquals(setOf(46, 118), DispatcharrJson.parseFavorites("""{"channels": [46, 118]}""")) assertEquals(emptySet(), DispatcharrJson.parseFavorites("""{"channels": []}""")) // A user who never starred anything gets an envelope without the key. assertEquals(emptySet(), DispatcharrJson.parseFavorites("""{}""")) } @Test fun `inactive output profiles are not offered`() { val body = """ {"results": [ {"name": "raw", "is_active": true}, {"name": "alt", "is_active": false}, {"name": "default"} ]} """.trimIndent() assertEquals(listOf("raw", "default"), DispatcharrJson.parseProfiles(body)) } @Test fun `the epg grid is keyed by tvg id and sorted`() { val body = """ {"data": [ {"tvg_id": "ard.de", "title": "Tagesschau", "start_time": "2026-08-26T18:00:00Z", "end_time": "2026-08-26T18:15:00Z"}, {"tvg_id": "ard.de", "title": "Sportschau", "start_time": "2026-08-26T17:00:00Z", "end_time": "2026-08-26T18:00:00Z"} ]} """.trimIndent() val grid = DispatcharrJson.parseEpgGrid(body) assertEquals(listOf("Sportschau", "Tagesschau"), grid.getValue("ard.de").map { it.title }) } @Test fun `grid entries without a usable time or id are skipped`() { val body = """ {"data": [ {"tvg_id": "", "title": "Ohne Sender", "start_time": "2026-08-26T18:00:00Z", "end_time": "2026-08-26T18:15:00Z"}, {"tvg_id": "zdf.de", "title": "Kaputte Zeit", "start_time": "kein Datum", "end_time": "2026-08-26T18:15:00Z"}, {"tvg_id": "zdf.de", "title": "Ende vor Start", "start_time": "2026-08-26T18:15:00Z", "end_time": "2026-08-26T18:00:00Z"} ]} """.trimIndent() assertTrue(DispatcharrJson.parseEpgGrid(body).isEmpty()) } /** The fork has shipped "data", "results" and a bare array over time. */ @Test fun `all three grid envelopes are accepted`() { val entry = """{"tvg_id": "ard.de", "title": "X", "start_time": "2026-08-26T18:00:00Z", "end_time": "2026-08-26T18:15:00Z"}""" listOf("""{"data": [$entry]}""", """{"results": [$entry]}""", """[$entry]""").forEach { body -> assertEquals(1, DispatcharrJson.parseEpgGrid(body).getValue("ard.de").size) } } }