diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/XQTJson.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/XQTJson.kt new file mode 100644 index 000000000..b5a64bff3 --- /dev/null +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/XQTJson.kt @@ -0,0 +1,25 @@ +package dev.dimension.flare.data.network.xqt + +import dev.dimension.flare.common.JSON_WITH_ENCODE_DEFAULT +import dev.dimension.flare.data.network.xqt.model.InstructionUnion +import dev.dimension.flare.data.network.xqt.model.UnknownInstruction +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic + +internal val XQT_JSON = + Json(JSON_WITH_ENCODE_DEFAULT) { + serializersModule = + SerializersModule { + include(JSON_WITH_ENCODE_DEFAULT.serializersModule) + polymorphic(InstructionUnion::class) { + defaultDeserializer { type -> + if (type == null) { + null + } else { + UnknownInstruction.serializer() + } + } + } + } + } diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/XQTService.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/XQTService.kt index fafcf2e5d..49a688468 100644 --- a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/XQTService.kt +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/XQTService.kt @@ -1,6 +1,5 @@ package dev.dimension.flare.data.network.xqt -import dev.dimension.flare.common.JSON_WITH_ENCODE_DEFAULT import dev.dimension.flare.common.Locale import dev.dimension.flare.data.network.ktorClient import dev.dimension.flare.data.network.ktorfit @@ -71,7 +70,7 @@ private fun config( url: String = baseUrl, accountKey: MicroBlogKey? = null, chocolateFlow: Flow? = null, -) = ktorfit(url, json = JSON_WITH_ENCODE_DEFAULT) { +) = ktorfit(url, json = XQT_JSON) { expectSuccess = false install(XQTHeaderPlugin) { this.chocolateFlow = chocolateFlow diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/model/UnknownInstruction.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/model/UnknownInstruction.kt new file mode 100644 index 000000000..c62be1f5f --- /dev/null +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/model/UnknownInstruction.kt @@ -0,0 +1,7 @@ +package dev.dimension.flare.data.network.xqt.model + +import kotlinx.serialization.Serializable + +// Unrecognized server instructions must not prevent known timeline entries from loading. +@Serializable +internal data object UnknownInstruction : InstructionUnion diff --git a/social/xqt/src/commonTest/kotlin/dev/dimension/flare/data/network/xqt/XQTJsonTest.kt b/social/xqt/src/commonTest/kotlin/dev/dimension/flare/data/network/xqt/XQTJsonTest.kt new file mode 100644 index 000000000..dbe21c929 --- /dev/null +++ b/social/xqt/src/commonTest/kotlin/dev/dimension/flare/data/network/xqt/XQTJsonTest.kt @@ -0,0 +1,126 @@ +package dev.dimension.flare.data.network.xqt + +import dev.dimension.flare.data.network.xqt.model.CursorType +import dev.dimension.flare.data.network.xqt.model.NotificationsTimelineResponse +import dev.dimension.flare.data.network.xqt.model.TimelineAddEntries +import dev.dimension.flare.data.network.xqt.model.TimelineNotification +import dev.dimension.flare.data.network.xqt.model.TimelineTerminateTimeline +import dev.dimension.flare.data.network.xqt.model.TimelineTimelineCursor +import dev.dimension.flare.data.network.xqt.model.TimelineTimelineItem +import kotlinx.serialization.SerializationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull + +class XQTJsonTest { + @Test + fun unknownInstructionsPreserveNotificationsAndCursors() { + val response = + decodeNotifications( + """{"type":"TimelineFutureInstruction","payload":{"items":[1,{"nested":true}]}}""", + ADDED_NOTIFICATION_AND_CURSOR, + """{"entry_ids":["notification-removed"],"type":"TimelineRemoveEntries"}""", + """{"type":"TimelineTerminateTimeline","direction":"Bottom"}""", + """{"type":"AnotherFutureInstruction","payload":null}""", + ) + val instructions = assertNotNull(response.data.viewerV2.userResults.result.notificationTimeline.timeline).instructions + val entries = instructions.filterIsInstance().single().propertyEntries + + assertEquals(listOf("notification-kept", "cursor-bottom"), entries.map { it.entryId }) + val item = assertIs(entries[0].content) + val notification = assertIs(item.itemContent) + assertEquals("notification-kept", notification.id) + assertEquals("Someone liked your post", notification.richMessage.text) + val cursor = assertIs(entries[1].content) + assertEquals(CursorType.bottom, cursor.cursorType) + assertEquals("next-page", cursor.value) + assertEquals( + TimelineTerminateTimeline.Direction.bottom, + instructions.filterIsInstance().single().direction, + ) + } + + @Test + fun responseContainingOnlyUnknownInstructionsStillDecodes() { + val response = + decodeNotifications( + """{"type":"TimelineRemoveEntries","entry_ids":["notification-removed"]}""", + """{"type":"TimelineFutureInstruction","unrecognized_field":{}}""", + ) + val instructions = assertNotNull(response.data.viewerV2.userResults.result.notificationTimeline.timeline).instructions + + assertEquals(emptyList(), instructions.filterIsInstance()) + } + + @Test + fun malformedKnownInstructionsStillFail() { + assertFailsWith { + decodeNotifications("""{"type":"TimelineAddEntries"}""") + } + } + + @Test + fun instructionsWithoutATypeStillFail() { + assertFailsWith { + decodeNotifications("""{"entries":[]}""") + } + } + + private fun decodeNotifications(vararg instructions: String): NotificationsTimelineResponse = + XQT_JSON.decodeFromString( + """ + { + "data": { + "viewer_v2": { + "user_results": { + "result": { + "__typename": "User", + "rest_id": "me", + "notification_timeline": { + "timeline": {"instructions": [${instructions.joinToString()}]} + } + } + } + } + } + } + """.trimIndent(), + ) +} + +private val ADDED_NOTIFICATION_AND_CURSOR = + """ + { + "type": "TimelineAddEntries", + "entries": [ + { + "entryId": "notification-kept", + "sortIndex": "2", + "content": { + "entryType": "TimelineTimelineItem", + "itemContent": { + "__typename": "TimelineNotification", + "id": "notification-kept", + "itemType": "TimelineNotification", + "notification_icon": "heart_icon", + "notification_url": {"url": "/notifications"}, + "rich_message": {"text": "Someone liked your post"}, + "template": {}, + "timestamp_ms": "1788156000000" + } + } + }, + { + "entryId": "cursor-bottom", + "sortIndex": "1", + "content": { + "entryType": "TimelineTimelineCursor", + "cursorType": "Bottom", + "value": "next-page" + } + } + ] + } + """.trimIndent()