From 9c52a3dafa152ec67c70546386592fcb8a06805a Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 22 Jul 2026 11:36:32 -0500 Subject: [PATCH] fix(notifications): always serialize streams so clearing the last subscription saves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turning off the final notification subscription (going from one opted-in stream to zero) failed with "could not save" and the toggle stuck on. The backend's PUT /auth/me/notifications/subscriptions validator requires the `streams` field (body('streams').isArray()), but kotlinx.serialization omits a property equal to its default (encodeDefaults=false). NotificationSubscriptionsDto defaulted `streams` to emptyList(), so an empty set serialized to `{}` and the backend rejected it 400 "Validation failed". Any non-empty set included the field, so only the last toggle-off broke — regardless of which stream it was. Remove the default from NotificationSubscriptionsDto.streams so kotlinx always emits the field; an empty set now sends `{"streams":[]}` (200). The one call site already passes streams explicitly and the server always returns the field, so response decoding is unaffected. Add a regression test asserting the empty DTO serializes to `{"streams":[]}` under the production Json config. Verified on-device (AVD) against the live site and via the live API (`{}` -> 400, `{"streams":[]}` -> 200). Co-Authored-By: Claude --- .../runicgateway/app/data/api/dto/NotificationsDto.kt | 9 ++++++++- .../app/data/api/dto/NotificationsDtoTest.kt | 10 ++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) 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"}}""",