diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/NotificationsDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/NotificationsDto.kt index 9b01cd8..b99f513 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/NotificationsDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/NotificationsDto.kt @@ -60,8 +60,15 @@ data class NotificationStreamsDto( * `GET/PUT /auth/me/notifications/subscriptions` — the user's opted-in stream ids. * PUT replaces the full set; unknown ids are dropped server-side and the stored set * echoed back. + * + * [streams] intentionally has NO default: this DTO doubles as the PUT body, and the + * backend validator requires the `streams` field (`body('streams').isArray()`). + * kotlinx omits a property equal to its default (encodeDefaults=false), so a default + * of `emptyList()` would drop the field when the user clears their LAST subscription, + * sending `{}` → 400 "Validation failed" (the "can't turn off the last one" bug). With + * no default the empty list always serializes as `{"streams":[]}`. Do not re-add a default. */ @Serializable data class NotificationSubscriptionsDto( - val streams: List = emptyList(), + val streams: List, ) diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/NotificationsDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/NotificationsDtoTest.kt index b1ec4c4..783676b 100644 --- a/app/src/test/java/com/runicgateway/app/data/api/dto/NotificationsDtoTest.kt +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/NotificationsDtoTest.kt @@ -3,6 +3,7 @@ */ package com.runicgateway.app.data.api.dto +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -58,6 +59,15 @@ class NotificationsDtoTest { assertEquals(listOf("news.post", "champ.start"), dto.streams) } + @Test fun emptySubscriptionsStillSerializeStreamsField() { + // Regression: clearing the LAST subscription sends an empty set. The backend + // validator requires `streams`, so it must be present as `[]`, not omitted. + // Uses the production Json config (no encodeDefaults) to prove the field is + // always emitted because the DTO field has no default. + val body = json.encodeToString(NotificationSubscriptionsDto(emptyList())) + assertEquals("""{"streams":[]}""", body) + } + @Test fun settingsPushBlockDecodes() { val dto = json.decodeFromString( """{"site_title":"Shard","brand":{"name":"Shard"},"push":{"ntfyUrl":"https://ntfy.shard.tld"}}""",