Skip to content

Commit e5de5eb

Browse files
committed
test: cover YouTube session stream extraction
1 parent 0f8e4b4 commit e5de5eb

3 files changed

Lines changed: 214 additions & 0 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package dev.typetype.server
2+
3+
import dev.typetype.server.models.ExtractionResult
4+
import dev.typetype.server.routes.streamRoutes
5+
import dev.typetype.server.services.AuthService
6+
import dev.typetype.server.services.StreamService
7+
import dev.typetype.server.services.YOUTUBE_SESSION_RECONNECT_ERROR
8+
import io.ktor.client.request.get
9+
import io.ktor.client.request.headers
10+
import io.ktor.client.statement.bodyAsText
11+
import io.ktor.http.HttpHeaders
12+
import io.ktor.http.HttpStatusCode
13+
import io.ktor.serialization.kotlinx.json.json
14+
import io.ktor.server.application.install
15+
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
16+
import io.ktor.server.routing.routing
17+
import io.ktor.server.testing.testApplication
18+
import io.mockk.coEvery
19+
import io.mockk.coVerify
20+
import io.mockk.mockk
21+
import org.junit.jupiter.api.Assertions.assertEquals
22+
import org.junit.jupiter.api.Assertions.assertTrue
23+
import org.junit.jupiter.api.Test
24+
25+
class StreamRoutesYoutubeSessionTest {
26+
private val streamService: StreamService = mockk()
27+
28+
@Test
29+
fun `GET streams uses authenticated YouTube session when bearer is valid`() = testApplication {
30+
coEvery { streamService.getStreamInfo(any()) } returns ExtractionResult.Failure("public path")
31+
application { testRoutes { userId, _ -> if (userId == TEST_USER_ID) ExtractionResult.Success(testStreamResponse()) else null } }
32+
val response = client.get("/streams?url=https://youtube.com/watch?v=test") {
33+
headers.append(HttpHeaders.Authorization, "Bearer test-jwt")
34+
}
35+
assertEquals(HttpStatusCode.OK, response.status)
36+
assertEquals("no-store", response.headers[HttpHeaders.CacheControl])
37+
coVerify(exactly = 1) { streamService.getStreamInfo(any()) }
38+
}
39+
40+
@Test
41+
fun `GET streams merges authenticated HLS with public direct streams`() = testApplication {
42+
coEvery { streamService.getStreamInfo(any()) } returns ExtractionResult.Success(
43+
testStreamResponse(videoOnlyStreams = listOf(testVideoStream()), audioStreams = listOf(testAudioStream()))
44+
)
45+
application {
46+
testRoutes { _, _ ->
47+
ExtractionResult.Success(testStreamResponse(videoOnlyStreams = emptyList(), audioStreams = emptyList()).copy(hlsUrl = "https://manifest.googlevideo.com/hls.m3u8"))
48+
}
49+
}
50+
val response = client.get("/streams?url=https://youtube.com/watch?v=test") {
51+
headers.append(HttpHeaders.Authorization, "Bearer test-jwt")
52+
}
53+
assertEquals(HttpStatusCode.OK, response.status)
54+
assertEquals("no-store", response.headers[HttpHeaders.CacheControl])
55+
val body = response.bodyAsText()
56+
assertTrue(body.contains("\"hlsUrl\":\"https://manifest.googlevideo.com/hls.m3u8\""))
57+
assertTrue(body.contains("\"videoOnlyStreams\":["))
58+
assertTrue(body.contains("\"audioStreams\":["))
59+
}
60+
61+
@Test
62+
fun `GET streams ignores authenticated path without valid bearer`() = testApplication {
63+
coEvery { streamService.getStreamInfo(any()) } returns ExtractionResult.Success(testStreamResponse())
64+
application { testRoutes { _, _ -> ExtractionResult.Failure("unexpected session path") } }
65+
val response = client.get("/streams?url=https://youtube.com/watch?v=test")
66+
assertEquals(HttpStatusCode.OK, response.status)
67+
assertEquals("public, max-age=21600, stale-while-revalidate=3600", response.headers[HttpHeaders.CacheControl])
68+
}
69+
70+
@Test
71+
fun `GET streams returns stable code when YouTube session needs reconnect`() = testApplication {
72+
coEvery { streamService.getStreamInfo(any()) } returns ExtractionResult.Failure("public path")
73+
application { testRoutes { _, _ -> ExtractionResult.BadRequest(YOUTUBE_SESSION_RECONNECT_ERROR) } }
74+
val response = client.get("/streams?url=https://youtube.com/watch?v=test") {
75+
headers.append(HttpHeaders.Authorization, "Bearer test-jwt")
76+
}
77+
assertEquals(HttpStatusCode.BadRequest, response.status)
78+
assertTrue(response.bodyAsText().contains("\"code\":\"youtube_session_needs_reconnect\""))
79+
}
80+
81+
private fun io.ktor.server.application.Application.testRoutes(
82+
block: suspend (String, String) -> ExtractionResult<dev.typetype.server.models.StreamResponse>?,
83+
): Unit {
84+
install(ContentNegotiation) { json() }
85+
routing { streamRoutes(streamService, AuthService.fixed(TEST_USER_ID), block) }
86+
}
87+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package dev.typetype.server
2+
3+
import dev.typetype.server.models.ExtractionResult
4+
import dev.typetype.server.models.StreamResponse
5+
import dev.typetype.server.models.YoutubeSessionCompleteRequest
6+
import dev.typetype.server.services.StreamService
7+
import dev.typetype.server.services.YoutubeSessionCompleteResult
8+
import dev.typetype.server.services.YoutubeSessionCrypto
9+
import dev.typetype.server.services.YoutubeSessionService
10+
import dev.typetype.server.services.YoutubeSessionStreamService
11+
import kotlinx.coroutines.runBlocking
12+
import org.junit.jupiter.api.Assertions.assertEquals
13+
import org.junit.jupiter.api.Assertions.assertNull
14+
import org.junit.jupiter.api.Assertions.assertTrue
15+
import org.junit.jupiter.api.BeforeAll
16+
import org.junit.jupiter.api.BeforeEach
17+
import org.junit.jupiter.api.Test
18+
19+
class YoutubeSessionStreamServiceTest {
20+
private val youtubeSessionService = YoutubeSessionService(
21+
YoutubeSessionCrypto.fromSecret("test-youtube-session-key-32-bytes")
22+
)
23+
24+
companion object {
25+
@BeforeAll
26+
@JvmStatic
27+
fun initDb() { TestDatabase.setup() }
28+
}
29+
30+
@BeforeEach
31+
fun clean() { TestDatabase.truncateAll() }
32+
33+
@Test
34+
fun `non YouTube url does not use authenticated extraction`() = runBlocking {
35+
val service = YoutubeSessionStreamService(failingStreamService(), youtubeSessionService, FakeCacheService())
36+
assertNull(service.getStreamInfo(TEST_USER_ID, "https://example.com/watch?v=test"))
37+
}
38+
39+
@Test
40+
fun `rejected authenticated extraction marks session needs reconnect`() = runBlocking {
41+
connectYoutubeSession()
42+
val service = YoutubeSessionStreamService(
43+
failingStreamService("No suitable stream"),
44+
youtubeSessionService,
45+
FakeCacheService(),
46+
)
47+
val result = service.getStreamInfo(TEST_USER_ID, "https://youtube.com/watch?v=test")
48+
assertTrue(result is ExtractionResult.BadRequest)
49+
assertEquals("needs_reconnect", youtubeSessionService.status(TEST_USER_ID).status)
50+
}
51+
52+
@Test
53+
fun `successful authenticated extraction is cached by session`() = runBlocking {
54+
connectYoutubeSession()
55+
var calls = 0
56+
val stream = object : StreamService {
57+
override suspend fun getStreamInfo(url: String): ExtractionResult<StreamResponse> {
58+
calls += 1
59+
return ExtractionResult.Success(testStreamResponse())
60+
}
61+
}
62+
val service = YoutubeSessionStreamService(stream, youtubeSessionService, FakeCacheService())
63+
assertTrue(service.getStreamInfo(TEST_USER_ID, "https://youtube.com/watch?v=test") is ExtractionResult.Success)
64+
assertTrue(service.getStreamInfo(TEST_USER_ID, "https://youtube.com/watch?v=test") is ExtractionResult.Success)
65+
assertEquals(1, calls)
66+
}
67+
68+
private suspend fun connectYoutubeSession() {
69+
val pairing = youtubeSessionService.createPairing(TEST_USER_ID)
70+
val result = youtubeSessionService.complete(
71+
YoutubeSessionCompleteRequest(
72+
code = pairing.code,
73+
cookies = "SID=secret-cookie",
74+
poToken = "secret-pot-value",
75+
)
76+
)
77+
assertEquals(YoutubeSessionCompleteResult.Completed, result)
78+
}
79+
80+
private fun failingStreamService(message: String = "unexpected call"): StreamService = object : StreamService {
81+
override suspend fun getStreamInfo(url: String): ExtractionResult<StreamResponse> =
82+
ExtractionResult.Failure(message)
83+
}
84+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package dev.typetype.server
2+
3+
import dev.typetype.server.services.YoutubeSessionCredentials
4+
import dev.typetype.server.services.YoutubeSessionTokenScope
5+
import kotlinx.coroutines.runBlocking
6+
import org.junit.jupiter.api.Assertions.assertEquals
7+
import org.junit.jupiter.api.Test
8+
import org.schabi.newpipe.extractor.ServiceList
9+
10+
class YoutubeSessionTokenScopeTest {
11+
@Test
12+
fun `withCredentials injects and clears YouTube tokens`() = runBlocking {
13+
val youtube = ServiceList.YouTube
14+
youtube.setTokens("previous-cookies")
15+
youtube.setAdditionalTokens("previous-pot")
16+
val observed = YoutubeSessionTokenScope.withCredentials(
17+
YoutubeSessionCredentials(
18+
userId = TEST_USER_ID,
19+
fingerprint = "session-fingerprint",
20+
cookies = "SID=session-cookie",
21+
poToken = "session-pot-value",
22+
)
23+
) {
24+
youtube.tokens to youtube.additionalTokens
25+
}
26+
assertEquals("SID=session-cookie", observed.first)
27+
assertEquals("session-pot-value", observed.second)
28+
assertEquals("", youtube.tokens.orEmpty())
29+
assertEquals("", youtube.additionalTokens.orEmpty())
30+
}
31+
32+
@Test
33+
fun `withoutCredentials clears YouTube tokens for public extraction`() = runBlocking {
34+
val youtube = ServiceList.YouTube
35+
youtube.setTokens("previous-cookies")
36+
youtube.setAdditionalTokens("previous-pot")
37+
val observed = YoutubeSessionTokenScope.withoutCredentials {
38+
youtube.tokens.orEmpty() to youtube.additionalTokens.orEmpty()
39+
}
40+
assertEquals("", observed.first)
41+
assertEquals("", observed.second)
42+
}
43+
}

0 commit comments

Comments
 (0)