diff --git a/Sources/Realtime/RealtimeMessageV2.swift b/Sources/Realtime/RealtimeMessageV2.swift index ae111ef5..1be076ea 100644 --- a/Sources/Realtime/RealtimeMessageV2.swift +++ b/Sources/Realtime/RealtimeMessageV2.swift @@ -1,7 +1,13 @@ import Foundation +/// A message sent over the Realtime WebSocket connection. +/// +/// Both `joinRef` and `ref` are optional because certain messages like heartbeats +/// don't require a join reference as they don't refer to a specific channel. public struct RealtimeMessageV2: Hashable, Codable, Sendable { + /// Optional join reference. Nil for messages like heartbeats that don't belong to a specific channel. public let joinRef: String? + /// Optional message reference. Can be nil for certain message types. public let ref: String? public let topic: String public let event: String diff --git a/Tests/RealtimeTests/RealtimeMessageV2Tests.swift b/Tests/RealtimeTests/RealtimeMessageV2Tests.swift index 0df944a6..f95304e1 100644 --- a/Tests/RealtimeTests/RealtimeMessageV2Tests.swift +++ b/Tests/RealtimeTests/RealtimeMessageV2Tests.swift @@ -76,4 +76,80 @@ final class RealtimeMessageV2Tests: XCTestCase { joinRef: nil, ref: nil, topic: "topic", event: "unknown_event", payload: payloadWithNoStatus) XCTAssertNil(unknownEventMessage._eventType) } + + func testMessageWithNilRefs() throws { + let message = RealtimeMessageV2( + joinRef: nil, + ref: nil, + topic: "phoenix", + event: "heartbeat", + payload: [:] + ) + // Verify JSON encoding works + let encoded = try JSONEncoder().encode(message) + let decoded = try JSONDecoder().decode(RealtimeMessageV2.self, from: encoded) + XCTAssertNil(decoded.joinRef) + XCTAssertNil(decoded.ref) + XCTAssertEqual(decoded.topic, "phoenix") + XCTAssertEqual(decoded.event, "heartbeat") + } + + func testHeartbeatMessageWithNilJoinRef() throws { + let message = RealtimeMessageV2( + joinRef: nil, // Heartbeats don't have joinRef + ref: "123", + topic: "phoenix", + event: "heartbeat", + payload: [:] + ) + let encoded = try JSONEncoder().encode(message) + let decoded = try JSONDecoder().decode(RealtimeMessageV2.self, from: encoded) + XCTAssertNil(decoded.joinRef) + XCTAssertEqual(decoded.ref, "123") + } + + func testMessageJSONEncodingWithNilRefs() throws { + let message = RealtimeMessageV2( + joinRef: nil, + ref: nil, + topic: "test", + event: "custom", + payload: ["key": "value"] + ) + let encoded = try JSONEncoder().encode(message) + let jsonString = String(data: encoded, encoding: .utf8)! + // Verify nil values are encoded as null in JSON + XCTAssertTrue(jsonString.contains("\"join_ref\":null") || !jsonString.contains("join_ref")) + XCTAssertTrue(jsonString.contains("\"ref\":null") || !jsonString.contains("ref")) + } + + func testMessageWithBothRefAndJoinRef() throws { + let message = RealtimeMessageV2( + joinRef: "join-456", + ref: "ref-789", + topic: "room:lobby", + event: "join", + payload: ["user_id": "123"] + ) + let encoded = try JSONEncoder().encode(message) + let decoded = try JSONDecoder().decode(RealtimeMessageV2.self, from: encoded) + XCTAssertEqual(decoded.joinRef, "join-456") + XCTAssertEqual(decoded.ref, "ref-789") + XCTAssertEqual(decoded.topic, "room:lobby") + } + + func testMessageWithRefButNilJoinRef() throws { + let message = RealtimeMessageV2( + joinRef: nil, + ref: "ref-999", + topic: "room:lobby", + event: "leave", + payload: [:] + ) + let encoded = try JSONEncoder().encode(message) + let decoded = try JSONDecoder().decode(RealtimeMessageV2.self, from: encoded) + XCTAssertNil(decoded.joinRef) + XCTAssertEqual(decoded.ref, "ref-999") + XCTAssertEqual(decoded.topic, "room:lobby") + } }