Test atomic JSON snapshots, failure diagnostics and subsequent recovery

This commit is contained in:
evgeny committed 2026-10-29 13:34:26 +01:00
1 parent 3eb7f8b0e6
commit a0c8d5490c
2 files changed
+46 -10

No files matched your search

@@ -214,7 +214,7 @@ fun cleanSocketName(raw: String): String =
raw.removePrefix("as_").replace(Regex("_v[46]_(tcp|udp)$"), " $1")
/** Stateless JNI/JSON adapter. Снимки применяются целиком: null означает отказ, пустой список — корректный пустой снимок. */
class ChatRepository {
class ChatRepository(private val reportFailure: (String) -> Unit = { LogManager.addLog("ERROR", "Repository", it) }) {
fun getChannels(): List<Channel>? {
val list = mutableListOf<Channel>()
try {
@@ -231,7 +231,7 @@ class ChatRepository {
))
}
} catch (e: Exception) {
LogManager.addLog("ERROR", "Repository", "Channel snapshot failed: $e"); return null
reportFailure("Channel snapshot failed: $e"); return null
}
return list
}
@@ -243,7 +243,7 @@ class ChatRepository {
val obj = arr.getJSONObject(i)
if (obj.getString("id") == channelId) return obj.optInt("peersOnline", 0)
}
} catch (e: Exception) { LogManager.addLog("ERROR", "Repository", "Peer count failed channel=$channelId: $e") }
} catch (e: Exception) { reportFailure("Peer count failed channel=$channelId: $e") }
return 0
}
@@ -369,7 +369,7 @@ class ChatRepository {
))
}
} catch (e: Exception) {
LogManager.addLog("ERROR", "Repository", "Message snapshot failed channel=$channelId anchor=$anchor: $e"); return null
reportFailure("Message snapshot failed channel=$channelId anchor=$anchor: $e"); return null
}
return list
}
@@ -419,7 +419,7 @@ class ChatRepository {
))
}
} catch (e: Exception) {
LogManager.addLog("ERROR", "Repository", "Member snapshot failed channel=$channelId: $e"); return emptyList()
reportFailure("Member snapshot failed channel=$channelId: $e"); return emptyList()
}
return list
}
@@ -457,7 +457,7 @@ class ChatRepository {
inviterId = if (obj.isNull("inviter_id")) null else obj.getLong("inviter_id"),
inviterName = obj.optString("inviter_name", "")
)
} catch (e: Exception) { LogManager.addLog("ERROR", "Repository", "Member detail failed channel=$channelId node=$nodeId: $e") }
} catch (e: Exception) { reportFailure("Member detail failed channel=$channelId node=$nodeId: $e") }
return null
}
@@ -505,7 +505,7 @@ class ChatRepository {
linksUp = obj.optInt("lu", 0),
links = links
)
} catch (e: Exception) { LogManager.addLog("ERROR", "Repository", "Member links failed node=$nodeId: $e") }
} catch (e: Exception) { reportFailure("Member links failed node=$nodeId: $e") }
return MemberLinks()
}
@@ -535,7 +535,7 @@ class ChatRepository {
}
list.sortWith(compareBy<LocalSocketState> { it.ifName }.thenBy { it.bindIp }.thenBy { it.protocol }.thenBy { it.port })
return list
} catch (e: Exception) { LogManager.addLog("ERROR", "Repository", "Socket snapshot parse failed: $e") }
} catch (e: Exception) { reportFailure("Socket snapshot parse failed: $e") }
return null
}
@@ -563,7 +563,7 @@ class ChatRepository {
))
}
} catch (e: Exception) {
LogManager.addLog("ERROR", "Repository", "PM conversation snapshot failed: $e"); return null
reportFailure("PM conversation snapshot failed: $e"); return null
}
return list
}
@@ -612,7 +612,7 @@ class ChatRepository {
))
}
} catch (e: Exception) {
LogManager.addLog("ERROR", "Repository", "PM message snapshot failed: $e"); return null
reportFailure("PM message snapshot failed: $e"); return null
}
return list
}
@@ -0,0 +1,36 @@
package com.utun.chat.data
import org.junit.Assert.*
import org.junit.Test
/** Повреждение второй строки не должно публиковать первый элемент как полный снимок. */
class ChatRepositorySnapshotTest {
private val errors = mutableListOf<String>()
private val repo = ChatRepository { errors.add(it) }
@Test fun conversationParseFailureDiffersFromAValidEmptySnapshotAndAllowsRetry() {
val row = """{"conv_id":"123","peer_node_id":4,"peer_name":"Узел 😀","generation":2}"""
assertNull(repo.parseDmConversations("[$row,{}]"))
assertTrue(errors.single().contains("PM conversation snapshot failed"))
assertEquals(emptyList<DmConversation>(), repo.parseDmConversations("[]"))
val restored = repo.parseDmConversations("[$row]")!!.single()
assertEquals("Узел 😀", restored.peerName); assertEquals(2L, restored.generation)
}
@Test fun invalidInviteCannotPublishAPartialMessageHistory() {
val row = """{"seq":1,"author":"4","data":"Текст 😀","ts":50}"""
assertNull(repo.parseDmMessages("[$row,{\"ct\":\"invite\",\"tags\":{}}]"))
assertTrue(errors.single().contains("PM message snapshot failed"))
assertEquals(emptyList<Message>(), repo.parseDmMessages("[]"))
assertEquals("Текст 😀", repo.parseDmMessages("[$row]")!!.single().text)
}
@Test fun missingRequiredNatFieldsDoNotClearOrTruncateTheSocketSnapshot() {
val row = """{"si":1,"nm":"as_eth_v4_udp","nmapping":1,"ninbound":1,"nsource":1,"external":"","externalPort":0}"""
val before = repo.parseLocalSockets("[$row]")!!
assertNull(repo.parseLocalSockets("[$row,{}]"))
assertTrue(errors.single().contains("Socket snapshot parse failed"))
assertEquals(emptyList<LocalSocketState>(), repo.parseLocalSockets("[]"))
assertEquals(before, repo.parseLocalSockets("[$row]"))
}
}