From d1651a756f8db73909b0aa489040d63f32943619 Mon Sep 17 00:00:00 2001 From: Luca Kellermann Date: Sat, 16 Mar 2024 02:55:32 +0100 Subject: [PATCH] Rewrite Event.DeserializationStrategy (#923) When deserializing Events, Event.DeserializationStrategy assumed that the d field [1] was the last field to be observed. If it wasn't the last field, the deserialization could fail in two ways: * the t and s fields were ignored if they came after the d field * an exception was thrown if the op field came after the d field To fix these possible failure cases, the deserialization logic has been changed to work in two steps: 1. decode all fields regardless of order, treating the d field as a plain JsonElement 2. after all fields have been decoded, construct an Event from the JsonElement, depending on the values of the op, t and s fields The supertype of Event.DeserializationStrategy has also been changed from DeserializationStrategy to DeserializationStrategy - deserialize no longer returns null in some cases of illegal event payloads, but throws exceptions instead. Fixes #922 [1] https://discord.com/developers/docs/topics/gateway-events#payload-structure Co-authored-by: Michael Rittmeister --- .../src/commonMain/kotlin/DefaultGateway.kt | 2 +- gateway/src/commonMain/kotlin/Event.kt | 360 +++----- .../json/DispatchEventDeserializationTest.kt | 799 ++++++++++++++++++ .../kotlin/json/JsonPermutations.kt | 18 + .../kotlin/json/ResumedDeserializationTest.kt | 59 ++ .../kotlin/json/SerializationTest.kt | 64 ++ ...UnknownDispatchEventDeserializationTest.kt | 128 +++ 7 files changed, 1202 insertions(+), 228 deletions(-) create mode 100644 gateway/src/commonTest/kotlin/json/DispatchEventDeserializationTest.kt create mode 100644 gateway/src/commonTest/kotlin/json/JsonPermutations.kt create mode 100644 gateway/src/commonTest/kotlin/json/ResumedDeserializationTest.kt create mode 100644 gateway/src/commonTest/kotlin/json/UnknownDispatchEventDeserializationTest.kt diff --git a/gateway/src/commonMain/kotlin/DefaultGateway.kt b/gateway/src/commonMain/kotlin/DefaultGateway.kt index 635fd38b9057..8a5f673ffc0a 100644 --- a/gateway/src/commonMain/kotlin/DefaultGateway.kt +++ b/gateway/src/commonMain/kotlin/DefaultGateway.kt @@ -186,7 +186,7 @@ public class DefaultGateway(private val data: DefaultGatewayData) : Gateway { try { defaultGatewayLogger.trace { "Gateway <<< $json" } - val event = jsonParser.decodeFromString(Event.DeserializationStrategy, json) ?: return + val event = jsonParser.decodeFromString(Event.DeserializationStrategy, json) data.eventFlow.emit(event) } catch (exception: Exception) { defaultGatewayLogger.error(exception) { "" } diff --git a/gateway/src/commonMain/kotlin/Event.kt b/gateway/src/commonMain/kotlin/Event.kt index 6ff21779fc09..1cc9f4b68659 100644 --- a/gateway/src/commonMain/kotlin/Event.kt +++ b/gateway/src/commonMain/kotlin/Event.kt @@ -6,22 +6,15 @@ import dev.kord.common.entity.optional.OptionalSnowflake import dev.kord.common.serialization.DurationInSeconds import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.datetime.Instant -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.NothingSerializer -import kotlinx.serialization.builtins.nullable +import kotlinx.serialization.* import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.CompositeDecoder import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject import kotlin.jvm.JvmField import kotlinx.serialization.DeserializationStrategy as KDeserializationStrategy @@ -32,458 +25,374 @@ public sealed class DispatchEvent : Event() { } public sealed class Event { - public object DeserializationStrategy : KDeserializationStrategy { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Event") { + public object DeserializationStrategy : KDeserializationStrategy { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("dev.kord.gateway.Event") { element("op", OpCode.serializer().descriptor) - element("t", String.serializer().descriptor, isOptional = true) - element("s", Int.serializer().descriptor, isOptional = true) - element("d", JsonObject.serializer().descriptor, isOptional = true) + element("t", String.serializer().descriptor.nullable, isOptional = true) + element("s", Int.serializer().descriptor.nullable, isOptional = true) + element("d", JsonElement.serializer().descriptor, isOptional = true) } - @OptIn(ExperimentalSerializationApi::class) - override fun deserialize(decoder: Decoder): Event? { + override fun deserialize(decoder: Decoder): Event = decoder.decodeStructure(descriptor) { var op: OpCode? = null - var data: Event? = null - var sequence: Int? = null - var eventName: String? = null - - with(decoder.beginStructure(descriptor)) { - loop@ while (true) { - when (val index = - decodeElementIndex(descriptor)) {//we assume the all fields to be present *before* the data field - CompositeDecoder.DECODE_DONE -> break@loop - 0 -> { - op = decodeSerializableElement(descriptor, index, OpCode.serializer()) - when (op) { - OpCode.HeartbeatACK -> data = HeartbeatACK - OpCode.Reconnect -> data = Reconnect - else -> {} - } - } - 1 -> eventName = - decodeNullableSerializableElement(descriptor, index, String.serializer().nullable) - 2 -> sequence = decodeNullableSerializableElement(descriptor, index, Int.serializer().nullable) - 3 -> data = when (op) { - OpCode.Dispatch -> getByDispatchEvent(index, this, eventName, sequence) - OpCode.Heartbeat -> decodeSerializableElement(descriptor, index, Heartbeat.serializer()) - OpCode.HeartbeatACK -> { - @Suppress("IMPLICIT_NOTHING_TYPE_ARGUMENT_IN_RETURN_POSITION") - decodeNullableSerializableElement(descriptor, index, NothingSerializer()) - HeartbeatACK - } - OpCode.InvalidSession -> decodeSerializableElement( - descriptor, - index, - InvalidSession.serializer() - ) - OpCode.Hello -> decodeSerializableElement(descriptor, index, Hello.serializer()) - //some events contain undocumented data fields, we'll only assume an unknown opcode with no data to be an error - else -> if (data == null) { - val element = decodeNullableSerializableElement( - descriptor, - index, - JsonElement.serializer().nullable - ) - error("Unknown 'd' field for Op code ${op?.code}: $element") - } else { - decodeNullableSerializableElement(descriptor, index, JsonElement.serializer().nullable) - data - } - } - } + var t: String? = null + var s: Int? = null + var d: JsonElement? = null + while (true) { + @OptIn(ExperimentalSerializationApi::class) + when (val index = decodeElementIndex(descriptor)) { + 0 -> op = decodeSerializableElement(descriptor, index, OpCode.serializer(), op) + 1 -> t = decodeNullableSerializableElement(descriptor, index, String.serializer(), t) + 2 -> s = decodeNullableSerializableElement(descriptor, index, Int.serializer(), s) + 3 -> d = decodeSerializableElement(descriptor, index, JsonElement.serializer(), d) + CompositeDecoder.DECODE_DONE -> break + else -> throw SerializationException("Unexpected index: $index") } - endStructure(descriptor) - if (op == OpCode.Dispatch && eventName == "RESUMED") return Resumed(sequence) - return data + } + when (op) { + null -> + throw @OptIn(ExperimentalSerializationApi::class) MissingFieldException("op", descriptor.serialName) + OpCode.Dispatch -> decodeDispatchEvent(decoder, eventName = t, sequence = s, eventData = d) + OpCode.Heartbeat -> decodeNonDispatchEvent(decoder, op, Heartbeat.serializer(), eventData = d) + OpCode.Reconnect -> { + // ignore the d field, Reconnect is supposed to have null here: + // https://discord.com/developers/docs/topics/gateway-events#reconnect + Reconnect + } + OpCode.InvalidSession -> decodeNonDispatchEvent(decoder, op, InvalidSession.serializer(), eventData = d) + OpCode.Hello -> decodeNonDispatchEvent(decoder, op, Hello.serializer(), eventData = d) + OpCode.HeartbeatACK -> { + // ignore the d field, Heartbeat ACK is supposed to omit it: + // https://discord.com/developers/docs/topics/gateway#heartbeat-interval-example-heartbeat-ack + HeartbeatACK + } + // OpCodes for Commands (aka send events), they shouldn't be received + OpCode.Identify, OpCode.StatusUpdate, OpCode.VoiceStateUpdate, OpCode.Resume, + OpCode.RequestGuildMembers, + -> throw IllegalArgumentException("Illegal opcode for gateway event: $op") + OpCode.Unknown -> throw IllegalArgumentException("Unknown opcode for gateway event") } } + private fun decodeNonDispatchEvent( + decoder: Decoder, + op: OpCode, + deserializer: KDeserializationStrategy, + eventData: JsonElement?, + ): T { + requireNotNull(eventData) { "Gateway event is missing 'd' field for opcode $op" } + // this cast will always succeed, otherwise decoder couldn't have decoded eventData + return (decoder as JsonDecoder).json.decodeFromJsonElement(deserializer, eventData) + } - @OptIn(ExperimentalSerializationApi::class) - private fun getByDispatchEvent(index: Int, decoder: CompositeDecoder, name: String?, sequence: Int?) = - when (name) { - "PRESENCES_REPLACE" -> { - decoder.decodeNullableSerializableElement(descriptor, index, JsonElement.serializer().nullable) - null //https://github.com/kordlib/kord/issues/42 - } + private fun decodeDispatchEvent( + decoder: Decoder, + eventName: String?, + sequence: Int?, + eventData: JsonElement?, + ): DispatchEvent { + fun decode(deserializer: KDeserializationStrategy): T { + requireNotNull(eventData) { "Gateway event is missing 'd' field for event name $eventName" } + // this cast will always succeed, otherwise decoder couldn't have decoded eventData + return (decoder as JsonDecoder).json.decodeFromJsonElement(deserializer, eventData) + } + + return when (eventName) { "RESUMED" -> { - decoder.decodeNullableSerializableElement(descriptor, index, JsonElement.serializer().nullable) + // ignore the d field, the content isn't documented: + // https://discord.com/developers/docs/topics/gateway-events#resumed Resumed(sequence) } - "READY" -> Ready(decoder.decodeSerializableElement(descriptor, index, ReadyData.serializer()), sequence) + "READY" -> Ready(decode(ReadyData.serializer()), sequence) "APPLICATION_COMMAND_PERMISSIONS_UPDATE" -> ApplicationCommandPermissionsUpdate( - decoder.decodeSerializableElement( - descriptor, index, DiscordGuildApplicationCommandPermissions.serializer() + decode( + DiscordGuildApplicationCommandPermissions.serializer() ), sequence ) "AUTO_MODERATION_RULE_CREATE" -> AutoModerationRuleCreate( - rule = decoder.decodeSerializableElement(descriptor, index, DiscordAutoModerationRule.serializer()), + rule = decode(DiscordAutoModerationRule.serializer()), sequence, ) "AUTO_MODERATION_RULE_UPDATE" -> AutoModerationRuleUpdate( - rule = decoder.decodeSerializableElement(descriptor, index, DiscordAutoModerationRule.serializer()), + rule = decode(DiscordAutoModerationRule.serializer()), sequence, ) "AUTO_MODERATION_RULE_DELETE" -> AutoModerationRuleDelete( - rule = decoder.decodeSerializableElement(descriptor, index, DiscordAutoModerationRule.serializer()), + rule = decode(DiscordAutoModerationRule.serializer()), sequence, ) "AUTO_MODERATION_ACTION_EXECUTION" -> AutoModerationActionExecution( - actionExecution = decoder.decodeSerializableElement( - descriptor, - index, + actionExecution = decode( DiscordAutoModerationActionExecution.serializer(), ), sequence, ) "CHANNEL_CREATE" -> ChannelCreate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordChannel.serializer() ), sequence ) "CHANNEL_UPDATE" -> ChannelUpdate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordChannel.serializer() ), sequence ) "CHANNEL_DELETE" -> ChannelDelete( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordChannel.serializer() ), sequence ) "CHANNEL_PINS_UPDATE" -> ChannelPinsUpdate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordPinsUpdateData.serializer() ), sequence ) "TYPING_START" -> TypingStart( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordTyping.serializer() ), sequence ) "GUILD_AUDIT_LOG_ENTRY_CREATE" -> GuildAuditLogEntryCreate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordAuditLogEntry.serializer() ), sequence ) "GUILD_CREATE" -> GuildCreate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordGuild.serializer() ), sequence ) "GUILD_UPDATE" -> GuildUpdate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordGuild.serializer() ), sequence ) "GUILD_DELETE" -> GuildDelete( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordUnavailableGuild.serializer() ), sequence ) "GUILD_BAN_ADD" -> GuildBanAdd( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordGuildBan.serializer() ), sequence ) "GUILD_BAN_REMOVE" -> GuildBanRemove( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordGuildBan.serializer() ), sequence ) "GUILD_EMOJIS_UPDATE" -> GuildEmojisUpdate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordUpdatedEmojis.serializer() ), sequence ) "GUILD_INTEGRATIONS_UPDATE" -> GuildIntegrationsUpdate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordGuildIntegrations.serializer() ), sequence ) "INTEGRATION_CREATE" -> IntegrationCreate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordIntegration.serializer() ), sequence ) "INTEGRATION_DELETE" -> IntegrationDelete( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordIntegrationDelete.serializer(), ), sequence ) "INTEGRATION_UPDATE" -> IntegrationUpdate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordIntegration.serializer() ), sequence ) "GUILD_MEMBER_ADD" -> GuildMemberAdd( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordAddedGuildMember.serializer() ), sequence ) "GUILD_MEMBER_REMOVE" -> GuildMemberRemove( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordRemovedGuildMember.serializer() ), sequence ) "GUILD_MEMBER_UPDATE" -> GuildMemberUpdate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordUpdatedGuildMember.serializer() ), sequence ) "GUILD_ROLE_CREATE" -> GuildRoleCreate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordGuildRole.serializer() ), sequence ) "GUILD_ROLE_UPDATE" -> GuildRoleUpdate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordGuildRole.serializer() ), sequence ) "GUILD_ROLE_DELETE" -> GuildRoleDelete( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordDeletedGuildRole.serializer() ), sequence ) "GUILD_MEMBERS_CHUNK" -> GuildMembersChunk( - decoder.decodeSerializableElement( - descriptor, - index, + decode( GuildMembersChunkData.serializer() ), sequence ) "INVITE_CREATE" -> InviteCreate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordCreatedInvite.serializer() ), sequence ) "INVITE_DELETE" -> InviteDelete( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordDeletedInvite.serializer() ), sequence ) "MESSAGE_CREATE" -> MessageCreate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordMessage.serializer() ), sequence ) "MESSAGE_UPDATE" -> MessageUpdate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordPartialMessage.serializer() ), sequence ) "MESSAGE_DELETE" -> MessageDelete( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DeletedMessage.serializer() ), sequence ) "MESSAGE_DELETE_BULK" -> MessageDeleteBulk( - decoder.decodeSerializableElement( - descriptor, - index, + decode( BulkDeleteData.serializer() ), sequence ) "MESSAGE_REACTION_ADD" -> MessageReactionAdd( - decoder.decodeSerializableElement( - descriptor, - index, + decode( MessageReactionAddData.serializer() ), sequence ) "MESSAGE_REACTION_REMOVE" -> MessageReactionRemove( - decoder.decodeSerializableElement( - descriptor, - index, + decode( MessageReactionRemoveData.serializer() ), sequence ) "MESSAGE_REACTION_REMOVE_EMOJI" -> MessageReactionRemoveEmoji( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordRemovedEmoji.serializer() ), sequence ) "MESSAGE_REACTION_REMOVE_ALL" -> MessageReactionRemoveAll( - decoder.decodeSerializableElement( - descriptor, - index, + decode( AllRemovedMessageReactions.serializer() ), sequence ) "PRESENCE_UPDATE" -> PresenceUpdate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordPresenceUpdate.serializer() ), sequence ) "USER_UPDATE" -> UserUpdate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordUser.serializer() ), sequence ) "VOICE_STATE_UPDATE" -> VoiceStateUpdate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordVoiceState.serializer() ), sequence ) "VOICE_SERVER_UPDATE" -> VoiceServerUpdate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordVoiceServerUpdateData.serializer() ), sequence ) "WEBHOOKS_UPDATE" -> WebhooksUpdate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordWebhooksUpdateData.serializer() ), sequence ) "INTERACTION_CREATE" -> InteractionCreate( - decoder.decodeSerializableElement( - descriptor, - index, + decode( DiscordInteraction.serializer() ), sequence ) "APPLICATION_COMMAND_CREATE" -> ApplicationCommandCreate( - decoder.decodeSerializableElement(descriptor, index, DiscordApplicationCommand.serializer()), + decode(DiscordApplicationCommand.serializer()), sequence ) "APPLICATION_COMMAND_UPDATE" -> ApplicationCommandUpdate( - decoder.decodeSerializableElement(descriptor, index, DiscordApplicationCommand.serializer()), + decode(DiscordApplicationCommand.serializer()), sequence ) "APPLICATION_COMMAND_DELETE" -> ApplicationCommandDelete( - decoder.decodeSerializableElement(descriptor, index, DiscordApplicationCommand.serializer()), + decode(DiscordApplicationCommand.serializer()), sequence ) "THREAD_CREATE" -> ThreadCreate( - decoder.decodeSerializableElement(descriptor, index, DiscordChannel.serializer()), + decode(DiscordChannel.serializer()), sequence ) "THREAD_DELETE" -> ThreadDelete( - decoder.decodeSerializableElement(descriptor, index, DiscordChannel.serializer()), + decode(DiscordChannel.serializer()), sequence ) "THREAD_UPDATE" -> ThreadUpdate( - decoder.decodeSerializableElement(descriptor, index, DiscordChannel.serializer()), + decode(DiscordChannel.serializer()), sequence ) "THREAD_LIST_SYNC" -> ThreadListSync( - decoder.decodeSerializableElement(descriptor, index, DiscordThreadListSync.serializer()), + decode(DiscordThreadListSync.serializer()), sequence ) "THREAD_MEMBER_UPDATE" -> ThreadMemberUpdate( - decoder.decodeSerializableElement(descriptor, index, DiscordThreadMember.serializer()), + decode(DiscordThreadMember.serializer()), sequence ) "THREAD_MEMBERS_UPDATE" -> ThreadMembersUpdate( - decoder.decodeSerializableElement(descriptor, index, DiscordThreadMembersUpdate.serializer()), + decode(DiscordThreadMembersUpdate.serializer()), sequence ) "GUILD_SCHEDULED_EVENT_CREATE" -> GuildScheduledEventCreate( - decoder.decodeSerializableElement(descriptor, index, DiscordGuildScheduledEvent.serializer()), + decode(DiscordGuildScheduledEvent.serializer()), sequence ) "GUILD_SCHEDULED_EVENT_UPDATE" -> GuildScheduledEventUpdate( - decoder.decodeSerializableElement(descriptor, index, DiscordGuildScheduledEvent.serializer()), + decode(DiscordGuildScheduledEvent.serializer()), sequence ) "GUILD_SCHEDULED_EVENT_DELETE" -> GuildScheduledEventDelete( - decoder.decodeSerializableElement(descriptor, index, DiscordGuildScheduledEvent.serializer()), + decode(DiscordGuildScheduledEvent.serializer()), sequence ) "GUILD_SCHEDULED_EVENT_USER_ADD" -> GuildScheduledEventUserAdd( - data = decoder.decodeSerializableElement( - descriptor, - index, + data = decode( GuildScheduledEventUserMetadata.serializer(), ), sequence ) "GUILD_SCHEDULED_EVENT_USER_REMOVE" -> GuildScheduledEventUserRemove( - data = decoder.decodeSerializableElement( - descriptor, - index, + data = decode( GuildScheduledEventUserMetadata.serializer(), ), sequence @@ -491,15 +400,12 @@ public sealed class Event { else -> { - jsonLogger.debug { "unknown gateway event name $name" } - // consume json elements that are unknown to us - val data = decoder.decodeSerializableElement(descriptor, index, JsonElement.serializer().nullable) - UnknownDispatchEvent(name, data, sequence) + jsonLogger.debug { "Unknown gateway event name: $eventName" } + UnknownDispatchEvent(eventName, eventData, sequence) } } - + } } - } diff --git a/gateway/src/commonTest/kotlin/json/DispatchEventDeserializationTest.kt b/gateway/src/commonTest/kotlin/json/DispatchEventDeserializationTest.kt new file mode 100644 index 000000000000..329f10bccc5f --- /dev/null +++ b/gateway/src/commonTest/kotlin/json/DispatchEventDeserializationTest.kt @@ -0,0 +1,799 @@ +package dev.kord.gateway.json + +import dev.kord.common.entity.* +import dev.kord.gateway.* +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.json.* +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +class DispatchEventDeserializationTest { + private fun testDispatchEventDeserialization( + eventName: String, + eventConstructor: (data: T, sequence: Int?) -> DispatchEvent, + data: T, + json: String, + ) { + val sequence = Random.nextInt() + val eventWithoutSequence = eventConstructor(data, null) + val eventWithSequence = eventConstructor(data, sequence) + + val permutationsWithMissingSequence = + jsonObjectPermutations("op" to "0", "t" to "\"$eventName\"", "d" to json) + val permutationsWithNullSequence = + jsonObjectPermutations("op" to "0", "t" to "\"$eventName\"", "s" to "null", "d" to json) + val permutationsWithSequence = + jsonObjectPermutations("op" to "0", "t" to "\"$eventName\"", "s" to "$sequence", "d" to json) + + permutationsWithMissingSequence.forEach { perm -> + assertEquals(eventWithoutSequence, Json.decodeFromString(Event.DeserializationStrategy, perm)) + } + permutationsWithNullSequence.forEach { perm -> + assertEquals(eventWithoutSequence, Json.decodeFromString(Event.DeserializationStrategy, perm)) + } + permutationsWithSequence.forEach { perm -> + assertEquals(eventWithSequence, Json.decodeFromString(Event.DeserializationStrategy, perm)) + } + } + + + private val autoModerationRule = DiscordAutoModerationRule( + id = Snowflake.min, + guildId = Snowflake.min, + name = "rule", + creatorId = Snowflake.min, + eventType = AutoModerationRuleEventType.MessageSend, + triggerType = AutoModerationRuleTriggerType.Spam, + triggerMetadata = DiscordAutoModerationRuleTriggerMetadata(), + actions = emptyList(), + enabled = false, + exemptRoles = emptyList(), + exemptChannels = emptyList(), + ) + private val autoModerationRuleJson = """{"id":"0","guild_id":"0","name":"rule","creator_id":"0",""" + + """"event_type":1,"trigger_type":3,"trigger_metadata":{},"actions":[],"enabled":false,"exempt_roles":[],""" + + """"exempt_channels":[]}""" + private val channel = DiscordChannel(id = Snowflake.min, type = ChannelType.GuildText) + private val channelJson = """{"id":"0","type":0}""" + private val thread = DiscordChannel(id = Snowflake.min, type = ChannelType.PublicGuildThread) + private val threadJson = """{"id":"0","type":11}""" + private val guild = DiscordGuild( + id = Snowflake.min, + name = "name", + icon = null, + ownerId = Snowflake.min, + region = "nice-region", + afkChannelId = null, + afkTimeout = 42.minutes, + verificationLevel = VerificationLevel.Medium, + defaultMessageNotifications = DefaultMessageNotificationLevel.OnlyMentions, + explicitContentFilter = ExplicitContentFilter.MembersWithoutRoles, + roles = emptyList(), + emojis = emptyList(), + features = emptyList(), + mfaLevel = MFALevel.None, + applicationId = null, + systemChannelId = null, + systemChannelFlags = SystemChannelFlags(), + rulesChannelId = null, + vanityUrlCode = null, + description = null, + banner = null, + premiumTier = PremiumTier.One, + preferredLocale = "en-US", + publicUpdatesChannelId = null, + nsfwLevel = NsfwLevel.Default, + premiumProgressBarEnabled = false, + safetyAlertsChannelId = null, + ) + private val guildJson = """{"id":"0","name":"name","icon":null,"owner_id":"0","region":"nice-region",""" + + """"afk_channel_id":null,"afk_timeout":2520,"verification_level":2,"default_message_notifications":1,""" + + """"explicit_content_filter":1,"roles":[],"emojis":[],"features":[],"mfa_level":0,"application_id":null,""" + + """"system_channel_id":null,"system_channel_flags":0,"rules_channel_id":null,"vanity_url_code":null,""" + + """"description":null,"banner":null,"premium_tier":1,"preferred_locale":"en-US",""" + + """"public_updates_channel_id":null,"nsfw_level":0,"premium_progress_bar_enabled":false,""" + + """"safety_alerts_channel_id":null}""" + private val user = DiscordUser(id = Snowflake.min, username = "username", avatar = null) + private val userJson = """{"id":"0","username":"username","avatar":null}""" + private val guildBan = DiscordGuildBan(guildId = Snowflake.min, user = user) + private val guildBanJson = """{"guild_id":"0","user":$userJson}""" + private val guildRole = DiscordGuildRole( + guildId = Snowflake.min, + role = DiscordRole( + id = Snowflake.min, + name = "role", + color = 0, + hoist = false, + position = 0, + permissions = Permissions(), + managed = false, + mentionable = false, + flags = RoleFlags(), + ), + ) + private val guildRoleJson = """{"guild_id":"0","role":{"id":"0","name":"role","color":0,"hoist":false,""" + + """"position":0,"permissions":"0","managed":false,"mentionable":false,"flags":0}}""" + private val instant = Clock.System.now() + private val guildScheduledEvent = DiscordGuildScheduledEvent( + id = Snowflake.min, + guildId = Snowflake.min, + channelId = null, + name = "event", + scheduledStartTime = instant, + scheduledEndTime = null, + privacyLevel = GuildScheduledEventPrivacyLevel.GuildOnly, + status = GuildScheduledEventStatus.Active, + entityType = ScheduledEntityType.External, + entityId = null, + entityMetadata = null, + ) + private val guildScheduledEventJson = """{"id":"0","guild_id":"0","channel_id":null,"name":"event",""" + + """"scheduled_start_time":"$instant","scheduled_end_time":null,"privacy_level":2,"status":2,""" + + """"entity_type":3,"entity_id":null,"entity_metadata":null}""" + private val guildScheduledEventUserMetadata = GuildScheduledEventUserMetadata( + guildScheduledEventId = Snowflake.min, + userId = Snowflake.min, + guildId = Snowflake.min, + ) + private val guildScheduledEventUserMetadataJson = + """{"guild_scheduled_event_id":"0","user_id":"0","guild_id":"0"}""" + private val integration = DiscordIntegration( + id = Snowflake.min, + name = "name", + type = "discord", + enabled = true, + account = DiscordIntegrationsAccount(id = "id", name = "name"), + ) + private val integrationJson = + """{"id":"0","name":"name","type":"discord","enabled":true,"account":{"id":"id","name":"name"}}""" + + + /* + * Keep tests ordered like this table: https://discord.com/developers/docs/topics/gateway-events#receive-events + * (Hello, Reconnect and InvalidSession are tested elsewhere, they are no DispatchEvents) + */ + + + @Test + fun test_Ready_deserialization() = testDispatchEventDeserialization( + eventName = "READY", + eventConstructor = ::Ready, + data = ReadyData( + version = 42, + user = user, + privateChannels = emptyList(), + guilds = emptyList(), + sessionId = "deadbeef", + resumeGatewayUrl = "wss://example.com", + traces = emptyList(), + ), + json = """{"v":42,"user":$userJson,"private_channels":[],"guilds":[],"session_id":"deadbeef",""" + + """"resume_gateway_url":"wss://example.com","_trace":[]}""", + ) + + @Test + fun test_Resumed_deserialization() = testDispatchEventDeserialization( + eventName = "RESUMED", + eventConstructor = { _, sequence -> Resumed(sequence) }, + data = null, + json = "null", + ) + + @Test + fun test_ApplicationCommandPermissionsUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "APPLICATION_COMMAND_PERMISSIONS_UPDATE", + eventConstructor = ::ApplicationCommandPermissionsUpdate, + data = DiscordGuildApplicationCommandPermissions( + id = Snowflake.min, + applicationId = Snowflake.min, + guildId = Snowflake.min, + permissions = emptyList(), + ), + json = """{"id":"0","application_id":"0","guild_id":"0","permissions":[]}""", + ) + + @Test + fun test_AutoModerationRuleCreate_deserialization() = testDispatchEventDeserialization( + eventName = "AUTO_MODERATION_RULE_CREATE", + eventConstructor = ::AutoModerationRuleCreate, + data = autoModerationRule, + json = autoModerationRuleJson, + ) + + @Test + fun test_AutoModerationRuleUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "AUTO_MODERATION_RULE_UPDATE", + eventConstructor = ::AutoModerationRuleUpdate, + data = autoModerationRule, + json = autoModerationRuleJson, + ) + + @Test + fun test_AutoModerationRuleDelete_deserialization() = testDispatchEventDeserialization( + eventName = "AUTO_MODERATION_RULE_DELETE", + eventConstructor = ::AutoModerationRuleDelete, + data = autoModerationRule, + json = autoModerationRuleJson, + ) + + @Test + fun test_AutoModerationActionExecution_deserialization() = testDispatchEventDeserialization( + eventName = "AUTO_MODERATION_ACTION_EXECUTION", + eventConstructor = ::AutoModerationActionExecution, + data = DiscordAutoModerationActionExecution( + guildId = Snowflake.min, + action = DiscordAutoModerationAction(type = AutoModerationActionType.BlockMessage), + ruleId = Snowflake.min, + ruleTriggerType = AutoModerationRuleTriggerType.Keyword, + userId = Snowflake.min, + content = "evil", + matchedKeyword = "ev", + matchedContent = "ev", + ), + json = """{"guild_id":"0","action":{"type":1},"rule_id":"0","rule_trigger_type":1,"user_id":"0",""" + + """"content":"evil","matched_keyword":"ev","matched_content":"ev"}""", + ) + + @Test + fun test_ChannelCreate_deserialization() = testDispatchEventDeserialization( + eventName = "CHANNEL_CREATE", + eventConstructor = ::ChannelCreate, + data = channel, + json = channelJson, + ) + + @Test + fun test_ChannelUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "CHANNEL_UPDATE", + eventConstructor = ::ChannelUpdate, + data = channel, + json = channelJson, + ) + + @Test + fun test_ChannelDelete_deserialization() = testDispatchEventDeserialization( + eventName = "CHANNEL_DELETE", + eventConstructor = ::ChannelDelete, + data = channel, + json = channelJson, + ) + + @Test + fun test_ChannelPinsUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "CHANNEL_PINS_UPDATE", + eventConstructor = ::ChannelPinsUpdate, + data = DiscordPinsUpdateData(channelId = Snowflake.min), + json = """{"channel_id":"0"}""", + ) + + @Test + fun test_ThreadCreate_deserialization() = testDispatchEventDeserialization( + eventName = "THREAD_CREATE", + eventConstructor = ::ThreadCreate, + data = thread, + json = threadJson, + ) + + @Test + fun test_ThreadUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "THREAD_UPDATE", + eventConstructor = ::ThreadUpdate, + data = thread, + json = threadJson, + ) + + @Test + fun test_ThreadDelete_deserialization() = testDispatchEventDeserialization( + eventName = "THREAD_DELETE", + eventConstructor = ::ThreadDelete, + data = thread, + json = threadJson, + ) + + @Test + fun test_ThreadListSync_deserialization() = testDispatchEventDeserialization( + eventName = "THREAD_LIST_SYNC", + eventConstructor = ::ThreadListSync, + data = DiscordThreadListSync(guildId = Snowflake.min, threads = emptyList(), members = emptyList()), + json = """{"guild_id":"0","threads":[],"members":[]}""", + ) + + @Test + fun test_ThreadMemberUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "THREAD_MEMBER_UPDATE", + eventConstructor = ::ThreadMemberUpdate, + data = DiscordThreadMember(joinTimestamp = instant, flags = 0), + json = """{"join_timestamp":"$instant","flags":0}""", + ) + + @Test + fun test_ThreadMembersUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "THREAD_MEMBERS_UPDATE", + eventConstructor = ::ThreadMembersUpdate, + data = DiscordThreadMembersUpdate(id = Snowflake.min, guildId = Snowflake.min, memberCount = 42), + json = """{"id":"0","guild_id":"0","member_count":42}""", + ) + + /* + * Missing: + * - EntitlementCreate + * - EntitlementUpdate + * - EntitlementDelete + */ + + @Test + fun test_GuildCreate_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_CREATE", + eventConstructor = ::GuildCreate, + data = guild, + json = guildJson, + ) + + @Test + fun test_GuildUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_UPDATE", + eventConstructor = ::GuildUpdate, + data = guild, + json = guildJson, + ) + + @Test + fun test_GuildDelete_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_DELETE", + eventConstructor = ::GuildDelete, + data = DiscordUnavailableGuild(id = Snowflake.min), + json = """{"id":"0"}""", + ) + + @Test + fun test_GuildAuditLogEntryCreate_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_AUDIT_LOG_ENTRY_CREATE", + eventConstructor = ::GuildAuditLogEntryCreate, + data = DiscordAuditLogEntry( + targetId = null, + userId = null, + id = Snowflake.min, + actionType = AuditLogEvent.MemberKick, + ), + json = """{"target_id":null,"user_id":null,"id":"0","action_type":20}""", + ) + + @Test + fun test_GuildBanAdd_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_BAN_ADD", + eventConstructor = ::GuildBanAdd, + data = guildBan, + json = guildBanJson, + ) + + @Test + fun test_GuildBanRemove_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_BAN_REMOVE", + eventConstructor = ::GuildBanRemove, + data = guildBan, + json = guildBanJson, + ) + + @Test + fun test_GuildEmojisUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_EMOJIS_UPDATE", + eventConstructor = ::GuildEmojisUpdate, + data = DiscordUpdatedEmojis(guildId = Snowflake.min, emojis = emptyList()), + json = """{"guild_id":"0","emojis":[]}""", + ) + + /* + * Missing: + * - GuildStickersUpdate + */ + + @Test + fun test_GuildIntegrationsUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_INTEGRATIONS_UPDATE", + eventConstructor = ::GuildIntegrationsUpdate, + data = DiscordGuildIntegrations(guildId = Snowflake.min), + json = """{"guild_id":"0"}""", + ) + + @Test + fun test_GuildMemberAdd_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_MEMBER_ADD", + eventConstructor = ::GuildMemberAdd, + data = DiscordAddedGuildMember( + roles = emptyList(), + joinedAt = instant, + deaf = false, + mute = false, + flags = GuildMemberFlags(), + guildId = Snowflake.min, + ), + json = """{"roles":[],"joined_at":"$instant","deaf":false,"mute":false,"flags":0,"guild_id":"0"}""", + ) + + @Test + fun test_GuildMemberRemove_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_MEMBER_REMOVE", + eventConstructor = ::GuildMemberRemove, + data = DiscordRemovedGuildMember(guildId = Snowflake.min, user = user), + json = """{"guild_id":"0","user":$userJson}""", + ) + + @Test + fun test_GuildMemberUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_MEMBER_UPDATE", + eventConstructor = ::GuildMemberUpdate, + data = DiscordUpdatedGuildMember( + guildId = Snowflake.min, + roles = emptyList(), + user = user, + joinedAt = instant, + flags = GuildMemberFlags(), + ), + json = """{"guild_id":"0","roles":[],"user":$userJson,"joined_at":"$instant","flags":0}""", + ) + + @Test + fun test_GuildMembersChunk_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_MEMBERS_CHUNK", + eventConstructor = ::GuildMembersChunk, + data = GuildMembersChunkData( + guildId = Snowflake.min, + members = emptyList(), + chunkIndex = 42, + chunkCount = 9001, + ), + json = """{"guild_id":"0","members":[],"chunk_index":42,"chunk_count":9001}""", + ) + + @Test + fun test_GuildRoleCreate_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_ROLE_CREATE", + eventConstructor = ::GuildRoleCreate, + data = guildRole, + json = guildRoleJson, + ) + + @Test + fun test_GuildRoleUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_ROLE_UPDATE", + eventConstructor = ::GuildRoleUpdate, + data = guildRole, + json = guildRoleJson, + ) + + @Test + fun test_GuildRoleDelete_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_ROLE_DELETE", + eventConstructor = ::GuildRoleDelete, + data = DiscordDeletedGuildRole(guildId = Snowflake.min, id = Snowflake.min), + json = """{"guild_id":"0","role_id":"0"}""", + ) + + @Test + fun test_GuildScheduledEventCreate_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_SCHEDULED_EVENT_CREATE", + eventConstructor = ::GuildScheduledEventCreate, + data = guildScheduledEvent, + json = guildScheduledEventJson, + ) + + @Test + fun test_GuildScheduledEventUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_SCHEDULED_EVENT_UPDATE", + eventConstructor = ::GuildScheduledEventUpdate, + data = guildScheduledEvent, + json = guildScheduledEventJson, + ) + + @Test + fun test_GuildScheduledEventDelete_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_SCHEDULED_EVENT_DELETE", + eventConstructor = ::GuildScheduledEventDelete, + data = guildScheduledEvent, + json = guildScheduledEventJson, + ) + + @Test + fun test_GuildScheduledEventUserAdd_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_SCHEDULED_EVENT_USER_ADD", + eventConstructor = ::GuildScheduledEventUserAdd, + data = guildScheduledEventUserMetadata, + json = guildScheduledEventUserMetadataJson, + ) + + @Test + fun test_GuildScheduledEventUserRemove_deserialization() = testDispatchEventDeserialization( + eventName = "GUILD_SCHEDULED_EVENT_USER_REMOVE", + eventConstructor = ::GuildScheduledEventUserRemove, + data = guildScheduledEventUserMetadata, + json = guildScheduledEventUserMetadataJson, + ) + + @Test + fun test_IntegrationCreate_deserialization() = testDispatchEventDeserialization( + eventName = "INTEGRATION_CREATE", + eventConstructor = ::IntegrationCreate, + data = integration, + json = integrationJson, + ) + + @Test + fun test_IntegrationUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "INTEGRATION_UPDATE", + eventConstructor = ::IntegrationUpdate, + data = + integration, + json = integrationJson, + ) + + @Test + fun test_IntegrationDelete_deserialization() = testDispatchEventDeserialization( + eventName = "INTEGRATION_DELETE", + eventConstructor = ::IntegrationDelete, + data = DiscordIntegrationDelete(id = Snowflake.min, guildId = Snowflake.min), + json = """{"id":"0","guild_id":"0"}""", + ) + + @Test + fun test_InteractionCreate_deserialization() = testDispatchEventDeserialization( + eventName = "INTERACTION_CREATE", + eventConstructor = ::InteractionCreate, + data = DiscordInteraction( + id = Snowflake.min, + applicationId = Snowflake.min, + type = InteractionType.Ping, + data = InteractionCallbackData(), + token = "hunter2", + version = 1, + ), + json = """{"id":"0","application_id":"0","type":1,"data":{},"token":"hunter2","version":1}""", + ) + + @Test + fun test_InviteCreate_deserialization() = testDispatchEventDeserialization( + eventName = "INVITE_CREATE", + eventConstructor = ::InviteCreate, + data = DiscordCreatedInvite( + channelId = Snowflake.min, + code = "code", + createdAt = instant, + maxAge = 100.hours, + maxUses = 42, + temporary = false, + uses = 0, + ), + json = """{"channel_id":"0","code":"code","created_at":"$instant","max_age":360000,"max_uses":42,""" + + """"temporary":false,"uses":0}""", + ) + + @Test + fun test_InviteDelete_deserialization() = testDispatchEventDeserialization( + eventName = "INVITE_DELETE", + eventConstructor = ::InviteDelete, + data = DiscordDeletedInvite(channelId = Snowflake.min, code = "code"), + json = """{"channel_id":"0","code":"code"}""", + ) + + @Test + fun test_MessageCreate_deserialization() = testDispatchEventDeserialization( + eventName = "MESSAGE_CREATE", + eventConstructor = ::MessageCreate, + data = DiscordMessage( + id = Snowflake.min, + channelId = Snowflake.min, + author = user, + content = "hi", + timestamp = instant, + editedTimestamp = null, + tts = false, + mentionEveryone = false, + mentions = emptyList(), + mentionRoles = emptyList(), + attachments = emptyList(), + embeds = emptyList(), + pinned = false, + type = MessageType.Default, + ), + json = """{"id":"0","channel_id":"0","author":$userJson,"content":"hi","timestamp":"$instant",""" + + """"edited_timestamp":null,"tts":false,"mention_everyone":false,"mentions":[],"mention_roles":[],""" + + """"attachments":[],"embeds":[],"pinned":false,"type":0}""", + ) + + @Test + fun test_MessageUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "MESSAGE_UPDATE", + eventConstructor = ::MessageUpdate, + data = DiscordPartialMessage(id = Snowflake.min, channelId = Snowflake.min), + json = """{"id":"0","channel_id":"0"}""", + ) + + @Test + fun test_MessageDelete_deserialization() = testDispatchEventDeserialization( + eventName = "MESSAGE_DELETE", + eventConstructor = ::MessageDelete, + data = DeletedMessage(id = Snowflake.min, channelId = Snowflake.min), + json = """{"id":"0","channel_id":"0"}""", + ) + + @Test + fun test_MessageDeleteBulk_deserialization() = testDispatchEventDeserialization( + eventName = "MESSAGE_DELETE_BULK", + eventConstructor = ::MessageDeleteBulk, + data = BulkDeleteData(ids = emptyList(), channelId = Snowflake.min), + json = """{"ids":[],"channel_id":"0"}""", + ) + + @Test + fun test_MessageReactionAdd_deserialization() = testDispatchEventDeserialization( + eventName = "MESSAGE_REACTION_ADD", + eventConstructor = ::MessageReactionAdd, + data = MessageReactionAddData( + userId = Snowflake.min, + channelId = Snowflake.min, + messageId = Snowflake.min, + emoji = DiscordPartialEmoji(id = Snowflake.min), + ), + json = """{"user_id":"0","channel_id":"0","message_id":"0","emoji":{"id":"0"}}""", + ) + + @Test + fun test_MessageReactionRemove_deserialization() = testDispatchEventDeserialization( + eventName = "MESSAGE_REACTION_REMOVE", + eventConstructor = ::MessageReactionRemove, + data = MessageReactionRemoveData( + userId = Snowflake.min, + channelId = Snowflake.min, + messageId = Snowflake.min, + emoji = DiscordPartialEmoji(name = "❤️"), + ), + json = """{"user_id":"0","channel_id":"0","message_id":"0","emoji":{"name":"❤️"}}""", + ) + + @Test + fun test_MessageReactionRemoveAll_deserialization() = testDispatchEventDeserialization( + eventName = "MESSAGE_REACTION_REMOVE_ALL", + eventConstructor = ::MessageReactionRemoveAll, + data = AllRemovedMessageReactions(channelId = Snowflake.min, messageId = Snowflake.min), + json = """{"channel_id":"0","message_id":"0"}""", + ) + + @Test + fun test_MessageReactionRemoveEmoji_deserialization() = testDispatchEventDeserialization( + eventName = "MESSAGE_REACTION_REMOVE_EMOJI", + eventConstructor = ::MessageReactionRemoveEmoji, + data = DiscordRemovedEmoji( + channelId = Snowflake.min, + guildId = Snowflake.min, + messageId = Snowflake.min, + emoji = DiscordRemovedReactionEmoji(id = null, name = null), + ), + json = """{"channel_id":"0","guild_id":"0","message_id":"0","emoji":{"id":null,"name":null}}""", + ) + + @Test + fun test_PresenceUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "PRESENCE_UPDATE", + eventConstructor = ::PresenceUpdate, + data = DiscordPresenceUpdate( + user = DiscordPresenceUser(id = Snowflake.min, details = JsonObject(emptyMap())), + status = PresenceStatus.Online, + activities = emptyList(), + clientStatus = DiscordClientStatus(), + ), + json = """{"user":{"id":"0"},"status":"online","activities":[],"client_status":{}}""", + ) + + /* + * Missing: + * - StageInstanceCreate + * - StageInstanceUpdate + * - StageInstanceDelete + */ + + @Test + fun test_TypingStart_deserialization() = testDispatchEventDeserialization( + eventName = "TYPING_START", + eventConstructor = ::TypingStart, + data = DiscordTyping( + channelId = Snowflake.min, + userId = Snowflake.min, + timestamp = Instant.fromEpochSeconds(123), + ), + json = """{"channel_id":"0","user_id":"0","timestamp":123}""", + ) + + @Test + fun test_UserUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "USER_UPDATE", + eventConstructor = ::UserUpdate, + data = user, + json = userJson, + ) + + @Test + fun test_VoiceStateUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "VOICE_STATE_UPDATE", + eventConstructor = ::VoiceStateUpdate, + data = DiscordVoiceState( + channelId = null, + userId = Snowflake.min, + sessionId = "abcd", + deaf = false, + mute = false, + selfDeaf = false, + selfMute = false, + selfVideo = false, + suppress = false, + requestToSpeakTimestamp = null, + ), + json = """{"channel_id":null,"user_id":"0","session_id":"abcd","deaf":false,"mute":false,"self_deaf":false,""" + + """"self_mute":false,"self_video":false,"suppress":false,"request_to_speak_timestamp":null}""" + ) + + @Test + fun test_VoiceServerUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "VOICE_SERVER_UPDATE", + eventConstructor = ::VoiceServerUpdate, + data = DiscordVoiceServerUpdateData(token = "hunter2", guildId = Snowflake.min, endpoint = null), + json = """{"token":"hunter2","guild_id":"0","endpoint":null}""", + ) + + @Test + fun test_WebhooksUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "WEBHOOKS_UPDATE", + eventConstructor = ::WebhooksUpdate, + data = DiscordWebhooksUpdateData(guildId = Snowflake.min, channelId = Snowflake.min), + json = """{"guild_id":"0","channel_id":"0"}""", + ) + + @Test + fun test_UnknownDispatchEvent_deserialization() = testDispatchEventDeserialization( + eventName = "SOME_UNKNOWN_EVENT", + eventConstructor = { data, sequence -> UnknownDispatchEvent(name = "SOME_UNKNOWN_EVENT", data, sequence) }, + data = buildJsonObject { put("foo", "bar") }, + json = """{"foo":"bar"}""", + ) + + + // The following events have been removed from Discord's documentation, we should probably remove them too. + // See https://github.com/discord/discord-api-docs/pull/3691 + + private val applicationCommand = DiscordApplicationCommand( + id = Snowflake.min, + applicationId = Snowflake.min, + name = "name", + description = null, + defaultMemberPermissions = null, + version = Snowflake.min, + ) + private val applicationCommandJson = """{"id":"0","application_id":"0","name":"name","description":null,""" + + """"default_member_permissions":null,"version":"0"}""" + + @Test + fun test_ApplicationCommandCreate_deserialization() = testDispatchEventDeserialization( + eventName = "APPLICATION_COMMAND_CREATE", + eventConstructor = ::ApplicationCommandCreate, + data = applicationCommand, + json = applicationCommandJson, + ) + + @Test + fun test_ApplicationCommandUpdate_deserialization() = testDispatchEventDeserialization( + eventName = "APPLICATION_COMMAND_UPDATE", + eventConstructor = ::ApplicationCommandUpdate, + data = applicationCommand, + json = applicationCommandJson, + ) + + @Test + fun test_ApplicationCommandDelete_deserialization() = testDispatchEventDeserialization( + eventName = "APPLICATION_COMMAND_DELETE", + eventConstructor = ::ApplicationCommandDelete, + data = applicationCommand, + json = applicationCommandJson, + ) +} diff --git a/gateway/src/commonTest/kotlin/json/JsonPermutations.kt b/gateway/src/commonTest/kotlin/json/JsonPermutations.kt new file mode 100644 index 000000000000..550adde2ca0f --- /dev/null +++ b/gateway/src/commonTest/kotlin/json/JsonPermutations.kt @@ -0,0 +1,18 @@ +package dev.kord.gateway.json + +fun jsonObjectPermutations(vararg keyValuePairs: Pair): List { + fun permutations(list: List): List> = + if (list.isEmpty()) { + listOf(emptyList()) + } else { + val head = list.first() + val tail = list.subList(1, list.size) + permutations(tail).flatMap { perm -> + val len = perm.size + List(len + 1) { i -> perm.subList(0, i) + head + perm.subList(i, len) } + } + } + return permutations(keyValuePairs.toList()).map { pairs -> + pairs.joinToString(separator = ",", prefix = "{", postfix = "}") { (key, value) -> "\"$key\":$value" } + } +} diff --git a/gateway/src/commonTest/kotlin/json/ResumedDeserializationTest.kt b/gateway/src/commonTest/kotlin/json/ResumedDeserializationTest.kt new file mode 100644 index 000000000000..907e1dd15676 --- /dev/null +++ b/gateway/src/commonTest/kotlin/json/ResumedDeserializationTest.kt @@ -0,0 +1,59 @@ +package dev.kord.gateway.json + +import dev.kord.gateway.Event +import dev.kord.gateway.Resumed +import kotlinx.serialization.json.Json +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals + +class ResumedDeserializationTest { + private val name = "\"RESUMED\"" + + @Test + fun test_Resumed_deserialization_without_sequence() { + val resumed = Resumed(sequence = null) + val permutations = listOf( + jsonObjectPermutations("op" to "0", "t" to name), + jsonObjectPermutations("op" to "0", "t" to name, "s" to "null"), + ).flatten() + permutations.forEach { perm -> + assertEquals(resumed, Json.decodeFromString(Event.DeserializationStrategy, perm)) + } + } + + @Test + fun test_Resumed_deserialization_with_sequence() { + val sequence = Random.nextInt() + val resumed = Resumed(sequence) + val permutations = jsonObjectPermutations("op" to "0", "t" to name, "s" to "$sequence") + permutations.forEach { perm -> + assertEquals(resumed, Json.decodeFromString(Event.DeserializationStrategy, perm)) + } + } + + private val data = + listOf("null", "1234", "true", "\"str\"", """[null,-1,false,""]""", """{"a":null,"b":-134,"c":true,"d":"x"}""") + + @Test + fun test_Resumed_deserialization_ignores_data_without_sequence() = data.forEach { data -> + val resumed = Resumed(sequence = null) + val permutations = listOf( + jsonObjectPermutations("op" to "0", "t" to name, "d" to data), + jsonObjectPermutations("op" to "0", "t" to name, "s" to "null", "d" to data), + ).flatten() + permutations.forEach { perm -> + assertEquals(resumed, Json.decodeFromString(Event.DeserializationStrategy, perm)) + } + } + + @Test + fun test_Resumed_deserialization_ignores_data_with_sequence() = data.forEach { data -> + val sequence = Random.nextInt() + val resumed = Resumed(sequence) + val permutations = jsonObjectPermutations("op" to "0", "t" to name, "s" to "$sequence", "d" to data) + permutations.forEach { perm -> + assertEquals(resumed, Json.decodeFromString(Event.DeserializationStrategy, perm)) + } + } +} diff --git a/gateway/src/commonTest/kotlin/json/SerializationTest.kt b/gateway/src/commonTest/kotlin/json/SerializationTest.kt index 8d546fd04bd9..55f58df8395e 100644 --- a/gateway/src/commonTest/kotlin/json/SerializationTest.kt +++ b/gateway/src/commonTest/kotlin/json/SerializationTest.kt @@ -8,9 +8,14 @@ import dev.kord.common.entity.optional.value import dev.kord.gateway.* import kotlinx.coroutines.test.runTest import kotlinx.datetime.Instant +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.MissingFieldException import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull import kotlin.js.JsName import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.time.Duration.Companion.seconds private suspend fun file(name: String): String = readFile("event", name) @@ -162,4 +167,63 @@ class SerializationTest { } } + @Test + fun field_order_doesnt_matter() { + val data = listOf( + Triple(OpCode.Dispatch, "null", UnknownDispatchEvent(name = null, data = JsonNull, sequence = null)), + Triple(OpCode.Heartbeat, "1234", Heartbeat(1234)), + Triple(OpCode.Reconnect, """{"foo":["bar"]}""", Reconnect), + Triple(OpCode.InvalidSession, "false", InvalidSession(false)), + Triple(OpCode.Hello, """{"heartbeat_interval":1234}""", Hello(1234)), + Triple(OpCode.HeartbeatACK, """{"foo":["bar"]}""", HeartbeatACK), + ) + data.forEach { (opCode, json, event) -> + val permutations = listOf( + jsonObjectPermutations("op" to "${opCode.code}", "d" to json), + jsonObjectPermutations("op" to "${opCode.code}", "t" to "null", "d" to json), + jsonObjectPermutations("op" to "${opCode.code}", "s" to "null", "d" to json), + jsonObjectPermutations("op" to "${opCode.code}", "t" to "null", "s" to "null", "d" to json), + ).flatten() + permutations.forEach { perm -> + assertEquals(event, Json.decodeFromString(Event.DeserializationStrategy, perm)) + } + } + } + + @Test + fun deserializing_Event_with_illegal_or_unknown_OpCode_fails() { + val ops = listOf( + OpCode.Identify, + OpCode.StatusUpdate, + OpCode.VoiceStateUpdate, + OpCode.Resume, + OpCode.RequestGuildMembers, + OpCode.Unknown, + ) + for (op in ops.map { it.code } + (-100..-1) + (12..100)) { + val permutations = listOf( + jsonObjectPermutations("op" to "$op"), + jsonObjectPermutations("op" to "$op", "t" to "null"), + jsonObjectPermutations("op" to "$op", "s" to "null"), + jsonObjectPermutations("op" to "$op", "d" to "\"foo\""), + jsonObjectPermutations("op" to "$op", "t" to "null", "s" to "null"), + jsonObjectPermutations("op" to "$op", "t" to "null", "d" to "\"foo\""), + jsonObjectPermutations("op" to "$op", "s" to "null", "d" to "\"foo\""), + jsonObjectPermutations("op" to "$op", "t" to "null", "s" to "null", "d" to "\"foo\""), + ).flatten() + permutations.forEach { perm -> + assertFailsWith { + Json.decodeFromString(Event.DeserializationStrategy, perm) + } + } + } + } + + @Test + fun deserializing_Event_with_missing_op_field_fails() { + @OptIn(ExperimentalSerializationApi::class) + assertFailsWith { + Json.decodeFromString(Event.DeserializationStrategy, """{"s":1,"t":"EVENT_X","d":{"foo":"bar"}}""") + } + } } diff --git a/gateway/src/commonTest/kotlin/json/UnknownDispatchEventDeserializationTest.kt b/gateway/src/commonTest/kotlin/json/UnknownDispatchEventDeserializationTest.kt new file mode 100644 index 000000000000..6b03bb533691 --- /dev/null +++ b/gateway/src/commonTest/kotlin/json/UnknownDispatchEventDeserializationTest.kt @@ -0,0 +1,128 @@ +package dev.kord.gateway.json + +import dev.kord.gateway.Event +import dev.kord.gateway.UnknownDispatchEvent +import kotlinx.serialization.json.* +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals + +class UnknownDispatchEventDeserializationTest { + private val eventName = "SOME_UNKNOWN_EVENT" + private val jsonAndData = listOf( + "null" to JsonNull, + "1234" to JsonPrimitive(1234), + "true" to JsonPrimitive(true), + "\"str\"" to JsonPrimitive("str"), + """[null,-1,false,""]""" to buildJsonArray { + add(JsonNull) + add(-1) + add(false) + add("") + }, + """{"a":null,"b":-134,"c":true,"d":"x"}""" to buildJsonObject { + put("a", JsonNull) + put("b", -134) + put("c", true) + put("d", "x") + }, + ) + + @Test + fun test_empty_UnknownDispatchEvent_deserialization() { + val emptyEvent = UnknownDispatchEvent(name = null, data = null, sequence = null) + val permutations = listOf( + jsonObjectPermutations("op" to "0"), + jsonObjectPermutations("op" to "0", "t" to "null"), + jsonObjectPermutations("op" to "0", "s" to "null"), + jsonObjectPermutations("op" to "0", "t" to "null", "s" to "null"), + ).flatten() + permutations.forEach { perm -> + assertEquals(emptyEvent, Json.decodeFromString(Event.DeserializationStrategy, perm)) + } + } + + @Test + fun test_name_only_UnknownDispatchEvent_deserialization() { + val nameOnlyEvent = UnknownDispatchEvent(eventName, data = null, sequence = null) + val permutations = listOf( + jsonObjectPermutations("op" to "0", "t" to "\"$eventName\""), + jsonObjectPermutations("op" to "0", "t" to "\"$eventName\"", "s" to "null"), + ).flatten() + permutations.forEach { perm -> + assertEquals(nameOnlyEvent, Json.decodeFromString(Event.DeserializationStrategy, perm)) + } + } + + @Test + fun test_sequence_only_UnknownDispatchEvent_deserialization() { + val sequence = Random.nextInt() + val sequenceOnlyEvent = UnknownDispatchEvent(name = null, data = null, sequence) + val permutations = listOf( + jsonObjectPermutations("op" to "0", "s" to "$sequence"), + jsonObjectPermutations("op" to "0", "t" to "null", "s" to "$sequence"), + ).flatten() + permutations.forEach { perm -> + assertEquals(sequenceOnlyEvent, Json.decodeFromString(Event.DeserializationStrategy, perm)) + } + } + + @Test + fun test_data_only_UnknownDispatchEvent_deserialization() = jsonAndData.forEach { (json, data) -> + val dataOnlyEvent = UnknownDispatchEvent(name = null, data, sequence = null) + val permutations = listOf( + jsonObjectPermutations("op" to "0", "d" to json), + jsonObjectPermutations("op" to "0", "t" to "null", "d" to json), + jsonObjectPermutations("op" to "0", "s" to "null", "d" to json), + jsonObjectPermutations("op" to "0", "t" to "null", "s" to "null", "d" to json), + ).flatten() + permutations.forEach { perm -> + assertEquals(dataOnlyEvent, Json.decodeFromString(Event.DeserializationStrategy, perm)) + } + } + + @Test + fun test_name_and_sequence_UnknownDispatchEvent_deserialization() { + val sequence = Random.nextInt() + val nameAndSequenceEvent = UnknownDispatchEvent(eventName, data = null, sequence) + val permutations = jsonObjectPermutations("op" to "0", "t" to "\"$eventName\"", "s" to "$sequence") + permutations.forEach { perm -> + assertEquals(nameAndSequenceEvent, Json.decodeFromString(Event.DeserializationStrategy, perm)) + } + } + + @Test + fun test_name_and_data_UnknownDispatchEvent_deserialization() = jsonAndData.forEach { (json, data) -> + val nameAndDataEvent = UnknownDispatchEvent(eventName, data, sequence = null) + val permutations = listOf( + jsonObjectPermutations("op" to "0", "t" to "\"$eventName\"", "d" to json), + jsonObjectPermutations("op" to "0", "t" to "\"$eventName\"", "s" to "null", "d" to json), + ).flatten() + permutations.forEach { perm -> + assertEquals(nameAndDataEvent, Json.decodeFromString(Event.DeserializationStrategy, perm)) + } + } + + @Test + fun test_sequence_and_data_UnknownDispatchEvent_deserialization() = jsonAndData.forEach { (json, data) -> + val sequence = Random.nextInt() + val sequenceAndDataEvent = UnknownDispatchEvent(name = null, data, sequence) + val permutations = listOf( + jsonObjectPermutations("op" to "0", "s" to "$sequence", "d" to json), + jsonObjectPermutations("op" to "0", "t" to "null", "s" to "$sequence", "d" to json), + ).flatten() + permutations.forEach { perm -> + assertEquals(sequenceAndDataEvent, Json.decodeFromString(Event.DeserializationStrategy, perm)) + } + } + + @Test + fun test_full_UnknownDispatchEvent_deserialization() = jsonAndData.forEach { (json, data) -> + val sequence = Random.nextInt() + val fullEvent = UnknownDispatchEvent(eventName, data, sequence) + val permutations = jsonObjectPermutations("op" to "0", "t" to "\"$eventName\"", "s" to "$sequence", "d" to json) + permutations.forEach { perm -> + assertEquals(fullEvent, Json.decodeFromString(Event.DeserializationStrategy, perm)) + } + } +}