diff --git a/Sources/Realtime/PushV2.swift b/Sources/Realtime/PushV2.swift index 66d4d49e..81e88e33 100644 --- a/Sources/Realtime/PushV2.swift +++ b/Sources/Realtime/PushV2.swift @@ -16,12 +16,12 @@ public enum PushStatus: String, Sendable { @MainActor final class PushV2 { - private weak var channel: RealtimeChannelV2? + private weak var channel: (any RealtimeChannelProtocol)? let message: RealtimeMessageV2 private var receivedContinuation: CheckedContinuation? - init(channel: RealtimeChannelV2?, message: RealtimeMessageV2) { + init(channel: (any RealtimeChannelProtocol)?, message: RealtimeMessageV2) { self.channel = channel self.message = message } diff --git a/Sources/Realtime/RealtimeChannelV2.swift b/Sources/Realtime/RealtimeChannelV2.swift index 9b6efa14..4acb163d 100644 --- a/Sources/Realtime/RealtimeChannelV2.swift +++ b/Sources/Realtime/RealtimeChannelV2.swift @@ -24,7 +24,15 @@ public struct RealtimeChannelConfig: Sendable { public var isPrivate: Bool } -public final class RealtimeChannelV2: Sendable { +protocol RealtimeChannelProtocol: AnyObject, Sendable { + @MainActor var config: RealtimeChannelConfig { get } + var topic: String { get } + var logger: (any SupabaseLogger)? { get } + + var socket: any RealtimeClientProtocol { get } +} + +public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol { struct MutableState { var clientChanges: [PostgresJoinConfig] = [] var joinRef: String? @@ -39,7 +47,7 @@ public final class RealtimeChannelV2: Sendable { @MainActor var config: RealtimeChannelConfig let logger: (any SupabaseLogger)? - let socket: RealtimeClientV2 + let socket: any RealtimeClientProtocol @MainActor var joinRef: String? { mutableState.joinRef } @@ -70,7 +78,7 @@ public final class RealtimeChannelV2: Sendable { init( topic: String, config: RealtimeChannelConfig, - socket: RealtimeClientV2, + socket: any RealtimeClientProtocol, logger: (any SupabaseLogger)? ) { self.topic = topic diff --git a/Sources/Realtime/RealtimeClientV2.swift b/Sources/Realtime/RealtimeClientV2.swift index af44bcd4..67c8c573 100644 --- a/Sources/Realtime/RealtimeClientV2.swift +++ b/Sources/Realtime/RealtimeClientV2.swift @@ -16,7 +16,20 @@ import Foundation typealias WebSocketTransport = @Sendable (_ url: URL, _ headers: [String: String]) async throws -> any WebSocket -public final class RealtimeClientV2: Sendable { +protocol RealtimeClientProtocol: AnyObject, Sendable { + var status: RealtimeClientStatus { get } + var options: RealtimeClientOptions { get } + var http: any HTTPClientType { get } + var broadcastURL: URL { get } + + func connect() async + func push(_ message: RealtimeMessageV2) + func _getAccessToken() async -> String? + func makeRef() -> String + func _remove(_ channel: any RealtimeChannelProtocol) +} + +public final class RealtimeClientV2: Sendable, RealtimeClientProtocol { struct MutableState { var accessToken: String? var ref = 0 @@ -320,7 +333,7 @@ public final class RealtimeClientV2: Sendable { } } - func _remove(_ channel: RealtimeChannelV2) { + func _remove(_ channel: any RealtimeChannelProtocol) { mutableState.withValue { $0.channels[channel.topic] = nil } diff --git a/Supabase.xcworkspace/xcshareddata/xcschemes/Functions.xcscheme b/Supabase.xcworkspace/xcshareddata/xcschemes/Functions.xcscheme index 08e60d28..222b2856 100644 --- a/Supabase.xcworkspace/xcshareddata/xcschemes/Functions.xcscheme +++ b/Supabase.xcworkspace/xcshareddata/xcschemes/Functions.xcscheme @@ -1,7 +1,7 @@ + version = "1.3"> diff --git a/Tests/RealtimeTests/ExportsTests.swift b/Tests/RealtimeTests/ExportsTests.swift new file mode 100644 index 00000000..677436cf --- /dev/null +++ b/Tests/RealtimeTests/ExportsTests.swift @@ -0,0 +1,31 @@ +// +// ExportsTests.swift +// Supabase +// +// Created by Guilherme Souza on 29/07/25. +// + +import XCTest + +@testable import Realtime + +final class ExportsTests: XCTestCase { + func testHelperImportIsAccessible() { + // Test that the Helpers module is properly exported + // This is a simple validation that the @_exported import works + + // Test that we can access JSONObject from Helpers via Realtime + let jsonObject: JSONObject = [:] + XCTAssertNotNil(jsonObject) + + // Test that we can access AnyJSON from Helpers via Realtime + let anyJSON: AnyJSON = .string("test") + XCTAssertEqual(anyJSON, .string("test")) + + // Test that we can access ObservationToken from Helpers via Realtime + let token = ObservationToken { + // Empty cleanup + } + XCTAssertNotNil(token) + } +} diff --git a/Tests/RealtimeTests/PostgresActionTests.swift b/Tests/RealtimeTests/PostgresActionTests.swift new file mode 100644 index 00000000..643f47b9 --- /dev/null +++ b/Tests/RealtimeTests/PostgresActionTests.swift @@ -0,0 +1,259 @@ +// +// PostgresActionTests.swift +// Supabase +// +// Created by Guilherme Souza on 29/07/25. +// + +import XCTest + +@testable import Realtime + +final class PostgresActionTests: XCTestCase { + private let sampleMessage = RealtimeMessageV2( + joinRef: nil, + ref: nil, + topic: "test:table", + event: "postgres_changes", + payload: [:] + ) + + private let sampleColumns = [ + Column(name: "id", type: "int8"), + Column(name: "name", type: "text"), + Column(name: "email", type: "text"), + ] + + private let sampleDate = Date(timeIntervalSince1970: 1_722_246_000) // Fixed timestamp for consistency + + func testColumnEquality() { + let column1 = Column(name: "id", type: "int8") + let column2 = Column(name: "id", type: "int8") + let column3 = Column(name: "email", type: "text") + + XCTAssertEqual(column1, column2) + XCTAssertNotEqual(column1, column3) + } + + func testInsertActionEventType() { + XCTAssertEqual(InsertAction.eventType, .insert) + } + + func testInsertActionProperties() { + let record: JSONObject = ["id": .string("123"), "name": .string("John")] + let insertAction = InsertAction( + columns: sampleColumns, + commitTimestamp: sampleDate, + record: record, + rawMessage: sampleMessage + ) + + XCTAssertEqual(insertAction.columns, sampleColumns) + XCTAssertEqual(insertAction.commitTimestamp, sampleDate) + XCTAssertEqual(insertAction.record, record) + XCTAssertEqual(insertAction.rawMessage.topic, "test:table") + } + + func testUpdateActionEventType() { + XCTAssertEqual(UpdateAction.eventType, .update) + } + + func testUpdateActionProperties() { + let record: JSONObject = ["id": .string("123"), "name": .string("John Updated")] + let oldRecord: JSONObject = ["id": .string("123"), "name": .string("John")] + + let updateAction = UpdateAction( + columns: sampleColumns, + commitTimestamp: sampleDate, + record: record, + oldRecord: oldRecord, + rawMessage: sampleMessage + ) + + XCTAssertEqual(updateAction.columns, sampleColumns) + XCTAssertEqual(updateAction.commitTimestamp, sampleDate) + XCTAssertEqual(updateAction.record, record) + XCTAssertEqual(updateAction.oldRecord, oldRecord) + XCTAssertEqual(updateAction.rawMessage.topic, "test:table") + } + + func testDeleteActionEventType() { + XCTAssertEqual(DeleteAction.eventType, .delete) + } + + func testDeleteActionProperties() { + let oldRecord: JSONObject = ["id": .string("123"), "name": .string("John")] + + let deleteAction = DeleteAction( + columns: sampleColumns, + commitTimestamp: sampleDate, + oldRecord: oldRecord, + rawMessage: sampleMessage + ) + + XCTAssertEqual(deleteAction.columns, sampleColumns) + XCTAssertEqual(deleteAction.commitTimestamp, sampleDate) + XCTAssertEqual(deleteAction.oldRecord, oldRecord) + XCTAssertEqual(deleteAction.rawMessage.topic, "test:table") + } + + func testAnyActionEventType() { + XCTAssertEqual(AnyAction.eventType, .all) + } + + func testAnyActionInsertCase() { + let record: JSONObject = ["id": .string("123"), "name": .string("John")] + let insertAction = InsertAction( + columns: sampleColumns, + commitTimestamp: sampleDate, + record: record, + rawMessage: sampleMessage + ) + + let anyAction = AnyAction.insert(insertAction) + XCTAssertEqual(anyAction.rawMessage.topic, "test:table") + + if case let .insert(wrappedAction) = anyAction { + XCTAssertEqual(wrappedAction.record, record) + } else { + XCTFail("Expected insert case") + } + } + + func testAnyActionUpdateCase() { + let record: JSONObject = ["id": .string("123"), "name": .string("John Updated")] + let oldRecord: JSONObject = ["id": .string("123"), "name": .string("John")] + + let updateAction = UpdateAction( + columns: sampleColumns, + commitTimestamp: sampleDate, + record: record, + oldRecord: oldRecord, + rawMessage: sampleMessage + ) + + let anyAction = AnyAction.update(updateAction) + XCTAssertEqual(anyAction.rawMessage.topic, "test:table") + + if case let .update(wrappedAction) = anyAction { + XCTAssertEqual(wrappedAction.record, record) + XCTAssertEqual(wrappedAction.oldRecord, oldRecord) + } else { + XCTFail("Expected update case") + } + } + + func testAnyActionDeleteCase() { + let oldRecord: JSONObject = ["id": .string("123"), "name": .string("John")] + + let deleteAction = DeleteAction( + columns: sampleColumns, + commitTimestamp: sampleDate, + oldRecord: oldRecord, + rawMessage: sampleMessage + ) + + let anyAction = AnyAction.delete(deleteAction) + XCTAssertEqual(anyAction.rawMessage.topic, "test:table") + + if case let .delete(wrappedAction) = anyAction { + XCTAssertEqual(wrappedAction.oldRecord, oldRecord) + } else { + XCTFail("Expected delete case") + } + } + + func testAnyActionEquality() { + let record: JSONObject = ["id": .string("123")] + let insertAction1 = InsertAction( + columns: sampleColumns, + commitTimestamp: sampleDate, + record: record, + rawMessage: sampleMessage + ) + let insertAction2 = InsertAction( + columns: sampleColumns, + commitTimestamp: sampleDate, + record: record, + rawMessage: sampleMessage + ) + + let anyAction1 = AnyAction.insert(insertAction1) + let anyAction2 = AnyAction.insert(insertAction2) + + XCTAssertEqual(anyAction1, anyAction2) + } + + func testDecodeRecord() throws { + struct TestRecord: Codable, Equatable { + let id: String + let name: String + let email: String? + } + + let record: JSONObject = [ + "id": .string("123"), + "name": .string("John"), + "email": .string("john@example.com"), + ] + + let insertAction = InsertAction( + columns: sampleColumns, + commitTimestamp: sampleDate, + record: record, + rawMessage: sampleMessage + ) + + let decoder = JSONDecoder() + let decodedRecord = try insertAction.decodeRecord(as: TestRecord.self, decoder: decoder) + + let expectedRecord = TestRecord(id: "123", name: "John", email: "john@example.com") + XCTAssertEqual(decodedRecord, expectedRecord) + } + + func testDecodeOldRecord() throws { + struct TestRecord: Codable, Equatable { + let id: String + let name: String + } + + let record: JSONObject = ["id": .string("123"), "name": .string("John Updated")] + let oldRecord: JSONObject = ["id": .string("123"), "name": .string("John")] + + let updateAction = UpdateAction( + columns: sampleColumns, + commitTimestamp: sampleDate, + record: record, + oldRecord: oldRecord, + rawMessage: sampleMessage + ) + + let decoder = JSONDecoder() + let decodedOldRecord = try updateAction.decodeOldRecord(as: TestRecord.self, decoder: decoder) + + let expectedOldRecord = TestRecord(id: "123", name: "John") + XCTAssertEqual(decodedOldRecord, expectedOldRecord) + } + + func testDecodeRecordError() { + struct TestRecord: Codable { + let id: Int // This will cause decode error since we're passing string + let name: String + } + + let record: JSONObject = [ + "id": .string("not-a-number"), // This should cause decoding to fail + "name": .string("John"), + ] + + let insertAction = InsertAction( + columns: sampleColumns, + commitTimestamp: sampleDate, + record: record, + rawMessage: sampleMessage + ) + + let decoder = JSONDecoder() + XCTAssertThrowsError(try insertAction.decodeRecord(as: TestRecord.self, decoder: decoder)) + } +} diff --git a/Tests/RealtimeTests/PresenceActionTests.swift b/Tests/RealtimeTests/PresenceActionTests.swift new file mode 100644 index 00000000..16b7e11d --- /dev/null +++ b/Tests/RealtimeTests/PresenceActionTests.swift @@ -0,0 +1,589 @@ +// +// PresenceActionTests.swift +// +// +// Created by Guilherme Souza on 29/07/25. +// + +import XCTest + +@testable import Realtime + +final class PresenceActionTests: XCTestCase { + + // MARK: - PresenceV2 Tests + + func testPresenceV2Initialization() { + let ref = "test_ref_123" + let state: JSONObject = [ + "user_id": .string("user_123"), + "username": .string("testuser"), + "status": .string("online") + ] + + let presence = PresenceV2(ref: ref, state: state) + + XCTAssertEqual(presence.ref, ref) + XCTAssertEqual(presence.state["user_id"]?.stringValue, "user_123") + XCTAssertEqual(presence.state["username"]?.stringValue, "testuser") + XCTAssertEqual(presence.state["status"]?.stringValue, "online") + } + + func testPresenceV2Hashable() { + let state: JSONObject = ["key": .string("value")] + let presence1 = PresenceV2(ref: "ref1", state: state) + let presence2 = PresenceV2(ref: "ref1", state: state) + let presence3 = PresenceV2(ref: "ref2", state: state) + + XCTAssertEqual(presence1, presence2) + XCTAssertNotEqual(presence1, presence3) + + let set = Set([presence1, presence2, presence3]) + XCTAssertEqual(set.count, 2) // presence1 and presence2 are equal + } + + // MARK: - PresenceV2 Codable Tests + + func testPresenceV2DecodingValidData() throws { + let jsonData = """ + { + "metas": [ + { + "phx_ref": "presence_ref_123", + "user_id": "user_456", + "username": "johndoe", + "status": "active", + "extra_field": "extra_value" + } + ] + } + """.data(using: .utf8)! + + let presence = try JSONDecoder().decode(PresenceV2.self, from: jsonData) + + XCTAssertEqual(presence.ref, "presence_ref_123") + XCTAssertEqual(presence.state["user_id"]?.stringValue, "user_456") + XCTAssertEqual(presence.state["username"]?.stringValue, "johndoe") + XCTAssertEqual(presence.state["status"]?.stringValue, "active") + XCTAssertEqual(presence.state["extra_field"]?.stringValue, "extra_value") + + // Ensure phx_ref is not in the state + XCTAssertNil(presence.state["phx_ref"]) + } + + func testPresenceV2DecodingWithMultipleMetas() throws { + // Should use the first meta object + let jsonData = """ + { + "metas": [ + { + "phx_ref": "first_ref", + "user_id": "first_user" + }, + { + "phx_ref": "second_ref", + "user_id": "second_user" + } + ] + } + """.data(using: .utf8)! + + let presence = try JSONDecoder().decode(PresenceV2.self, from: jsonData) + + XCTAssertEqual(presence.ref, "first_ref") + XCTAssertEqual(presence.state["user_id"]?.stringValue, "first_user") + } + + func testPresenceV2DecodingMissingMetas() { + let jsonData = """ + { + "other_field": "value" + } + """.data(using: .utf8)! + + XCTAssertThrowsError(try JSONDecoder().decode(PresenceV2.self, from: jsonData)) { error in + guard let decodingError = error as? DecodingError, + case .typeMismatch(let type, let context) = decodingError else { + XCTFail("Expected DecodingError.typeMismatch, got \(error)") + return + } + + XCTAssertTrue(type == JSONObject.self) + XCTAssertEqual(context.debugDescription, "A presence should at least have a phx_ref.") + } + } + + func testPresenceV2DecodingEmptyMetas() { + let jsonData = """ + { + "metas": [] + } + """.data(using: .utf8)! + + XCTAssertThrowsError(try JSONDecoder().decode(PresenceV2.self, from: jsonData)) { error in + guard let decodingError = error as? DecodingError, + case .typeMismatch(let type, let context) = decodingError else { + XCTFail("Expected DecodingError.typeMismatch, got \(error)") + return + } + + XCTAssertTrue(type == JSONObject.self) + XCTAssertEqual(context.debugDescription, "A presence should at least have a phx_ref.") + } + } + + func testPresenceV2DecodingMissingPhxRef() { + let jsonData = """ + { + "metas": [ + { + "user_id": "user_123", + "username": "testuser" + } + ] + } + """.data(using: .utf8)! + + XCTAssertThrowsError(try JSONDecoder().decode(PresenceV2.self, from: jsonData)) { error in + guard let decodingError = error as? DecodingError, + case .typeMismatch(let type, let context) = decodingError else { + XCTFail("Expected DecodingError.typeMismatch, got \(error)") + return + } + + XCTAssertTrue(type == String.self) + XCTAssertEqual(context.debugDescription, "A presence should at least have a phx_ref.") + } + } + + func testPresenceV2DecodingNonStringPhxRef() { + let jsonData = """ + { + "metas": [ + { + "phx_ref": 123, + "user_id": "user_123" + } + ] + } + """.data(using: .utf8)! + + XCTAssertThrowsError(try JSONDecoder().decode(PresenceV2.self, from: jsonData)) { error in + guard let decodingError = error as? DecodingError, + case .typeMismatch(let type, let context) = decodingError else { + XCTFail("Expected DecodingError.typeMismatch, got \(error)") + return + } + + XCTAssertTrue(type == String.self) + XCTAssertEqual(context.debugDescription, "A presence should at least have a phx_ref.") + } + } + + func testPresenceV2Encoding() throws { + let state: JSONObject = [ + "user_id": .string("user_789"), + "status": .string("online"), + "count": .integer(42) + ] + let presence = PresenceV2(ref: "test_ref", state: state) + + let encodedData = try JSONEncoder().encode(presence) + let decodedDict = try JSONSerialization.jsonObject(with: encodedData) as? [String: Any] + + XCTAssertNotNil(decodedDict) + XCTAssertEqual(decodedDict?["phx_ref"] as? String, "test_ref") + + let stateDict = decodedDict?["state"] as? [String: Any] + XCTAssertNotNil(stateDict) + XCTAssertEqual(stateDict?["user_id"] as? String, "user_789") + XCTAssertEqual(stateDict?["status"] as? String, "online") + XCTAssertEqual(stateDict?["count"] as? Int, 42) + } + + // MARK: - PresenceV2 decodeState Tests + + struct TestUser: Codable, Equatable { + let id: String + let name: String + let age: Int + } + + func testDecodeStateSuccess() throws { + let state: JSONObject = [ + "id": .string("user_123"), + "name": .string("John Doe"), + "age": .integer(30) + ] + let presence = PresenceV2(ref: "ref", state: state) + + let user = try presence.decodeState(as: TestUser.self) + + XCTAssertEqual(user.id, "user_123") + XCTAssertEqual(user.name, "John Doe") + XCTAssertEqual(user.age, 30) + } + + func testDecodeStateWithCustomDecoder() throws { + let customDecoder = JSONDecoder() + customDecoder.keyDecodingStrategy = .convertFromSnakeCase + + let state: JSONObject = [ + "user_id": .string("user_456"), + "user_name": .string("Jane Doe"), + "user_age": .integer(25) + ] + let presence = PresenceV2(ref: "ref", state: state) + + struct SnakeCaseUser: Codable, Equatable { + let userId: String + let userName: String + let userAge: Int + } + + let user = try presence.decodeState(as: SnakeCaseUser.self, decoder: customDecoder) + + XCTAssertEqual(user.userId, "user_456") + XCTAssertEqual(user.userName, "Jane Doe") + XCTAssertEqual(user.userAge, 25) + } + + func testDecodeStateFailure() { + let state: JSONObject = [ + "id": .string("user_123"), + "name": .string("John Doe") + // Missing required age field + ] + let presence = PresenceV2(ref: "ref", state: state) + + XCTAssertThrowsError(try presence.decodeState(as: TestUser.self)) + } + + // MARK: - PresenceAction Protocol Extension Tests + + struct MockPresenceAction: PresenceAction { + let joins: [String: PresenceV2] + let leaves: [String: PresenceV2] + let rawMessage: RealtimeMessageV2 + } + + func testDecodeJoinsWithIgnoreOtherTypes() throws { + let validState1: JSONObject = [ + "id": .string("user_1"), + "name": .string("Alice"), + "age": .integer(25) + ] + let validState2: JSONObject = [ + "id": .string("user_2"), + "name": .string("Bob"), + "age": .integer(30) + ] + let invalidState: JSONObject = [ + "id": .string("user_3"), + "name": .string("Charlie") + // Missing age field + ] + + let joins: [String: PresenceV2] = [ + "key1": PresenceV2(ref: "ref1", state: validState1), + "key2": PresenceV2(ref: "ref2", state: validState2), + "key3": PresenceV2(ref: "ref3", state: invalidState) + ] + + let rawMessage = RealtimeMessageV2( + joinRef: nil, ref: nil, topic: "test", event: "test", payload: [:] + ) + + let action = MockPresenceAction(joins: joins, leaves: [:], rawMessage: rawMessage) + + // With ignoreOtherTypes = true (default), should return only valid users + let users = try action.decodeJoins(as: TestUser.self) + XCTAssertEqual(users.count, 2) + XCTAssertTrue(users.contains(TestUser(id: "user_1", name: "Alice", age: 25))) + XCTAssertTrue(users.contains(TestUser(id: "user_2", name: "Bob", age: 30))) + } + + func testDecodeJoinsWithoutIgnoreOtherTypes() { + let validState: JSONObject = [ + "id": .string("user_1"), + "name": .string("Alice"), + "age": .integer(25) + ] + let invalidState: JSONObject = [ + "id": .string("user_2"), + "name": .string("Bob") + // Missing age field + ] + + let joins: [String: PresenceV2] = [ + "key1": PresenceV2(ref: "ref1", state: validState), + "key2": PresenceV2(ref: "ref2", state: invalidState) + ] + + let rawMessage = RealtimeMessageV2( + joinRef: nil, ref: nil, topic: "test", event: "test", payload: [:] + ) + + let action = MockPresenceAction(joins: joins, leaves: [:], rawMessage: rawMessage) + + // With ignoreOtherTypes = false, should throw on invalid data + XCTAssertThrowsError(try action.decodeJoins(as: TestUser.self, ignoreOtherTypes: false)) + } + + func testDecodeLeavesWithIgnoreOtherTypes() throws { + let validState1: JSONObject = [ + "id": .string("user_1"), + "name": .string("Alice"), + "age": .integer(25) + ] + let validState2: JSONObject = [ + "id": .string("user_2"), + "name": .string("Bob"), + "age": .integer(30) + ] + let invalidState: JSONObject = [ + "id": .string("user_3"), + "name": .string("Charlie") + // Missing age field + ] + + let leaves: [String: PresenceV2] = [ + "key1": PresenceV2(ref: "ref1", state: validState1), + "key2": PresenceV2(ref: "ref2", state: validState2), + "key3": PresenceV2(ref: "ref3", state: invalidState) + ] + + let rawMessage = RealtimeMessageV2( + joinRef: nil, ref: nil, topic: "test", event: "test", payload: [:] + ) + + let action = MockPresenceAction(joins: [:], leaves: leaves, rawMessage: rawMessage) + + // With ignoreOtherTypes = true (default), should return only valid users + let users = try action.decodeLeaves(as: TestUser.self) + XCTAssertEqual(users.count, 2) + XCTAssertTrue(users.contains(TestUser(id: "user_1", name: "Alice", age: 25))) + XCTAssertTrue(users.contains(TestUser(id: "user_2", name: "Bob", age: 30))) + } + + func testDecodeLeavesWithoutIgnoreOtherTypes() { + let validState: JSONObject = [ + "id": .string("user_1"), + "name": .string("Alice"), + "age": .integer(25) + ] + let invalidState: JSONObject = [ + "id": .string("user_2"), + "name": .string("Bob") + // Missing age field + ] + + let leaves: [String: PresenceV2] = [ + "key1": PresenceV2(ref: "ref1", state: validState), + "key2": PresenceV2(ref: "ref2", state: invalidState) + ] + + let rawMessage = RealtimeMessageV2( + joinRef: nil, ref: nil, topic: "test", event: "test", payload: [:] + ) + + let action = MockPresenceAction(joins: [:], leaves: leaves, rawMessage: rawMessage) + + // With ignoreOtherTypes = false, should throw on invalid data + XCTAssertThrowsError(try action.decodeLeaves(as: TestUser.self, ignoreOtherTypes: false)) + } + + func testDecodeJoinsWithCustomDecoder() throws { + let customDecoder = JSONDecoder() + customDecoder.keyDecodingStrategy = .convertFromSnakeCase + + let state: JSONObject = [ + "user_id": .string("user_123"), + "user_name": .string("Test User"), + "user_age": .integer(28) + ] + + let joins: [String: PresenceV2] = [ + "key1": PresenceV2(ref: "ref1", state: state) + ] + + let rawMessage = RealtimeMessageV2( + joinRef: nil, ref: nil, topic: "test", event: "test", payload: [:] + ) + + let action = MockPresenceAction(joins: joins, leaves: [:], rawMessage: rawMessage) + + struct SnakeCaseUser: Codable, Equatable { + let userId: String + let userName: String + let userAge: Int + } + + let users = try action.decodeJoins(as: SnakeCaseUser.self, decoder: customDecoder) + XCTAssertEqual(users.count, 1) + XCTAssertEqual(users.first?.userId, "user_123") + XCTAssertEqual(users.first?.userName, "Test User") + XCTAssertEqual(users.first?.userAge, 28) + } + + func testDecodeEmptyJoinsAndLeaves() throws { + let rawMessage = RealtimeMessageV2( + joinRef: nil, ref: nil, topic: "test", event: "test", payload: [:] + ) + + let action = MockPresenceAction(joins: [:], leaves: [:], rawMessage: rawMessage) + + let joinUsers = try action.decodeJoins(as: TestUser.self) + let leaveUsers = try action.decodeLeaves(as: TestUser.self) + + XCTAssertEqual(joinUsers.count, 0) + XCTAssertEqual(leaveUsers.count, 0) + } + + // MARK: - PresenceActionImpl Tests + + func testPresenceActionImplInitialization() { + let joins: [String: PresenceV2] = [ + "user1": PresenceV2(ref: "ref1", state: ["name": .string("User 1")]) + ] + let leaves: [String: PresenceV2] = [ + "user2": PresenceV2(ref: "ref2", state: ["name": .string("User 2")]) + ] + let rawMessage = RealtimeMessageV2( + joinRef: "join_ref", ref: "ref", topic: "topic", event: "event", payload: ["key": .string("value")] + ) + + let impl = PresenceActionImpl(joins: joins, leaves: leaves, rawMessage: rawMessage) + + XCTAssertEqual(impl.joins.count, 1) + XCTAssertEqual(impl.leaves.count, 1) + XCTAssertEqual(impl.joins["user1"]?.ref, "ref1") + XCTAssertEqual(impl.leaves["user2"]?.ref, "ref2") + XCTAssertEqual(impl.rawMessage.topic, "topic") + XCTAssertEqual(impl.rawMessage.event, "event") + } + + func testPresenceActionImplConformsToProtocol() { + let rawMessage = RealtimeMessageV2( + joinRef: nil, ref: nil, topic: "test", event: "test", payload: [:] + ) + + let impl = PresenceActionImpl(joins: [:], leaves: [:], rawMessage: rawMessage) + + // Test that it can be used as PresenceAction + let presenceAction: any PresenceAction = impl + XCTAssertEqual(presenceAction.joins.count, 0) + XCTAssertEqual(presenceAction.leaves.count, 0) + XCTAssertEqual(presenceAction.rawMessage.topic, "test") + } + + // MARK: - Edge Cases and Complex Scenarios + + func testPresenceV2WithComplexNestedState() throws { + let complexState: JSONObject = [ + "user": .object([ + "id": .string("123"), + "profile": .object([ + "name": .string("John"), + "preferences": .object([ + "theme": .string("dark"), + "notifications": .bool(true) + ]) + ]), + "tags": .array([.string("admin"), .string("developer")]) + ]), + "metadata": .object([ + "last_seen": .string("2024-01-01T00:00:00Z"), + "connection_count": .integer(3) + ]) + ] + + let presence = PresenceV2(ref: "complex_ref", state: complexState) + + XCTAssertEqual(presence.ref, "complex_ref") + XCTAssertEqual(presence.state["user"]?.objectValue?["id"]?.stringValue, "123") + XCTAssertEqual( + presence.state["user"]?.objectValue?["profile"]?.objectValue?["name"]?.stringValue, + "John" + ) + XCTAssertEqual( + presence.state["user"]?.objectValue?["profile"]?.objectValue?["preferences"]?.objectValue?["theme"]?.stringValue, + "dark" + ) + XCTAssertEqual(presence.state["user"]?.objectValue?["tags"]?.arrayValue?.count, 2) + XCTAssertEqual(presence.state["metadata"]?.objectValue?["connection_count"]?.intValue, 3) + } + + func testPresenceV2RoundTripCoding() throws { + let originalState: JSONObject = [ + "user_id": .string("user_789"), + "status": .string("online"), + "score": .double(98.5), + "active": .bool(true), + "tags": .array([.string("tag1"), .string("tag2")]), + "metadata": .object(["key": .string("value")]) + ] + let originalPresence = PresenceV2(ref: "original_ref", state: originalState) + + // Test that encoding works (we don't need the actual data for this test) + _ = try JSONEncoder().encode(originalPresence) + + // Create the expected server format manually by adding the state to metas with phx_ref + let stateWithRef = originalState.merging(["phx_ref": .string(originalPresence.ref)]) { _, new in new } + let serverFormat: [String: Any] = [ + "metas": [ + stateWithRef.mapValues(\.value) + ] + ] + + let serverData = try JSONSerialization.data(withJSONObject: serverFormat) + let decodedPresence = try JSONDecoder().decode(PresenceV2.self, from: serverData) + + XCTAssertEqual(decodedPresence.ref, originalPresence.ref) + XCTAssertEqual(decodedPresence.state["user_id"]?.stringValue, "user_789") + XCTAssertEqual(decodedPresence.state["status"]?.stringValue, "online") + XCTAssertEqual(decodedPresence.state["score"]?.doubleValue, 98.5) + XCTAssertEqual(decodedPresence.state["active"]?.boolValue, true) + XCTAssertEqual(decodedPresence.state["tags"]?.arrayValue?.count, 2) + XCTAssertNotNil(decodedPresence.state["metadata"]?.objectValue) + } + + func testPresenceActionWithMixedValidAndInvalidData() throws { + struct PartialUser: Codable { + let id: String + let name: String? + } + + let validState: JSONObject = [ + "id": .string("valid_user"), + "name": .string("Valid User") + ] + let partialState: JSONObject = [ + "id": .string("partial_user") + // name is optional, so this should still decode + ] + let invalidState: JSONObject = [ + "name": .string("No ID User") + // missing required id field + ] + + let joins: [String: PresenceV2] = [ + "valid": PresenceV2(ref: "ref1", state: validState), + "partial": PresenceV2(ref: "ref2", state: partialState), + "invalid": PresenceV2(ref: "ref3", state: invalidState) + ] + + let rawMessage = RealtimeMessageV2( + joinRef: nil, ref: nil, topic: "test", event: "test", payload: [:] + ) + + let action = MockPresenceAction(joins: joins, leaves: [:], rawMessage: rawMessage) + + // With ignoreOtherTypes = true, should get valid and partial users + let users = try action.decodeJoins(as: PartialUser.self, ignoreOtherTypes: true) + XCTAssertEqual(users.count, 2) + + let userIds = users.map(\.id).sorted() + XCTAssertEqual(userIds, ["partial_user", "valid_user"]) + } +} \ No newline at end of file diff --git a/Tests/RealtimeTests/PushV2Tests.swift b/Tests/RealtimeTests/PushV2Tests.swift new file mode 100644 index 00000000..040eb4fc --- /dev/null +++ b/Tests/RealtimeTests/PushV2Tests.swift @@ -0,0 +1,339 @@ +// +// PushV2Tests.swift +// Supabase +// +// Created by Guilherme Souza on 29/07/25. +// + +import ConcurrencyExtras +import XCTest + +@testable import Realtime + +final class PushV2Tests: XCTestCase { + + func testPushStatusValues() { + XCTAssertEqual(PushStatus.ok.rawValue, "ok") + XCTAssertEqual(PushStatus.error.rawValue, "error") + XCTAssertEqual(PushStatus.timeout.rawValue, "timeout") + } + + func testPushStatusFromRawValue() { + XCTAssertEqual(PushStatus(rawValue: "ok"), .ok) + XCTAssertEqual(PushStatus(rawValue: "error"), .error) + XCTAssertEqual(PushStatus(rawValue: "timeout"), .timeout) + XCTAssertNil(PushStatus(rawValue: "invalid")) + } + + @MainActor + func testPushV2InitializationWithNilChannel() { + let sampleMessage = RealtimeMessageV2( + joinRef: "ref1", + ref: "ref2", + topic: "test:channel", + event: "broadcast", + payload: ["data": "test"] + ) + + let push = PushV2(channel: nil, message: sampleMessage) + + XCTAssertEqual(push.message.topic, "test:channel") + XCTAssertEqual(push.message.event, "broadcast") + } + + @MainActor + func testSendWithNilChannelReturnsError() async { + let sampleMessage = RealtimeMessageV2( + joinRef: "ref1", + ref: "ref2", + topic: "test:channel", + event: "broadcast", + payload: ["data": "test"] + ) + + let push = PushV2(channel: nil, message: sampleMessage) + + let status = await push.send() + + XCTAssertEqual(status, .error) + } + + @MainActor + func testSendWithAckDisabledReturnsOkImmediately() async { + let mockSocket = MockRealtimeClient() + let config = RealtimeChannelConfig( + broadcast: BroadcastJoinConfig(acknowledgeBroadcasts: false, receiveOwnBroadcasts: false), + presence: PresenceJoinConfig(key: "", enabled: false), + isPrivate: false + ) + let mockChannel = MockRealtimeChannel( + topic: "test:channel", + config: config, + socket: mockSocket, + logger: nil + ) + + let sampleMessage = RealtimeMessageV2( + joinRef: "ref1", + ref: "ref2", + topic: "test:channel", + event: "broadcast", + payload: ["data": "test"] + ) + + let push = PushV2(channel: mockChannel, message: sampleMessage) + let status = await push.send() + + XCTAssertEqual(status, PushStatus.ok) + XCTAssertEqual(mockSocket.pushedMessages.count, 1) + XCTAssertEqual(mockSocket.pushedMessages.first?.topic, "test:channel") + XCTAssertEqual(mockSocket.pushedMessages.first?.event, "broadcast") + } + + @MainActor + func testSendWithAckEnabledWaitsForResponse() async { + let mockSocket = MockRealtimeClient() + let config = RealtimeChannelConfig( + broadcast: BroadcastJoinConfig(acknowledgeBroadcasts: true, receiveOwnBroadcasts: false), + presence: PresenceJoinConfig(key: "", enabled: false), + isPrivate: false + ) + let mockChannel = MockRealtimeChannel( + topic: "test:channel", + config: config, + socket: mockSocket, + logger: nil + ) + + let sampleMessage = RealtimeMessageV2( + joinRef: "ref1", + ref: "ref2", + topic: "test:channel", + event: "broadcast", + payload: ["data": "test"] + ) + + let push = PushV2(channel: mockChannel, message: sampleMessage) + + let sendTask = Task { + await push.send() + } + + // Give push time to start waiting + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + + // Simulate receiving acknowledgment + push.didReceive(status: PushStatus.ok) + + let status = await sendTask.value + XCTAssertEqual(status, PushStatus.ok) + XCTAssertEqual(mockSocket.pushedMessages.count, 1) + } + + @MainActor + func testChannelConfigurationForAcknowledgment() { + // Test that the channel configuration is properly checked for acknowledgment settings + let mockSocket = MockRealtimeClient() + + // Test acknowledgment disabled + let configAckDisabled = RealtimeChannelConfig( + broadcast: BroadcastJoinConfig(acknowledgeBroadcasts: false, receiveOwnBroadcasts: false), + presence: PresenceJoinConfig(key: "", enabled: false), + isPrivate: false + ) + let channelAckDisabled = MockRealtimeChannel( + topic: "test:channel", + config: configAckDisabled, + socket: mockSocket, + logger: nil + ) + XCTAssertFalse(channelAckDisabled.config.broadcast.acknowledgeBroadcasts) + + // Test acknowledgment enabled + let configAckEnabled = RealtimeChannelConfig( + broadcast: BroadcastJoinConfig(acknowledgeBroadcasts: true, receiveOwnBroadcasts: false), + presence: PresenceJoinConfig(key: "", enabled: false), + isPrivate: false + ) + let channelAckEnabled = MockRealtimeChannel( + topic: "test:channel", + config: configAckEnabled, + socket: mockSocket, + logger: nil + ) + XCTAssertTrue(channelAckEnabled.config.broadcast.acknowledgeBroadcasts) + } + + @MainActor + func testSendWithAckEnabledReceivesError() async { + let mockSocket = MockRealtimeClient() + let config = RealtimeChannelConfig( + broadcast: BroadcastJoinConfig(acknowledgeBroadcasts: true, receiveOwnBroadcasts: false), + presence: PresenceJoinConfig(key: "", enabled: false), + isPrivate: false + ) + let mockChannel = MockRealtimeChannel( + topic: "test:channel", + config: config, + socket: mockSocket, + logger: nil + ) + + let sampleMessage = RealtimeMessageV2( + joinRef: "ref1", + ref: "ref2", + topic: "test:channel", + event: "broadcast", + payload: ["data": "test"] + ) + + let push = PushV2(channel: mockChannel, message: sampleMessage) + + let sendTask = Task { + await push.send() + } + + // Give push time to start waiting + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + + // Simulate receiving error acknowledgment + push.didReceive(status: PushStatus.error) + + let status = await sendTask.value + XCTAssertEqual(status, PushStatus.error) + XCTAssertEqual(mockSocket.pushedMessages.count, 1) + } + + @MainActor + func testDidReceiveStatusWithoutWaitingDoesNothing() { + let sampleMessage = RealtimeMessageV2( + joinRef: "ref1", + ref: "ref2", + topic: "test:channel", + event: "broadcast", + payload: ["data": "test"] + ) + + let push = PushV2(channel: nil, message: sampleMessage) + + // This should not crash or cause issues + push.didReceive(status: PushStatus.ok) + push.didReceive(status: PushStatus.error) + push.didReceive(status: PushStatus.timeout) + } + + @MainActor + func testMultipleDidReceiveCallsOnlyFirstMatters() async { + let mockSocket = MockRealtimeClient() + let config = RealtimeChannelConfig( + broadcast: BroadcastJoinConfig(acknowledgeBroadcasts: true, receiveOwnBroadcasts: false), + presence: PresenceJoinConfig(key: "", enabled: false), + isPrivate: false + ) + let mockChannel = MockRealtimeChannel( + topic: "test:channel", + config: config, + socket: mockSocket, + logger: nil + ) + + let sampleMessage = RealtimeMessageV2( + joinRef: "ref1", + ref: "ref2", + topic: "test:channel", + event: "broadcast", + payload: ["data": "test"] + ) + + let push = PushV2(channel: mockChannel, message: sampleMessage) + + let sendTask = Task { + await push.send() + } + + // Give push time to start waiting + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + + // First response should be used + push.didReceive(status: PushStatus.ok) + + // Subsequent responses should be ignored + push.didReceive(status: PushStatus.error) + push.didReceive(status: PushStatus.timeout) + + let status = await sendTask.value + XCTAssertEqual(status, PushStatus.ok) // Should be .ok, not .error or .timeout + } +} + +// MARK: - Mock Objects + +@MainActor +private final class MockRealtimeChannel: RealtimeChannelProtocol { + let topic: String + var config: RealtimeChannelConfig + let socket: any RealtimeClientProtocol + let logger: (any SupabaseLogger)? + + init( + topic: String, + config: RealtimeChannelConfig, + socket: any RealtimeClientProtocol, + logger: (any SupabaseLogger)? + ) { + self.topic = topic + self.config = config + self.socket = socket + self.logger = logger + } +} + +private final class MockRealtimeClient: RealtimeClientProtocol, @unchecked Sendable { + private let _pushedMessages = LockIsolated<[RealtimeMessageV2]>([]) + private let _status = LockIsolated(.connected) + let options: RealtimeClientOptions + let http: any HTTPClientType = MockHTTPClient() + let broadcastURL = URL(string: "https://test.supabase.co/api/broadcast")! + + var status: RealtimeClientStatus { + _status.value + } + + init(timeoutInterval: TimeInterval = 10.0) { + self.options = RealtimeClientOptions( + timeoutInterval: timeoutInterval + ) + } + + var pushedMessages: [RealtimeMessageV2] { + _pushedMessages.value + } + + func connect() async { + _status.setValue(.connected) + } + + func push(_ message: RealtimeMessageV2) { + _pushedMessages.withValue { messages in + messages.append(message) + } + } + + func _getAccessToken() async -> String? { + return nil + } + + func makeRef() -> String { + return UUID().uuidString + } + + func _remove(_ channel: any RealtimeChannelProtocol) { + // No-op for mock + } +} + +private struct MockHTTPClient: HTTPClientType { + func send(_ request: HTTPRequest) async throws -> HTTPResponse { + return HTTPResponse(data: Data(), response: HTTPURLResponse()) + } +} diff --git a/Tests/RealtimeTests/RealtimeErrorTests.swift b/Tests/RealtimeTests/RealtimeErrorTests.swift new file mode 100644 index 00000000..d150912f --- /dev/null +++ b/Tests/RealtimeTests/RealtimeErrorTests.swift @@ -0,0 +1,52 @@ +// +// RealtimeErrorTests.swift +// Supabase +// +// Created by Guilherme Souza on 29/07/25. +// + +import XCTest + +@testable import Realtime + +final class RealtimeErrorTests: XCTestCase { + func testRealtimeErrorInitialization() { + let errorMessage = "Connection failed" + let error = RealtimeError(errorMessage) + + XCTAssertEqual(error.errorDescription, errorMessage) + } + + func testRealtimeErrorLocalizedDescription() { + let errorMessage = "Test error message" + let error = RealtimeError(errorMessage) + + // LocalizedError protocol provides localizedDescription + XCTAssertEqual(error.localizedDescription, errorMessage) + } + + func testRealtimeErrorWithEmptyMessage() { + let error = RealtimeError("") + XCTAssertEqual(error.errorDescription, "") + } + + func testRealtimeErrorAsError() { + let errorMessage = "Network timeout" + let realtimeError = RealtimeError(errorMessage) + let error: Error = realtimeError + + // Test that it can be used as a general Error + XCTAssertNotNil(error) + XCTAssertEqual(error.localizedDescription, errorMessage) + } + + func testRealtimeErrorEquality() { + let error1 = RealtimeError("Same message") + let error2 = RealtimeError("Same message") + let error3 = RealtimeError("Different message") + + // Since RealtimeError doesn't implement Equatable, we test the description + XCTAssertEqual(error1.errorDescription, error2.errorDescription) + XCTAssertNotEqual(error1.errorDescription, error3.errorDescription) + } +} diff --git a/Tests/RealtimeTests/RealtimeJoinConfigTests.swift b/Tests/RealtimeTests/RealtimeJoinConfigTests.swift new file mode 100644 index 00000000..50735799 --- /dev/null +++ b/Tests/RealtimeTests/RealtimeJoinConfigTests.swift @@ -0,0 +1,314 @@ +// +// RealtimeJoinConfigTests.swift +// Supabase +// +// Created by Guilherme Souza on 29/07/25. +// + +import XCTest + +@testable import Realtime + +final class RealtimeJoinConfigTests: XCTestCase { + + // MARK: - RealtimeJoinPayload Tests + + func testRealtimeJoinPayloadInit() { + let config = RealtimeJoinConfig() + let payload = RealtimeJoinPayload( + config: config, + accessToken: "token123", + version: "1.0" + ) + + XCTAssertEqual(payload.config, config) + XCTAssertEqual(payload.accessToken, "token123") + XCTAssertEqual(payload.version, "1.0") + } + + func testRealtimeJoinPayloadCodingKeys() throws { + let config = RealtimeJoinConfig() + let payload = RealtimeJoinPayload( + config: config, + accessToken: "token123", + version: "1.0" + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(payload) + + let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any] + XCTAssertNotNil(jsonObject?["config"]) + XCTAssertEqual(jsonObject?["access_token"] as? String, "token123") + XCTAssertEqual(jsonObject?["version"] as? String, "1.0") + } + + func testRealtimeJoinPayloadDecoding() throws { + let jsonData = """ + { + "config": { + "broadcast": {"ack": false, "self": false}, + "presence": {"key": "", "enabled": false}, + "postgres_changes": [], + "private": false + }, + "access_token": "token123", + "version": "1.0" + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + let payload = try decoder.decode(RealtimeJoinPayload.self, from: jsonData) + + XCTAssertEqual(payload.accessToken, "token123") + XCTAssertEqual(payload.version, "1.0") + XCTAssertFalse(payload.config.isPrivate) + } + + // MARK: - RealtimeJoinConfig Tests + + func testRealtimeJoinConfigDefaults() { + let config = RealtimeJoinConfig() + + XCTAssertFalse(config.broadcast.acknowledgeBroadcasts) + XCTAssertFalse(config.broadcast.receiveOwnBroadcasts) + XCTAssertEqual(config.presence.key, "") + XCTAssertFalse(config.presence.enabled) + XCTAssertTrue(config.postgresChanges.isEmpty) + XCTAssertFalse(config.isPrivate) + } + + func testRealtimeJoinConfigCustomValues() { + var config = RealtimeJoinConfig() + config.broadcast.acknowledgeBroadcasts = true + config.broadcast.receiveOwnBroadcasts = true + config.presence.key = "user123" + config.presence.enabled = true + config.isPrivate = true + config.postgresChanges = [ + PostgresJoinConfig(event: .insert, schema: "public", table: "users", filter: nil, id: 1) + ] + + XCTAssertTrue(config.broadcast.acknowledgeBroadcasts) + XCTAssertTrue(config.broadcast.receiveOwnBroadcasts) + XCTAssertEqual(config.presence.key, "user123") + XCTAssertTrue(config.presence.enabled) + XCTAssertTrue(config.isPrivate) + XCTAssertEqual(config.postgresChanges.count, 1) + } + + func testRealtimeJoinConfigEquality() { + let config1 = RealtimeJoinConfig() + var config2 = RealtimeJoinConfig() + config2.isPrivate = false + + XCTAssertEqual(config1, config2) + + config2.isPrivate = true + XCTAssertNotEqual(config1, config2) + } + + func testRealtimeJoinConfigHashable() { + let config1 = RealtimeJoinConfig() + var config2 = RealtimeJoinConfig() + config2.isPrivate = false + + XCTAssertEqual(config1.hashValue, config2.hashValue) + + config2.isPrivate = true + XCTAssertNotEqual(config1.hashValue, config2.hashValue) + } + + func testRealtimeJoinConfigCodingKeys() throws { + var config = RealtimeJoinConfig() + config.isPrivate = true + config.postgresChanges = [ + PostgresJoinConfig(event: .insert, schema: "public", table: "users", filter: nil, id: 1) + ] + + let encoder = JSONEncoder() + let data = try encoder.encode(config) + + let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any] + XCTAssertNotNil(jsonObject?["broadcast"]) + XCTAssertNotNil(jsonObject?["presence"]) + XCTAssertNotNil(jsonObject?["postgres_changes"]) + XCTAssertEqual(jsonObject?["private"] as? Bool, true) + } + + // MARK: - BroadcastJoinConfig Tests + + func testBroadcastJoinConfigDefaults() { + let config = BroadcastJoinConfig() + + XCTAssertFalse(config.acknowledgeBroadcasts) + XCTAssertFalse(config.receiveOwnBroadcasts) + } + + func testBroadcastJoinConfigCustomValues() { + let config = BroadcastJoinConfig( + acknowledgeBroadcasts: true, + receiveOwnBroadcasts: true + ) + + XCTAssertTrue(config.acknowledgeBroadcasts) + XCTAssertTrue(config.receiveOwnBroadcasts) + } + + func testBroadcastJoinConfigCodingKeys() throws { + let config = BroadcastJoinConfig( + acknowledgeBroadcasts: true, + receiveOwnBroadcasts: true + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(config) + + let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any] + XCTAssertEqual(jsonObject?["ack"] as? Bool, true) + XCTAssertEqual(jsonObject?["self"] as? Bool, true) + } + + func testBroadcastJoinConfigDecoding() throws { + let jsonData = """ + { + "ack": true, + "self": false + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + let config = try decoder.decode(BroadcastJoinConfig.self, from: jsonData) + + XCTAssertTrue(config.acknowledgeBroadcasts) + XCTAssertFalse(config.receiveOwnBroadcasts) + } + + // MARK: - PresenceJoinConfig Tests + + func testPresenceJoinConfigDefaults() { + let config = PresenceJoinConfig() + + XCTAssertEqual(config.key, "") + XCTAssertFalse(config.enabled) + } + + func testPresenceJoinConfigCustomValues() { + var config = PresenceJoinConfig() + config.key = "user123" + config.enabled = true + + XCTAssertEqual(config.key, "user123") + XCTAssertTrue(config.enabled) + } + + func testPresenceJoinConfigCodable() throws { + var config = PresenceJoinConfig() + config.key = "user123" + config.enabled = true + + let encoder = JSONEncoder() + let data = try encoder.encode(config) + + let decoder = JSONDecoder() + let decodedConfig = try decoder.decode(PresenceJoinConfig.self, from: data) + + XCTAssertEqual(decodedConfig.key, "user123") + XCTAssertTrue(decodedConfig.enabled) + } + + // MARK: - PostgresChangeEvent Tests + + func testPostgresChangeEventRawValues() { + XCTAssertEqual(PostgresChangeEvent.insert.rawValue, "INSERT") + XCTAssertEqual(PostgresChangeEvent.update.rawValue, "UPDATE") + XCTAssertEqual(PostgresChangeEvent.delete.rawValue, "DELETE") + XCTAssertEqual(PostgresChangeEvent.all.rawValue, "*") + } + + func testPostgresChangeEventFromRawValue() { + XCTAssertEqual(PostgresChangeEvent(rawValue: "INSERT"), .insert) + XCTAssertEqual(PostgresChangeEvent(rawValue: "UPDATE"), .update) + XCTAssertEqual(PostgresChangeEvent(rawValue: "DELETE"), .delete) + XCTAssertEqual(PostgresChangeEvent(rawValue: "*"), .all) + XCTAssertNil(PostgresChangeEvent(rawValue: "INVALID")) + } + + func testPostgresChangeEventCodable() throws { + let events: [PostgresChangeEvent] = [.insert, .update, .delete, .all] + + let encoder = JSONEncoder() + let data = try encoder.encode(events) + + let decoder = JSONDecoder() + let decodedEvents = try decoder.decode([PostgresChangeEvent].self, from: data) + + XCTAssertEqual(decodedEvents, events) + } + + // MARK: - PostgresJoinConfig Additional Tests + + func testPostgresJoinConfigDefaults() { + let config = PostgresJoinConfig( + event: .insert, schema: "public", table: "users", filter: nil, id: 0) + + XCTAssertEqual(config.event, .insert) + XCTAssertEqual(config.schema, "public") + XCTAssertEqual(config.table, "users") + XCTAssertNil(config.filter) + XCTAssertEqual(config.id, 0) + } + + func testPostgresJoinConfigCustomEncoding() throws { + let config = PostgresJoinConfig( + event: .insert, + schema: "public", + table: "users", + filter: "id=1", + id: 123 + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(config) + + let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any] + XCTAssertEqual(jsonObject?["event"] as? String, "INSERT") + XCTAssertEqual(jsonObject?["schema"] as? String, "public") + XCTAssertEqual(jsonObject?["table"] as? String, "users") + XCTAssertEqual(jsonObject?["filter"] as? String, "id=1") + XCTAssertEqual(jsonObject?["id"] as? Int, 123) + } + + func testPostgresJoinConfigEncodingWithZeroId() throws { + let config = PostgresJoinConfig( + event: .insert, + schema: "public", + table: "users", + filter: nil, + id: 0 + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(config) + + let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any] + XCTAssertNil(jsonObject?["id"]) // Should not encode id when it's 0 + } + + func testPostgresJoinConfigEncodingWithNilValues() throws { + let config = PostgresJoinConfig( + event: .insert, + schema: "public", + table: nil, + filter: nil, + id: 0 + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(config) + + let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any] + XCTAssertNil(jsonObject?["table"]) // Should not encode nil table + XCTAssertNil(jsonObject?["filter"]) // Should not encode nil filter + } +} diff --git a/Tests/RealtimeTests/WebSocketTests.swift b/Tests/RealtimeTests/WebSocketTests.swift new file mode 100644 index 00000000..26027f53 --- /dev/null +++ b/Tests/RealtimeTests/WebSocketTests.swift @@ -0,0 +1,98 @@ +// +// WebSocketTests.swift +// Supabase +// +// Created by Guilherme Souza on 29/07/25. +// + +import ConcurrencyExtras +import XCTest + +@testable import Realtime + +final class WebSocketTests: XCTestCase { + + // MARK: - WebSocketEvent Tests + + func testWebSocketEventEquality() { + let textEvent1 = WebSocketEvent.text("hello") + let textEvent2 = WebSocketEvent.text("hello") + let textEvent3 = WebSocketEvent.text("world") + + XCTAssertEqual(textEvent1, textEvent2) + XCTAssertNotEqual(textEvent1, textEvent3) + + let binaryData = Data([1, 2, 3]) + let binaryEvent1 = WebSocketEvent.binary(binaryData) + let binaryEvent2 = WebSocketEvent.binary(binaryData) + let binaryEvent3 = WebSocketEvent.binary(Data([4, 5, 6])) + + XCTAssertEqual(binaryEvent1, binaryEvent2) + XCTAssertNotEqual(binaryEvent1, binaryEvent3) + + let closeEvent1 = WebSocketEvent.close(code: 1000, reason: "normal") + let closeEvent2 = WebSocketEvent.close(code: 1000, reason: "normal") + let closeEvent3 = WebSocketEvent.close(code: 1001, reason: "going away") + + XCTAssertEqual(closeEvent1, closeEvent2) + XCTAssertNotEqual(closeEvent1, closeEvent3) + } + + func testWebSocketEventHashable() { + let textEvent = WebSocketEvent.text("hello") + let binaryEvent = WebSocketEvent.binary(Data([1, 2, 3])) + let closeEvent = WebSocketEvent.close(code: 1000, reason: "normal") + + let events: Set = [textEvent, binaryEvent, closeEvent] + XCTAssertEqual(events.count, 3) + } + + func testWebSocketEventPatternMatching() { + let textEvent = WebSocketEvent.text("hello world") + let binaryEvent = WebSocketEvent.binary(Data([1, 2, 3])) + let closeEvent = WebSocketEvent.close(code: 1000, reason: "normal") + + switch textEvent { + case .text(let message): + XCTAssertEqual(message, "hello world") + default: + XCTFail("Expected text event") + } + + switch binaryEvent { + case .binary(let data): + XCTAssertEqual(data, Data([1, 2, 3])) + default: + XCTFail("Expected binary event") + } + + switch closeEvent { + case .close(let code, let reason): + XCTAssertEqual(code, 1000) + XCTAssertEqual(reason, "normal") + default: + XCTFail("Expected close event") + } + } + + // MARK: - WebSocketError Tests + + func testWebSocketErrorConnection() { + let underlyingError = NSError( + domain: "TestDomain", code: 123, userInfo: [NSLocalizedDescriptionKey: "Test error"]) + let webSocketError = WebSocketError.connection( + message: "Connection failed", error: underlyingError) + + XCTAssertEqual(webSocketError.errorDescription, "Connection failed Test error") + } + + func testWebSocketErrorAsError() { + let underlyingError = NSError( + domain: "TestDomain", code: 123, userInfo: [NSLocalizedDescriptionKey: "Test error"]) + let webSocketError = WebSocketError.connection( + message: "Connection failed", error: underlyingError) + let error: Error = webSocketError + + XCTAssertEqual(error.localizedDescription, "Connection failed Test error") + } +}