Add the pinned Conductarr TV client and disk cache
All checks were successful
review/code-claude Claude cross-family review: clean, no blocking findings
All checks were successful
review/code-claude Claude cross-family review: clean, no blocking findings
This commit is contained in:
parent
de452448ba
commit
adbfe5d0e7
87 changed files with 21924 additions and 0 deletions
|
|
@ -0,0 +1,63 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.conductarr
|
||||
|
||||
import java.nio.file.Files
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
/** Lifetime boundaries and storage behavior do not depend on wall-clock sleeps. */
|
||||
class CacheTest {
|
||||
@Test fun `per endpoint lifetime boundaries preserve expired LKG for failure fallback`() {
|
||||
val entry = CacheEntry("body", "tag", 1_000, 1_000, expiresAtMillis = 5_000, group = "group")
|
||||
val rows = listOf(
|
||||
Triple(CachePolicy.DAILY, ReadTrigger.IF_STALE, 86_400_000L),
|
||||
Triple(CachePolicy.DISCOVERY, ReadTrigger.IF_STALE, 1_800_000L),
|
||||
Triple(CachePolicy.RATINGS, ReadTrigger.IF_STALE, 21_600_000L),
|
||||
Triple(CachePolicy.ACTIVITY, ReadTrigger.POLL, 30_000L),
|
||||
)
|
||||
for ((policy, trigger, lifetime) in rows) {
|
||||
assertFalse(policy.name, policy.shouldRefresh(trigger, entry, 1_000 + lifetime - 1))
|
||||
assertTrue(policy.name, policy.shouldRefresh(trigger, entry, 1_000 + lifetime))
|
||||
}
|
||||
assertFalse(CachePolicy.FEED.shouldRefresh(ReadTrigger.IF_STALE, entry, 4_999))
|
||||
assertTrue(CachePolicy.FEED.shouldRefresh(ReadTrigger.IF_STALE, entry, 5_000))
|
||||
assertFalse(CachePolicy.LAUNCH.shouldRefresh(ReadTrigger.IF_STALE, entry, Long.MAX_VALUE))
|
||||
assertTrue(CachePolicy.LAUNCH.shouldRefresh(ReadTrigger.LAUNCH, entry, 1_001))
|
||||
assertTrue(CachePolicy.FOREGROUND.shouldRefresh(ReadTrigger.FOREGROUND, entry, 1_001))
|
||||
assertTrue(CachePolicy.ON_OPEN.shouldRefresh(ReadTrigger.OPEN, entry, 1_001))
|
||||
assertTrue(CachePolicy.SEASON.shouldRefresh(ReadTrigger.OPEN, entry, 1_001))
|
||||
assertFalse(CachePolicy.DAILY.shouldRefresh(ReadTrigger.CACHE_ONLY, null, Long.MAX_VALUE))
|
||||
}
|
||||
|
||||
@Test fun `disk and memory retain newest group entries while isolating other identities and media`() {
|
||||
val directory = Files.createTempDirectory("conductarr-capacity-").toFile()
|
||||
try {
|
||||
for (store in listOf(MemoryCacheStore(), FileCacheStore(directory))) {
|
||||
for (index in 0..300) {
|
||||
store.write("person-a", "movies:$index", CacheEntry("$index", null, index.toLong(), index.toLong(), group = "movies"), 300)
|
||||
}
|
||||
store.write("person-a", "tv:1", CacheEntry("tv", null, 0, 0, group = "tv"), 300)
|
||||
store.write("person-b", "movies:0", CacheEntry("other", null, 0, 0, group = "movies"), 300)
|
||||
assertNull(store.read("person-a", "movies:0"))
|
||||
assertEquals("300", store.read("person-a", "movies:300")!!.body)
|
||||
assertEquals("tv", store.read("person-a", "tv:1")!!.body)
|
||||
store.clear("person-a")
|
||||
assertNull(store.read("person-a", "tv:1"))
|
||||
assertEquals("other", store.read("person-b", "movies:0")!!.body)
|
||||
}
|
||||
} finally { directory.deleteRecursively() }
|
||||
}
|
||||
|
||||
@Test fun `corrupt saved entries become a cache miss rather than blocking recovery`() {
|
||||
val directory = Files.createTempDirectory("conductarr-corrupt-").toFile()
|
||||
try {
|
||||
val store = FileCacheStore(directory)
|
||||
store.write("person", "profiles:1", CacheEntry("valid", null, 0, 0, group = "profiles"))
|
||||
directory.walkTopDown().first { it.extension == "json" }.writeText("truncated{")
|
||||
assertNull(FileCacheStore(directory).read("person", "profiles:1"))
|
||||
val repaired = CacheEntry("new", null, 1, 1, group = "profiles")
|
||||
store.write("person", "profiles:1", repaired)
|
||||
assertEquals(repaired, FileCacheStore(directory).read("person", "profiles:1"))
|
||||
} finally { directory.deleteRecursively() }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.conductarr
|
||||
|
||||
import java.nio.file.Files
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import mockwebserver3.MockResponse
|
||||
import mockwebserver3.MockWebServer
|
||||
|
||||
/** Exercise the production facade with a real local socket and a reopened disk cache. */
|
||||
fun main() = runBlocking {
|
||||
val directory = Files.createTempDirectory("conductarr-smoke-").toFile()
|
||||
try {
|
||||
MockWebServer().use { server ->
|
||||
server.start()
|
||||
val baseUrl = server.url("/").toString()
|
||||
server.enqueue(MockResponse.Builder().body(PROFILES_JSON).addHeader("ETag", "\"smoke\"").build())
|
||||
ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client ->
|
||||
val live = client.read(TvRoutes.profiles()).toList().single() as ReadResult.Value
|
||||
check(live.saved.value!!.profiles!!.single().name == "Justin")
|
||||
}
|
||||
ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client ->
|
||||
val saved = client.read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).first() as ReadResult.Value
|
||||
check(saved.saved.fromDisk)
|
||||
server.enqueue(MockResponse.Builder().code(304).build())
|
||||
val revalidated = client.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList().last() as ReadResult.Value
|
||||
check(revalidated.saved.value == saved.saved.value)
|
||||
server.enqueue(MockResponse.Builder().code(503).body("offline").build())
|
||||
val failure = client.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList().last() as ReadResult.Failure
|
||||
check(failure.error == ClientError.Http(503))
|
||||
check(failure.saved!!.value!!.defaultProfile == "justin")
|
||||
println("SMOKE: live profile Justin -> disk reopen -> ETag 304 -> HTTP 503 retains Justin; requests=${server.requestCount}")
|
||||
}
|
||||
}
|
||||
} finally { directory.deleteRecursively() }
|
||||
}
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.conductarr
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.util.logging.Handler
|
||||
import java.util.logging.LogRecord
|
||||
import java.util.logging.Logger
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import mockwebserver3.MockResponse
|
||||
import mockwebserver3.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.justinvisser.wolfskin.conductarr.model.*
|
||||
|
||||
internal const val TEST_TOKEN = "0123456789abcdef0123456789abcdef"
|
||||
internal const val PROFILES_JSON = """{"profiles":[{"key":"justin","name":"Justin"}],"default_profile":"justin"}"""
|
||||
|
||||
/** Local network tests exercise identity, conditional reads and durable failure recovery. */
|
||||
class ClientTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private val clients = mutableListOf<ConductarrClient>()
|
||||
private lateinit var directory: File
|
||||
private var now = 1_700_000_000_000L
|
||||
|
||||
@Before fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
directory = Files.createTempDirectory("conductarr-test-").toFile()
|
||||
}
|
||||
|
||||
@After fun tearDown() {
|
||||
clients.forEach { it.close() }
|
||||
server.close()
|
||||
directory.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test fun `401 means sign in clears saved identity and never returns stale fallback`() = runBlocking {
|
||||
val cache = FileCacheStore(directory)
|
||||
val client = client(cache)
|
||||
server.enqueue(response(PROFILES_JSON))
|
||||
client.read(TvRoutes.profiles()).toList()
|
||||
server.enqueue(response("unauthorized", 401))
|
||||
val results = client.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList()
|
||||
assertEquals("Justin", (results.first() as ReadResult.Value).saved.value!!.profiles!!.first().name)
|
||||
val failure = results.last() as ReadResult.Failure
|
||||
assertEquals(ClientError.NeedsSignIn, failure.error)
|
||||
assertNull(failure.saved)
|
||||
assertEquals(listOf(ReadResult.Failure<RecommendationProfiles>(ClientError.NeedsSignIn)), client.read(TvRoutes.profiles()).toList())
|
||||
server.enqueue(response("unauthorized", 401))
|
||||
val reopened = client(cache).read(TvRoutes.profiles()).toList()
|
||||
assertEquals(1, reopened.size)
|
||||
assertEquals(ClientError.NeedsSignIn, (reopened.single() as ReadResult.Failure).error)
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
|
||||
@Test fun `ETag 304 reuses body across disk reopen without changing saved time`() = runBlocking {
|
||||
val first = client(FileCacheStore(directory))
|
||||
server.enqueue(MockResponse.Builder().body(PROFILES_JSON).addHeader("ETag", "\"profiles-v1\"").build())
|
||||
val original = (first.read(TvRoutes.profiles()).toList().single() as ReadResult.Value).saved
|
||||
val firstRequest = server.takeRequest()
|
||||
assertEquals("MediaBrowser Token=\"$TEST_TOKEN\"", firstRequest.headers["Authorization"])
|
||||
assertEquals("/api/v1/recommendations/profiles", firstRequest.url.encodedPath)
|
||||
now += 60_000
|
||||
val reopened = client(FileCacheStore(directory))
|
||||
val painted = reopened.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).first() as ReadResult.Value
|
||||
assertEquals(original.value, painted.saved.value)
|
||||
assertTrue(painted.saved.fromDisk)
|
||||
assertEquals(1, server.requestCount)
|
||||
server.enqueue(response("", 304))
|
||||
val results = reopened.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList()
|
||||
assertEquals(2, results.size)
|
||||
val validated = (results.last() as ReadResult.Value).saved
|
||||
assertEquals(original.value, validated.value)
|
||||
assertEquals(original.savedAtMillis, validated.savedAtMillis)
|
||||
assertFalse(validated.fromDisk)
|
||||
assertEquals("\"profiles-v1\"", server.takeRequest().headers["If-None-Match"])
|
||||
}
|
||||
|
||||
@Test fun `HTTP and invalid JSON failures preserve last known good on disk`() = runBlocking {
|
||||
val cache = FileCacheStore(directory)
|
||||
server.enqueue(response(PROFILES_JSON))
|
||||
client(cache).read(TvRoutes.profiles()).toList()
|
||||
for ((body, status, expected) in listOf(
|
||||
Triple("down", 503, ClientError.Http(503)),
|
||||
Triple("{bad json", 200, ClientError.InvalidResponse),
|
||||
)) {
|
||||
server.enqueue(response(body, status))
|
||||
val result = client(FileCacheStore(directory)).read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList().last() as ReadResult.Failure
|
||||
assertEquals(expected, result.error)
|
||||
assertEquals("justin", result.saved!!.value!!.defaultProfile)
|
||||
}
|
||||
val saved = client(FileCacheStore(directory)).read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).first() as ReadResult.Value
|
||||
assertEquals("Justin", saved.saved.value!!.profiles!!.single().name)
|
||||
}
|
||||
|
||||
@Test fun `network failure retains LKG and maps to unreachable`() = runBlocking {
|
||||
val client = client()
|
||||
server.enqueue(response(PROFILES_JSON))
|
||||
client.read(TvRoutes.profiles()).toList()
|
||||
server.close()
|
||||
val failure = client.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList().last() as ReadResult.Failure
|
||||
assertEquals(ClientError.Unreachable, failure.error)
|
||||
assertEquals("justin", failure.saved!!.value!!.defaultProfile)
|
||||
}
|
||||
|
||||
@Test fun `identity partitions saved data by token and base URL`() = runBlocking {
|
||||
val cache = FileCacheStore(directory)
|
||||
server.enqueue(response(PROFILES_JSON))
|
||||
client(cache).read(TvRoutes.profiles()).toList()
|
||||
val switched = client(cache, "fedcba9876543210fedcba9876543210")
|
||||
assertTrue(switched.read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).toList().isEmpty())
|
||||
val otherServer = ConductarrClient(server.url("/other/").toString(), TEST_TOKEN, cache).also { clients.add(it) }
|
||||
assertTrue(otherServer.read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).toList().isEmpty())
|
||||
assertFalse(directory.walkTopDown().filter { it.isFile }.any { it.readText().contains(TEST_TOKEN) || it.path.contains(TEST_TOKEN) })
|
||||
}
|
||||
|
||||
@Test fun `ratings 404 is cached as none for six hours but server failure is not cached`() = runBlocking {
|
||||
val client = client()
|
||||
val call = TvRoutes.enrichment(42, MediaType.MOVIE)
|
||||
server.enqueue(response("missing", 404))
|
||||
assertTrue((client.read(call).toList().single() as ReadResult.Value).saved.absent)
|
||||
now += 6 * 60 * 60 * 1000L - 1
|
||||
assertTrue((client.read(call).toList().single() as ReadResult.Value).saved.absent)
|
||||
assertEquals(1, server.requestCount)
|
||||
now++
|
||||
server.enqueue(response("down", 503))
|
||||
assertEquals(ClientError.Http(503), (client.read(call).toList().last() as ReadResult.Failure).error)
|
||||
server.enqueue(response("missing", 404))
|
||||
client.read(call).toList()
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
|
||||
@Test fun `mutation is sent once without caller controlled actor or automatic replay`() = runBlocking {
|
||||
val client = client()
|
||||
val body = RequestIntentBody(mediaType = RequestIntentBody.MediaType.MOVIE, tmdbId = 42, requester = "mindel")
|
||||
server.enqueue(response("uncertain", 503))
|
||||
assertEquals(MutationResult.Failure(ClientError.Http(503)), client.mutate(TvRoutes.request(body)))
|
||||
assertEquals(1, server.requestCount)
|
||||
val request = server.takeRequest()
|
||||
assertEquals("POST", request.method)
|
||||
val sent = WireJson.parseToJsonElement(request.body!!.utf8()).toString()
|
||||
assertTrue(sent.contains("\"requester\":\"mindel\""))
|
||||
assertFalse(sent.contains("actor"))
|
||||
assertFalse(sent.contains("seasons"))
|
||||
assertTrue(client.read(TvRoutes.requests(42, MediaType.MOVIE), ReadTrigger.CACHE_ONLY).toList().isEmpty())
|
||||
}
|
||||
|
||||
@Test fun `diagnostics and errors never log token or hostile response body`() = runBlocking {
|
||||
val events = mutableListOf<ClientEvent>()
|
||||
val logged = StringBuilder()
|
||||
val handler = object : Handler() {
|
||||
override fun publish(record: LogRecord) { logged.append(record.message).append(record.thrown?.toString().orEmpty()) }
|
||||
override fun flush() = Unit
|
||||
override fun close() = Unit
|
||||
}
|
||||
val logger = Logger.getLogger("")
|
||||
logger.addHandler(handler)
|
||||
try {
|
||||
val client = ConductarrClient(server.url("/").toString(), TEST_TOKEN, MemoryCacheStore(), onEvent = { events.add(it) }).also { clients.add(it) }
|
||||
server.enqueue(response("hostile response $TEST_TOKEN", 500))
|
||||
val error = client.read(TvRoutes.profiles()).toList().single() as ReadResult.Failure
|
||||
assertEquals(ClientError.Http(500), error.error)
|
||||
server.enqueue(response("$TEST_TOKEN", 401))
|
||||
val unauthorized = client.read(TvRoutes.profiles()).toList().single() as ReadResult.Failure
|
||||
assertEquals(ClientError.NeedsSignIn, unauthorized.error)
|
||||
assertEquals(listOf(ClientEvent.HTTP_FAILURE, ClientEvent.NEEDS_SIGN_IN), events)
|
||||
assertFalse((logged.toString() + events + error + unauthorized).contains(TEST_TOKEN))
|
||||
} finally { logger.removeHandler(handler) }
|
||||
}
|
||||
|
||||
@Test fun `feed honours server expiry and encodes profile as one path segment`() = runBlocking {
|
||||
val client = client()
|
||||
val expiry = java.time.Instant.ofEpochMilli(now + 10_000).toString()
|
||||
val feed = """{"profile":{"key":"justin","name":"Justin"},"projection":{"coverage":{"complete":true,"excluded":0,"matched":0,"total":0,"unresolved":0},"facts":null,"genre_id":0,"media_type":"all","service_id":0,"unresolved":null},"state":"current","outcome":"empty","materialization":"current","movies":null,"shows":null,"expires_at":"$expiry"}"""
|
||||
server.enqueue(MockResponse.Builder().body(feed).addHeader("ETag", "\"feed-v1\"").build())
|
||||
val call = TvRoutes.feed("justin/a b")
|
||||
client.read(call).toList()
|
||||
now += 9_999
|
||||
assertEquals(RecommendationFeed.Outcome.EMPTY, (client.read(call).toList().single() as ReadResult.Value).saved.value!!.outcome)
|
||||
assertEquals(1, server.requestCount)
|
||||
val request = server.takeRequest()
|
||||
assertEquals("/api/v1/recommendations/profiles/justin%2Fa%20b/feed", request.url.encodedPath)
|
||||
now++
|
||||
server.enqueue(response("", 304))
|
||||
val result = client.read(call).toList().last() as ReadResult.Value
|
||||
assertEquals(RecommendationFeed.Outcome.EMPTY, result.saved.value!!.outcome)
|
||||
assertEquals("\"feed-v1\"", server.takeRequest().headers["If-None-Match"])
|
||||
}
|
||||
|
||||
@Test fun `successful request returns typed receipt and title requests never use cache`() = runBlocking {
|
||||
val receipt = """{"actor":{"name":"Justin","requester":"justin","role":"household"},"attempts":1,"coverage":null,"created_at":"2026-11-05T12:00:00Z","id":7,"media_type":"movie","membership":{"status":"added"},"on_behalf":true,"requester":"mindel","requester_username":"Mindel","seasons":null,"seerr":{"outcome":"requested","request_id":9},"source":"title","state":"settled","tmdb_id":42,"updated_at":"2026-11-05T12:00:00Z"}"""
|
||||
val client = client()
|
||||
server.enqueue(response(receipt))
|
||||
val result = client.mutate(TvRoutes.request(RequestIntentBody(mediaType = RequestIntentBody.MediaType.MOVIE, tmdbId = 42, requester = "mindel"))) as MutationResult.Success
|
||||
assertEquals(7L, result.value.id)
|
||||
assertEquals("justin", result.value.actor.requester)
|
||||
assertTrue(result.value.onBehalf)
|
||||
server.enqueue(response("""{"requests":[$receipt]}"""))
|
||||
val receipts = client.read(TvRoutes.requests(42, MediaType.MOVIE)).toList().single() as ReadResult.Value
|
||||
assertEquals(7L, receipts.saved.value!!.requests!!.single().id)
|
||||
assertTrue(client.read(TvRoutes.requests(42, MediaType.MOVIE), ReadTrigger.CACHE_ONLY).toList().isEmpty())
|
||||
}
|
||||
|
||||
private fun client(cache: CacheStore = MemoryCacheStore(), token: String = TEST_TOKEN): ConductarrClient =
|
||||
ConductarrClient(server.url("/").toString(), token, cache, nowMillis = { now }).also { clients.add(it) }
|
||||
|
||||
private fun response(body: String, status: Int = 200): MockResponse = MockResponse.Builder().code(status).body(body).build()
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
@file:OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
|
||||
package org.justinvisser.wolfskin.conductarr
|
||||
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialKind
|
||||
import kotlinx.serialization.descriptors.StructureKind
|
||||
import kotlinx.serialization.json.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.justinvisser.wolfskin.conductarr.model.ErrorDetail
|
||||
|
||||
/** Schema structure, constraints and route coverage are independent of runtime transport tests. */
|
||||
class ContractTest {
|
||||
private val document = resource("openapi.json")
|
||||
private val schemas = document["components"]!!.jsonObject["schemas"]!!.jsonObject
|
||||
private val serializers = modelSerializers.associateBy { it.descriptor.serialName.substringAfterLast('.') }
|
||||
|
||||
@Test fun `every maintained model matches pinned schema fields types requiredness and enums`() {
|
||||
for ((name, serializer) in serializers) {
|
||||
compare(serializer.descriptor, schemas.getValue(name).jsonObject, name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `all reviewed schema constraints fail on drift including formats limits and defaults`() {
|
||||
val reviewed = resource("model-contracts.json")
|
||||
assertEquals(serializers.keys, reviewed.keys)
|
||||
for (name in reviewed.keys) assertEquals("Schema constraint drift: $name", reviewed[name], schemas[name])
|
||||
}
|
||||
|
||||
@Test fun `all twenty TV operations exist with their typed request and response contracts`() {
|
||||
val expected = setOf(
|
||||
"GET /api/v1/operator", "GET /api/v1/recommendations/profiles",
|
||||
"GET /api/v1/recommendations/profiles/{profile}/feed",
|
||||
"GET /api/v1/recommendations/profiles/{profile}/dismissals",
|
||||
"POST /api/v1/recommendations/profiles/{profile}/dismissals",
|
||||
"DELETE /api/v1/recommendations/profiles/{profile}/dismissals/{type}/{tmdb}",
|
||||
"GET /catalogue/options", "GET /catalogue/discover",
|
||||
"GET /catalogue/titles/{tmdb}/{type}", "GET /catalogue/titles/{tmdb}/{type}/cast",
|
||||
"GET /catalogue/titles/{tmdb}/{type}/related", "GET /catalogue/titles/{tmdb}/{type}/enrichment",
|
||||
"GET /catalogue/tv/{tmdb}/seasons/{season}", "GET /catalogue/performers/{performer}",
|
||||
"GET /catalogue/performers/{performer}/filmography",
|
||||
"GET /api/v1/activity", "GET /api/v1/activity/titles/{tmdb}/{type}",
|
||||
"GET /api/v1/requests/titles/{tmdb}/{type}", "POST /api/v1/requests",
|
||||
"GET /curation/library-audiences",
|
||||
)
|
||||
assertEquals(expected, TvRoutes.contracts.map { "${it.method} ${it.path}" }.toSet())
|
||||
assertEquals(expected.size, TvRoutes.contracts.size)
|
||||
val paths = document["paths"]!!.jsonObject
|
||||
for (route in TvRoutes.contracts) {
|
||||
val operation = paths.getValue(route.path).jsonObject.getValue(route.method.lowercase()).jsonObject
|
||||
val response = operation["responses"]!!.jsonObject["200"]!!.jsonObject
|
||||
assertEquals(route.responseSchema, schemaReference(response))
|
||||
assertTrue("Missing response model ${route.responseSchema}", serializers.containsKey(route.responseSchema))
|
||||
if (route.requestSchema != null) {
|
||||
assertEquals(route.requestSchema, schemaReference(operation["requestBody"]!!.jsonObject))
|
||||
assertTrue(serializers.containsKey(route.requestSchema))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `unconstrained error values preserve objects arrays scalars and null`() {
|
||||
for (value in listOf("{\"nested\":[1,null,true]}", "[1,\"x\"]", "42", "true", "null")) {
|
||||
val detail = WireJson.decodeFromString<ErrorDetail>("{\"value\":$value}")
|
||||
assertEquals(Json.parseToJsonElement(value), detail.value ?: JsonNull)
|
||||
}
|
||||
}
|
||||
|
||||
private fun compare(descriptor: SerialDescriptor, schema: JsonObject, location: String) {
|
||||
schema["\$ref"]?.let {
|
||||
val name = it.jsonPrimitive.content.substringAfterLast('/')
|
||||
assertEquals(location, name, descriptor.serialName.substringAfterLast('.').removeSuffix("?"))
|
||||
assertTrue("Unregistered referenced model $name", serializers.containsKey(name))
|
||||
return
|
||||
}
|
||||
if (schema["type"] == null) {
|
||||
assertTrue(location, descriptor.serialName.startsWith("kotlinx.serialization.json.JsonElement"))
|
||||
return
|
||||
}
|
||||
schema["enum"]?.let { values ->
|
||||
assertEquals(location, SerialKind.ENUM, descriptor.kind)
|
||||
assertEquals(location, values.jsonArray.map { it.jsonPrimitive.content }.toSet(),
|
||||
(0 until descriptor.elementsCount).map { descriptor.getElementName(it) }.toSet())
|
||||
return
|
||||
}
|
||||
val types = when (val type = schema["type"]!!) {
|
||||
is JsonArray -> type.map { it.jsonPrimitive.content }
|
||||
else -> listOf(type.jsonPrimitive.content)
|
||||
}
|
||||
when (types.first { it != "null" }) {
|
||||
"string" -> assertEquals(location, PrimitiveKind.STRING, descriptor.kind)
|
||||
"integer" -> assertEquals(location, PrimitiveKind.LONG, descriptor.kind)
|
||||
"number" -> assertEquals(location, PrimitiveKind.DOUBLE, descriptor.kind)
|
||||
"boolean" -> assertEquals(location, PrimitiveKind.BOOLEAN, descriptor.kind)
|
||||
"array" -> {
|
||||
assertEquals(location, StructureKind.LIST, descriptor.kind)
|
||||
compare(descriptor.getElementDescriptor(0), schema["items"]!!.jsonObject, "$location[]")
|
||||
}
|
||||
"object" -> if (schema["additionalProperties"] is JsonObject) {
|
||||
assertEquals(location, StructureKind.MAP, descriptor.kind)
|
||||
assertEquals(location, PrimitiveKind.STRING, descriptor.getElementDescriptor(0).kind)
|
||||
compare(descriptor.getElementDescriptor(1), schema["additionalProperties"]!!.jsonObject, "$location{}")
|
||||
} else compareFields(descriptor, schema, location)
|
||||
else -> error("Unhandled schema type at $location")
|
||||
}
|
||||
}
|
||||
|
||||
private fun compareFields(descriptor: SerialDescriptor, schema: JsonObject, location: String) {
|
||||
assertEquals(location, StructureKind.CLASS, descriptor.kind)
|
||||
val properties = schema["properties"]!!.jsonObject.filterValues { it.jsonObject["readOnly"] != JsonPrimitive(true) }
|
||||
val required = schema["required"]?.jsonArray?.map { it.jsonPrimitive.content }?.toSet().orEmpty()
|
||||
assertEquals(location, properties.keys, (0 until descriptor.elementsCount).map { descriptor.getElementName(it) }.toSet())
|
||||
for ((name, property) in properties) {
|
||||
val index = descriptor.getElementIndex(name)
|
||||
val field = descriptor.getElementDescriptor(index)
|
||||
assertEquals("Requiredness drift: $location.$name", name !in required, descriptor.isElementOptional(index))
|
||||
val type = property.jsonObject["type"]
|
||||
if (type is JsonArray) assertTrue("Nullable collection drift: $location.$name", field.isNullable)
|
||||
else if (name in required) assertFalse("Nullability drift: $location.$name", field.isNullable)
|
||||
compare(field, property.jsonObject, "$location.$name")
|
||||
}
|
||||
}
|
||||
|
||||
private fun schemaReference(container: JsonObject): String = container.getValue("content").jsonObject
|
||||
.getValue("application/json").jsonObject.getValue("schema").jsonObject.getValue("\$ref").jsonPrimitive.content.substringAfterLast('/')
|
||||
|
||||
private fun resource(name: String): JsonObject = javaClass.classLoader.getResourceAsStream(name)!!.use {
|
||||
Json.parseToJsonElement(it.bufferedReader().readText()).jsonObject
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.conductarr
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import org.justinvisser.wolfskin.conductarr.model.*
|
||||
|
||||
/** Independently enumerated fallback models checked against the vendored schemas. */
|
||||
internal val modelSerializers: List<KSerializer<*>> = listOf(
|
||||
ActingCredit.serializer(),
|
||||
ActivityFeed.serializer(),
|
||||
Actor.serializer(),
|
||||
Candidate.serializer(),
|
||||
CastMember.serializer(),
|
||||
Detail.serializer(),
|
||||
Discovery.serializer(),
|
||||
Enrichment.serializer(),
|
||||
Episode.serializer(),
|
||||
EpisodeFileChip.serializer(),
|
||||
EpisodeRow.serializer(),
|
||||
EpisodeView.serializer(),
|
||||
ErrorDetail.serializer(),
|
||||
ErrorModel.serializer(),
|
||||
EvidenceSource.serializer(),
|
||||
FileDetail.serializer(),
|
||||
FilmographyPage.serializer(),
|
||||
FilmographyTitle.serializer(),
|
||||
Genre.serializer(),
|
||||
HealthDetail.serializer(),
|
||||
HeldTitle.serializer(),
|
||||
ItemDetail.serializer(),
|
||||
LibraryAudience.serializer(),
|
||||
LibraryAudiencesBody.serializer(),
|
||||
LibraryObservation.serializer(),
|
||||
OpenLink.serializer(),
|
||||
Operator.serializer(),
|
||||
OperatorCapabilities.serializer(),
|
||||
Option.serializer(),
|
||||
Options.serializer(),
|
||||
Page.serializer(),
|
||||
PerformerDetail.serializer(),
|
||||
Person.serializer(),
|
||||
Provider.serializer(),
|
||||
Rating.serializer(),
|
||||
ReadProfile.serializer(),
|
||||
ReadinessStage.serializer(),
|
||||
RecommendationCoverage.serializer(),
|
||||
RecommendationDismissal.serializer(),
|
||||
RecommendationDismissals.serializer(),
|
||||
RecommendationFact.serializer(),
|
||||
RecommendationFeed.serializer(),
|
||||
RecommendationHideInputBody.serializer(),
|
||||
RecommendationHideResult.serializer(),
|
||||
RecommendationProfiles.serializer(),
|
||||
RecommendationProjection.serializer(),
|
||||
RecommendationRestoreResult.serializer(),
|
||||
RequestIntentBody.serializer(),
|
||||
RequestProgress.serializer(),
|
||||
RequestReceipt.serializer(),
|
||||
RequestReceiptActor.serializer(),
|
||||
RequestReceiptCoverage.serializer(),
|
||||
RequestReceiptMembership.serializer(),
|
||||
RequestReceiptSeerr.serializer(),
|
||||
Season.serializer(),
|
||||
SeasonCoverage.serializer(),
|
||||
SeasonDetail.serializer(),
|
||||
SeasonGroup.serializer(),
|
||||
SeedContribution.serializer(),
|
||||
SeerrObservation.serializer(),
|
||||
SeerrRequest.serializer(),
|
||||
SourceDetail.serializer(),
|
||||
Title.serializer(),
|
||||
TitleCast.serializer(),
|
||||
TitleMetadata.serializer(),
|
||||
TitleRequestsBody.serializer(),
|
||||
TitleVersion.serializer(),
|
||||
ViewerReadiness.serializer(),
|
||||
)
|
||||
3173
conductarr-client/src/test/resources/model-contracts.json
Normal file
3173
conductarr-client/src/test/resources/model-contracts.json
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue