From 6499540dabb75e1252664d945ff9e41a74792b5c Mon Sep 17 00:00:00 2001 From: jguz-pubnub Date: Mon, 5 Oct 2026 18:08:08 +0200 Subject: [PATCH 1/5] Report DataSync object type on subscribe events Subscribe events now surface the DataSync object type from `metadata.type`, and unsupported DataSync envelopes are dropped instead of degrading to a message. - Add `PubNubDataSyncEntityKind` and `PubNubDataSyncRelationshipKind` - DataSync cases carry event structs exposing `kind` alongside the payload - Rename `PubNubDataSyncRemovedObject` to `PubNubDataSyncRemovedEntity` - Make `asPubNubEvent()` optional and drop undecodable DataSync envelopes --- Snippets/ListenersOld/listeners-old.swift | 36 ++-- Snippets/SubscribeNew/subscriptions.swift | 36 ++-- .../KMP/Wrappers/KMPDataSyncEvent.swift | 88 ++++++---- .../SubscribeDataSyncPayload.swift | 69 ++++++-- .../Subscribe/API/PubNubDataSyncEvent.swift | 78 +++++++-- .../PubNub/Subscribe/API/Subscription.swift | 7 +- .../Subscribe/Core/SubscriptionSession.swift | 2 +- .../Subscribe/Legacy/SubscriptionStream.swift | 2 +- .../SubscribeMessagePayload+PubNubEvent.swift | 9 +- ...DataSyncChannelEventIntegrationTests.swift | 15 +- ...taSyncEntityEndpointIntegrationTests.swift | 2 +- .../DataSyncEntityEventIntegrationTests.swift | 29 +++- ...aSyncMembershipEventIntegrationTests.swift | 16 +- ...RelationshipEndpointIntegrationTests.swift | 2 +- .../DataSyncUserEventIntegrationTests.swift | 15 +- .../SubscribeRouterTests+DataSync.swift | 158 +++++++++++++----- .../Routers/SubscribeRouterTests.swift | 2 +- 17 files changed, 412 insertions(+), 154 deletions(-) diff --git a/Snippets/ListenersOld/listeners-old.swift b/Snippets/ListenersOld/listeners-old.swift index eb311ff7..ed4bbc88 100644 --- a/Snippets/ListenersOld/listeners-old.swift +++ b/Snippets/ListenersOld/listeners-old.swift @@ -135,18 +135,30 @@ listener.didReceiveBatchSubscription = { events in case .dataSyncChanged(let dataSyncEvent): switch dataSyncEvent { - case let .entityCreated(entity): - print("Data Sync entity created: \(entity.id)") - case let .entityUpdated(entity): - print("Data Sync entity updated: \(entity.id)") - case let .entityDeleted(entity): - print("Data Sync entity deleted: \(entity.id)") - case let .relationshipCreated(relationship): - print("Data Sync relationship created: \(relationship.id)") - case let .relationshipUpdated(relationship): - print("Data Sync relationship updated: \(relationship.id)") - case let .relationshipDeleted(relationship): - print("Data Sync relationship deleted: \(relationship.id)") + case let .entityCreated(entityEvent): + let entity = entityEvent.entity + print("Entity \(entity.id) was created.") + print("Entity kind: \(entityEvent.kind)") + case let .entityUpdated(entityEvent): + let entity = entityEvent.entity + print("Entity \(entity.id) was updated.") + print("Entity kind: \(entityEvent.kind)") + case let .entityDeleted(entityEvent): + let removedEntity = entityEvent.removed + print("Entity \(removedEntity.id) was deleted.") + print("Entity kind: \(entityEvent.kind)") + case let .relationshipCreated(relationshipEvent): + let relationship = relationshipEvent.relationship + print("Relationship \(relationship.id) was created.") + print("Relationship kind: \(relationshipEvent.kind)") + case let .relationshipUpdated(relationshipEvent): + let relationship = relationshipEvent.relationship + print("Relationship \(relationship.id) was updated.") + print("Relationship kind: \(relationshipEvent.kind)") + case let .relationshipDeleted(relationshipEvent): + let removedRelationship = relationshipEvent.removed + print("Relationship \(removedRelationship.id) was deleted.") + print("Relationship kind: \(relationshipEvent.kind)") } } } diff --git a/Snippets/SubscribeNew/subscriptions.swift b/Snippets/SubscribeNew/subscriptions.swift index c8e56ac5..7cba2517 100644 --- a/Snippets/SubscribeNew/subscriptions.swift +++ b/Snippets/SubscribeNew/subscriptions.swift @@ -202,18 +202,30 @@ subscription.onAppContext = { appContextEvent in // Add a listener to receive Data Sync entity and relationship events dataSyncSubscription.onDataSync = { dataSyncEvent in switch dataSyncEvent { - case let .entityCreated(entity): - print("Data Sync entity created: \(entity.id)") - case let .entityUpdated(entity): - print("Data Sync entity updated: \(entity.id)") - case let .entityDeleted(entity): - print("Data Sync entity deleted: \(entity.id)") - case let .relationshipCreated(relationship): - print("Data Sync relationship created: \(relationship.id)") - case let .relationshipUpdated(relationship): - print("Data Sync relationship updated: \(relationship.id)") - case let .relationshipDeleted(relationship): - print("Data Sync relationship deleted: \(relationship.id)") + case let .entityCreated(entityEvent): + let entity = entityEvent.entity + print("Entity \(entity.id) was created.") + print("Entity kind: \(entityEvent.kind)") + case let .entityUpdated(entityEvent): + let entity = entityEvent.entity + print("Entity \(entity.id) was updated.") + print("Entity kind: \(entityEvent.kind)") + case let .entityDeleted(entityEvent): + let removedEntity = entityEvent.removed + print("Entity \(removedEntity.id) was deleted.") + print("Entity kind: \(entityEvent.kind)") + case let .relationshipCreated(relationshipEvent): + let relationship = relationshipEvent.relationship + print("Relationship \(relationship.id) was created.") + print("Relationship kind: \(relationshipEvent.kind)") + case let .relationshipUpdated(relationshipEvent): + let relationship = relationshipEvent.relationship + print("Relationship \(relationship.id) was updated.") + print("Relationship kind: \(relationshipEvent.kind)") + case let .relationshipDeleted(relationshipEvent): + let removedRelationship = relationshipEvent.removed + print("Relationship \(removedRelationship.id) was deleted.") + print("Relationship kind: \(relationshipEvent.kind)") } } // snippet.end diff --git a/Sources/PubNub/KMP/Wrappers/KMPDataSyncEvent.swift b/Sources/PubNub/KMP/Wrappers/KMPDataSyncEvent.swift index 7f37d9f4..698a7d24 100644 --- a/Sources/PubNub/KMP/Wrappers/KMPDataSyncEvent.swift +++ b/Sources/PubNub/KMP/Wrappers/KMPDataSyncEvent.swift @@ -32,10 +32,13 @@ public class KMPDataSyncEvent: NSObject { @objc public class KMPDataSyncEntityCreatedResult: KMPDataSyncEvent { + @objc public let kind: String @objc public let entity: KMPDataSyncEntity - init(entity: KMPDataSyncEntity) { - self.entity = entity + init(entityEvent: PubNubDataSyncEntityEvent) { + self.kind = entityEvent.kind.rawValue + self.entity = KMPDataSyncEntity(entity: entityEvent.entity) + super.init(event: "entityCreated") } } @@ -44,10 +47,13 @@ public class KMPDataSyncEntityCreatedResult: KMPDataSyncEvent { @objc public class KMPDataSyncEntityUpdatedResult: KMPDataSyncEvent { + @objc public let kind: String @objc public let entity: KMPDataSyncEntity - init(entity: KMPDataSyncEntity) { - self.entity = entity + init(entityEvent: PubNubDataSyncEntityEvent) { + self.kind = entityEvent.kind.rawValue + self.entity = KMPDataSyncEntity(entity: entityEvent.entity) + super.init(event: "entityUpdated") } } @@ -56,10 +62,13 @@ public class KMPDataSyncEntityUpdatedResult: KMPDataSyncEvent { @objc public class KMPDataSyncEntityDeletedResult: KMPDataSyncEvent { - @objc public let removedObject: KMPDataSyncRemovedObject + @objc public let kind: String + @objc public let removedEntity: KMPDataSyncRemovedEntity + + init(entityEvent: PubNubDataSyncEntityDeletedEvent) { + self.kind = entityEvent.kind.rawValue + self.removedEntity = KMPDataSyncRemovedEntity(removedEntity: entityEvent.removed) - init(removedObject: KMPDataSyncRemovedObject) { - self.removedObject = removedObject super.init(event: "entityDeleted") } } @@ -68,10 +77,13 @@ public class KMPDataSyncEntityDeletedResult: KMPDataSyncEvent { @objc public class KMPDataSyncRelationshipCreatedResult: KMPDataSyncEvent { + @objc public let kind: String @objc public let relationship: KMPDataSyncRelationship - init(relationship: KMPDataSyncRelationship) { - self.relationship = relationship + init(relationshipEvent: PubNubDataSyncRelationshipEvent) { + self.kind = relationshipEvent.kind.rawValue + self.relationship = KMPDataSyncRelationship(relationship: relationshipEvent.relationship) + super.init(event: "relationshipCreated") } } @@ -80,10 +92,13 @@ public class KMPDataSyncRelationshipCreatedResult: KMPDataSyncEvent { @objc public class KMPDataSyncRelationshipUpdatedResult: KMPDataSyncEvent { + @objc public let kind: String @objc public let relationship: KMPDataSyncRelationship - init(relationship: KMPDataSyncRelationship) { - self.relationship = relationship + init(relationshipEvent: PubNubDataSyncRelationshipEvent) { + self.kind = relationshipEvent.kind.rawValue + self.relationship = KMPDataSyncRelationship(relationship: relationshipEvent.relationship) + super.init(event: "relationshipUpdated") } } @@ -92,18 +107,21 @@ public class KMPDataSyncRelationshipUpdatedResult: KMPDataSyncEvent { @objc public class KMPDataSyncRelationshipDeletedResult: KMPDataSyncEvent { - @objc public let removedObject: KMPDataSyncRemovedRelationship + @objc public let kind: String + @objc public let removedRelationship: KMPDataSyncRemovedRelationship + + init(relationshipEvent: PubNubDataSyncRelationshipDeletedEvent) { + self.kind = relationshipEvent.kind.rawValue + self.removedRelationship = KMPDataSyncRemovedRelationship(removedRelationship: relationshipEvent.removed) - init(removedObject: KMPDataSyncRemovedRelationship) { - self.removedObject = removedObject super.init(event: "relationshipDeleted") } } -// MARK: - KMPDataSyncRemovedObject +// MARK: - KMPDataSyncRemovedEntity @objc -public class KMPDataSyncRemovedObject: NSObject { +public class KMPDataSyncRemovedEntity: NSObject { @objc public let id: String // Named `objectClass` rather than `className` because `NSObject` already exposes a `className` selector @objc public let objectClass: String @@ -111,12 +129,12 @@ public class KMPDataSyncRemovedObject: NSObject { @objc public let classVersion: Int @objc public let deletedAt: Date - init(removedObject: PubNubDataSyncRemovedObject) { - self.id = removedObject.id - self.objectClass = removedObject.className - self.classLevel = removedObject.classLevel.stringValue - self.classVersion = removedObject.classVersion - self.deletedAt = removedObject.deletedAt + init(removedEntity: PubNubDataSyncRemovedEntity) { + self.id = removedEntity.id + self.objectClass = removedEntity.className + self.classLevel = removedEntity.classLevel.stringValue + self.classVersion = removedEntity.classVersion + self.deletedAt = removedEntity.deletedAt } } @@ -143,20 +161,18 @@ public class KMPDataSyncRemovedRelationship: NSObject { extension KMPDataSyncEvent { static func from(event: PubNubDataSyncEvent) -> KMPDataSyncEvent { switch event { - case .entityCreated(let entity): - return KMPDataSyncEntityCreatedResult(entity: KMPDataSyncEntity(entity: entity)) - case .entityUpdated(let entity): - return KMPDataSyncEntityUpdatedResult(entity: KMPDataSyncEntity(entity: entity)) - case .entityDeleted(let removedObject): - return KMPDataSyncEntityDeletedResult(removedObject: KMPDataSyncRemovedObject(removedObject: removedObject)) - case .relationshipCreated(let relationship): - return KMPDataSyncRelationshipCreatedResult(relationship: KMPDataSyncRelationship(relationship: relationship)) - case .relationshipUpdated(let relationship): - return KMPDataSyncRelationshipUpdatedResult(relationship: KMPDataSyncRelationship(relationship: relationship)) - case .relationshipDeleted(let removedRelationship): - return KMPDataSyncRelationshipDeletedResult( - removedObject: KMPDataSyncRemovedRelationship(removedRelationship: removedRelationship) - ) + case .entityCreated(let entityEvent): + return KMPDataSyncEntityCreatedResult(entityEvent: entityEvent) + case .entityUpdated(let entityEvent): + return KMPDataSyncEntityUpdatedResult(entityEvent: entityEvent) + case .entityDeleted(let entityEvent): + return KMPDataSyncEntityDeletedResult(entityEvent: entityEvent) + case .relationshipCreated(let relationshipEvent): + return KMPDataSyncRelationshipCreatedResult(relationshipEvent: relationshipEvent) + case .relationshipUpdated(let relationshipEvent): + return KMPDataSyncRelationshipUpdatedResult(relationshipEvent: relationshipEvent) + case .relationshipDeleted(let relationshipEvent): + return KMPDataSyncRelationshipDeletedResult(relationshipEvent: relationshipEvent) } } } diff --git a/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift b/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift index 867b8612..fd892bee 100644 --- a/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift +++ b/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift @@ -91,7 +91,13 @@ extension SubscribeDataSyncPayload: Decodable { status: try data.decodeIfPresent(String.self, forKey: .status), payload: try data.decodeIfPresent(AnyJSON.self, forKey: .payload) ) - event = action == .create ? .entityCreated(entity) : .entityUpdated(entity) + let entityEvent = PubNubDataSyncEntityEvent( + kind: type.entityKind, + entity: entity + ) + + event = action == .create ? .entityCreated(entityEvent) : .entityUpdated(entityEvent) + case (.relationship, .create), (.relationship, .update), (.membership, .create), (.membership, .update): // A membership frames its two sides as `channelId` and `userId` let entityAKey: DataCodingKeys = type == .membership ? .channelId : .entityAId @@ -110,31 +116,72 @@ extension SubscribeDataSyncPayload: Decodable { status: try data.decodeIfPresent(String.self, forKey: .status), payload: try data.decodeIfPresent(AnyJSON.self, forKey: .payload) ) - event = action == .create ? .relationshipCreated(relationship) : .relationshipUpdated(relationship) + let relationshipEvent = PubNubDataSyncRelationshipEvent( + kind: type.relationshipKind, + relationship: relationship + ) + + event = action == .create ? .relationshipCreated(relationshipEvent) : .relationshipUpdated(relationshipEvent) + case (.entity, .delete), (.user, .delete), (.channel, .delete): - let removed = PubNubDataSyncRemovedObject( + let removed = PubNubDataSyncRemovedEntity( id: identifier, className: className, classLevel: classLevel, classVersion: classVersion, deletedAt: try data.decode(Date.self, forKey: .deletedAt) ) - event = .entityDeleted(removed) + event = .entityDeleted( + PubNubDataSyncEntityDeletedEvent( + kind: type.entityKind, + removed: removed + ) + ) + case (.relationship, .delete), (.membership, .delete): event = .relationshipDeleted( - PubNubDataSyncRemovedRelationship( - id: identifier, - className: className, - classVersion: classVersion, - deletedAt: try data.decode(Date.self, forKey: .deletedAt) + PubNubDataSyncRelationshipDeletedEvent( + kind: type.relationshipKind, + removed: PubNubDataSyncRemovedRelationship( + id: identifier, + className: className, + classVersion: classVersion, + deletedAt: try data.decode(Date.self, forKey: .deletedAt) + ) ) ) } } } +private extension SubscribeDataSyncPayload.ObjectType { + var entityKind: PubNubDataSyncEntityKind { + switch self { + case .entity: + return .custom + case .user: + return .user + case .channel: + return .channel + case .relationship, .membership: + preconditionFailure("A relationship type cannot be converted to an entity kind") + } + } + + var relationshipKind: PubNubDataSyncRelationshipKind { + switch self { + case .relationship: + return .custom + case .membership: + return .membership + case .entity, .user, .channel: + preconditionFailure("An entity type cannot be converted to a relationship kind") + } + } +} + extension SubscribeMessagePayload { - func asDataSyncEvent() -> PubNubDataSyncEvent? { - try? payload.decode(SubscribeDataSyncPayload.self).event + func decodeDataSyncEvent() throws -> PubNubDataSyncEvent { + try payload.decode(SubscribeDataSyncPayload.self).event } } diff --git a/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift b/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift index c175a0ff..73e8eca6 100644 --- a/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift +++ b/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift @@ -10,33 +10,83 @@ import Foundation +/// The type of a DataSync entity event. +public enum PubNubDataSyncEntityKind: String, Hashable { + /// A user, including classes that extend User + case user + /// A channel, including classes that extend Channel + case channel + /// A custom entity + case custom +} + +/// The type of a DataSync relationship event. +public enum PubNubDataSyncRelationshipKind: String, Hashable { + /// A membership + case membership + /// A custom relationship + case custom +} + +/// A created or updated DataSync entity event. +public struct PubNubDataSyncEntityEvent: Hashable { + /// The entity type + public let kind: PubNubDataSyncEntityKind + /// The created or updated entity + public let entity: PubNubDataSyncEntity +} + +/// A deleted DataSync entity event. +public struct PubNubDataSyncEntityDeletedEvent: Hashable { + /// The entity type + public let kind: PubNubDataSyncEntityKind + /// Information identifying the deleted entity + public let removed: PubNubDataSyncRemovedEntity +} + +/// A created or updated DataSync relationship event. +public struct PubNubDataSyncRelationshipEvent: Hashable { + /// The relationship type + public let kind: PubNubDataSyncRelationshipKind + /// The created or updated relationship + public let relationship: PubNubDataSyncRelationship +} + +/// A deleted DataSync relationship event. +public struct PubNubDataSyncRelationshipDeletedEvent: Hashable { + /// The relationship type + public let kind: PubNubDataSyncRelationshipKind + /// Information identifying the deleted relationship + public let removed: PubNubDataSyncRemovedRelationship +} + /// Possible subevents for DataSync -public enum PubNubDataSyncEvent { +public enum PubNubDataSyncEvent: Hashable { /// An entity was created - case entityCreated(PubNubDataSyncEntity) + case entityCreated(PubNubDataSyncEntityEvent) /// An entity was updated - case entityUpdated(PubNubDataSyncEntity) + case entityUpdated(PubNubDataSyncEntityEvent) /// An entity was deleted - case entityDeleted(PubNubDataSyncRemovedObject) + case entityDeleted(PubNubDataSyncEntityDeletedEvent) /// A relationship was created - case relationshipCreated(PubNubDataSyncRelationship) + case relationshipCreated(PubNubDataSyncRelationshipEvent) /// A relationship was updated - case relationshipUpdated(PubNubDataSyncRelationship) + case relationshipUpdated(PubNubDataSyncRelationshipEvent) /// A relationship was deleted - case relationshipDeleted(PubNubDataSyncRemovedRelationship) + case relationshipDeleted(PubNubDataSyncRelationshipDeletedEvent) } -/// A DataSync entity or relationship that was deleted. -public struct PubNubDataSyncRemovedObject: Hashable { - /// The unique identifier of the deleted entity or relationship +/// A DataSync entity that was deleted. +public struct PubNubDataSyncRemovedEntity: Hashable { + /// The unique identifier of the deleted entity public let id: String - /// The name of the deleted object's class + /// The name of the deleted entity's class public let className: String - /// The level the deleted object's class is registered at + /// The level the deleted entity's class is registered at public let classLevel: PubNubDataSyncClassLevel - /// The version of the deleted object's class + /// The version of the deleted entity's class public let classVersion: Int - /// The date the object was deleted + /// The date the entity was deleted public let deletedAt: Date } diff --git a/Sources/PubNub/Subscribe/API/Subscription.swift b/Sources/PubNub/Subscribe/API/Subscription.swift index 07ef8df7..fdabf976 100644 --- a/Sources/PubNub/Subscribe/API/Subscription.swift +++ b/Sources/PubNub/Subscribe/API/Subscription.swift @@ -152,11 +152,14 @@ extension Subscription: SubscribeMessagesReceiver { } func event(from payload: SubscribeMessagePayload) -> PubNubEvent? { - guard subscriptionTopology.matches(payload), payload.publishTimetoken.timetoken >= timetoken ?? 0 else { + guard + subscriptionTopology.matches(payload), + payload.publishTimetoken.timetoken >= timetoken ?? 0, + let event = payload.asPubNubEvent() + else { return nil } - let event = payload.asPubNubEvent() return options.filterCriteriaSatisfied(event: event) ? event : nil } } diff --git a/Sources/PubNub/Subscribe/Core/SubscriptionSession.swift b/Sources/PubNub/Subscribe/Core/SubscriptionSession.swift index 83b119e6..114f2ded 100644 --- a/Sources/PubNub/Subscribe/Core/SubscriptionSession.swift +++ b/Sources/PubNub/Subscribe/Core/SubscriptionSession.swift @@ -333,7 +333,7 @@ extension SubscriptionSession: SubscribeMessagesReceiver { func onPayloadsReceived(payloads: [SubscribeMessagePayload]) -> [PubNubEvent] { // Translates payloads into PubNub Subscibe Loop events - let events = payloads.map { $0.asPubNubEvent() } + let events = payloads.compactMap { $0.asPubNubEvent() } // Emits events from the SubscriptionSession emit(events: events) // Emits events to the underlying attached listeners diff --git a/Sources/PubNub/Subscribe/Legacy/SubscriptionStream.swift b/Sources/PubNub/Subscribe/Legacy/SubscriptionStream.swift index 76f4f722..62b610a6 100644 --- a/Sources/PubNub/Subscribe/Legacy/SubscriptionStream.swift +++ b/Sources/PubNub/Subscribe/Legacy/SubscriptionStream.swift @@ -110,7 +110,7 @@ public final class CoreListener: BaseSubscriptionListener { } override public func emit(batch: [SubscribeMessagePayload]) { - emitDidReceive(subscription: batch.map { $0.asPubNubEvent() }) + emitDidReceive(subscription: batch.compactMap { $0.asPubNubEvent() }) } public func emitDidReceive(subscription batch: [PubNubEvent]) { diff --git a/Sources/PubNub/Subscribe/Mapping/SubscribeMessagePayload+PubNubEvent.swift b/Sources/PubNub/Subscribe/Mapping/SubscribeMessagePayload+PubNubEvent.swift index 1719f5de..78a4fe27 100644 --- a/Sources/PubNub/Subscribe/Mapping/SubscribeMessagePayload+PubNubEvent.swift +++ b/Sources/PubNub/Subscribe/Mapping/SubscribeMessagePayload+PubNubEvent.swift @@ -12,7 +12,7 @@ import Foundation extension SubscribeMessagePayload { // swiftlint:disable:next cyclomatic_complexity - func asPubNubEvent() -> PubNubEvent { + func asPubNubEvent() -> PubNubEvent? { switch messageType { case .message: return .messageReceived(PubNubMessageBase(from: self)) @@ -43,10 +43,9 @@ extension SubscribeMessagePayload { } return .fileChanged(.uploaded(fileMessage)) case .dataSync: - guard let dataSyncEvent = asDataSyncEvent() else { - return .messageReceived(PubNubMessageBase(from: self)) - } - return .dataSyncChanged(dataSyncEvent) + // Discard DataSync envelopes this SDK cannot decode, including events + // with metadata types or actions this SDK version does not recognize + return try? .dataSyncChanged(decodeDataSyncEvent()) case .presence: guard let presence = PubNubPresenceChangeBase(from: self) else { return .messageReceived(PubNubMessageBase(from: self)) diff --git a/Tests/PubNubIntegrationTests/DataSyncChannelEventIntegrationTests.swift b/Tests/PubNubIntegrationTests/DataSyncChannelEventIntegrationTests.swift index 4c232fa8..b9faffb6 100644 --- a/Tests/PubNubIntegrationTests/DataSyncChannelEventIntegrationTests.swift +++ b/Tests/PubNubIntegrationTests/DataSyncChannelEventIntegrationTests.swift @@ -36,16 +36,19 @@ final class DataSyncChannelEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .entityCreated(entity) = event, entity.id == channelId else { + guard case let .entityCreated(entityEvent) = event, entityEvent.entity.id == channelId else { return } + let entity = entityEvent.entity + XCTAssertEqual(entityEvent.kind, .channel) XCTAssertEqual(entity.className, "Channel") XCTAssertEqual(entity.classLevel, .global) XCTAssertEqual(entity.classVersion, self.channelClassVersion) XCTAssertEqual(entity.status, "active") XCTAssertFalse(entity.eTag.isEmpty) XCTAssertNotNil(entity.payload) + createExpect.fulfill() } @@ -102,15 +105,18 @@ final class DataSyncChannelEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .entityUpdated(entity) = event, entity.id == channelId else { + guard case let .entityUpdated(entityEvent) = event, entityEvent.entity.id == channelId else { return } + let entity = entityEvent.entity + XCTAssertEqual(entityEvent.kind, .channel) XCTAssertEqual(entity.className, "Channel") XCTAssertEqual(entity.classLevel, .global) XCTAssertEqual(entity.classVersion, self.channelClassVersion) XCTAssertFalse(entity.eTag.isEmpty) XCTAssertNotNil(entity.payload) + updateExpect.fulfill() } @@ -162,13 +168,16 @@ final class DataSyncChannelEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .entityDeleted(removed) = event, removed.id == channelId else { + guard case let .entityDeleted(entityEvent) = event, entityEvent.removed.id == channelId else { return } + let removed = entityEvent.removed + XCTAssertEqual(entityEvent.kind, .channel) XCTAssertEqual(removed.className, "Channel") XCTAssertEqual(removed.classLevel, .global) XCTAssertEqual(removed.classVersion, self.channelClassVersion) + deleteExpect.fulfill() } diff --git a/Tests/PubNubIntegrationTests/DataSyncEntityEndpointIntegrationTests.swift b/Tests/PubNubIntegrationTests/DataSyncEntityEndpointIntegrationTests.swift index 4cc50e90..9788faf6 100644 --- a/Tests/PubNubIntegrationTests/DataSyncEntityEndpointIntegrationTests.swift +++ b/Tests/PubNubIntegrationTests/DataSyncEntityEndpointIntegrationTests.swift @@ -490,7 +490,7 @@ class DataSyncEntityEndpointIntegrationTests: XCTestCase { patchExpect.fulfill() case let .failure(error): XCTAssertNotNil(error.pubNubError) - XCTAssertEqual(error.pubNubError?.reason, .badRequest) + XCTAssertEqual(error.pubNubError?.reason, .conflict) client.dataSync.getEntity(id: patientId) { fetchResult in switch fetchResult { diff --git a/Tests/PubNubIntegrationTests/DataSyncEntityEventIntegrationTests.swift b/Tests/PubNubIntegrationTests/DataSyncEntityEventIntegrationTests.swift index d2d072c1..2cdc2317 100644 --- a/Tests/PubNubIntegrationTests/DataSyncEntityEventIntegrationTests.swift +++ b/Tests/PubNubIntegrationTests/DataSyncEntityEventIntegrationTests.swift @@ -44,14 +44,17 @@ final class DataSyncEntityEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .entityCreated(entity) = event, entity.id == self.patientId else { + guard case let .entityCreated(entityEvent) = event, entityEvent.entity.id == self.patientId else { return } + let entity = entityEvent.entity + XCTAssertEqual(entityEvent.kind, .custom) XCTAssertEqual(entity.id, "swift-patient-alice") XCTAssertEqual(entity.className, HealthcareClass.patient.name) XCTAssertFalse(entity.eTag.isEmpty) XCTAssertNotNil(entity.payload) + createExpect.fulfill() } @@ -106,14 +109,17 @@ final class DataSyncEntityEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .entityUpdated(entity) = event, entity.id == self.patientId else { + guard case let .entityUpdated(entityEvent) = event, entityEvent.entity.id == self.patientId else { return } + let entity = entityEvent.entity + XCTAssertEqual(entityEvent.kind, .custom) XCTAssertEqual(entity.id, "swift-patient-alice") XCTAssertEqual(entity.className, HealthcareClass.patient.name) XCTAssertFalse(entity.eTag.isEmpty) XCTAssertNotNil(entity.payload) + updateExpect.fulfill() } @@ -164,12 +170,15 @@ final class DataSyncEntityEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .entityDeleted(removed) = event, removed.id == self.patientId else { + guard case let .entityDeleted(entityEvent) = event, entityEvent.removed.id == self.patientId else { return } + let removed = entityEvent.removed + XCTAssertEqual(entityEvent.kind, .custom) XCTAssertEqual(removed.id, "swift-patient-alice") XCTAssertEqual(removed.className, HealthcareClass.patient.name) + deleteExpect.fulfill() } @@ -218,10 +227,12 @@ final class DataSyncEntityEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .relationshipCreated(relationship) = event, relationship.id == self.relationshipId else { + guard case let .relationshipCreated(relEvent) = event, relEvent.relationship.id == self.relationshipId else { return } + let relationship = relEvent.relationship + XCTAssertEqual(relEvent.kind, .custom) XCTAssertEqual(relationship.id, "swift-rel-attending-carter-alice") XCTAssertEqual(relationship.className, HealthcareClass.attendingPhysician.name) XCTAssertEqual(relationship.entityAId, "swift-practitioner-carter") @@ -298,16 +309,19 @@ final class DataSyncEntityEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .relationshipUpdated(relationship) = event, relationship.id == self.relationshipId else { + guard case let .relationshipUpdated(relEvent) = event, relEvent.relationship.id == self.relationshipId else { return } + let relationship = relEvent.relationship + XCTAssertEqual(relEvent.kind, .custom) XCTAssertEqual(relationship.id, "swift-rel-attending-carter-alice") XCTAssertEqual(relationship.className, HealthcareClass.attendingPhysician.name) XCTAssertEqual(relationship.entityAId, "swift-practitioner-carter") XCTAssertEqual(relationship.entityBId, "swift-patient-alice") XCTAssertFalse(relationship.eTag.isEmpty) XCTAssertNotNil(relationship.payload) + updateExpect.fulfill() } @@ -373,12 +387,15 @@ final class DataSyncEntityEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .relationshipDeleted(removed) = event, removed.id == self.relationshipId else { + guard case let .relationshipDeleted(relationshipEvent) = event, relationshipEvent.removed.id == self.relationshipId else { return } + let removed = relationshipEvent.removed + XCTAssertEqual(relationshipEvent.kind, .custom) XCTAssertEqual(removed.id, "swift-rel-attending-carter-alice") XCTAssertEqual(removed.className, HealthcareClass.attendingPhysician.name) + deleteExpect.fulfill() } diff --git a/Tests/PubNubIntegrationTests/DataSyncMembershipEventIntegrationTests.swift b/Tests/PubNubIntegrationTests/DataSyncMembershipEventIntegrationTests.swift index 23448930..aa7c8413 100644 --- a/Tests/PubNubIntegrationTests/DataSyncMembershipEventIntegrationTests.swift +++ b/Tests/PubNubIntegrationTests/DataSyncMembershipEventIntegrationTests.swift @@ -44,10 +44,12 @@ final class DataSyncMembershipEventIntegrationTests: XCTestCase { ) subscription.onDataSync = { event in - guard case let .relationshipCreated(relationship) = event, relationship.id == membershipId else { + guard case let .relationshipCreated(relationshipEvent) = event, relationshipEvent.relationship.id == membershipId else { return } + let relationship = relationshipEvent.relationship + XCTAssertEqual(relationshipEvent.kind, .membership) XCTAssertEqual(relationship.className, "Membership") XCTAssertEqual(relationship.classVersion, self.membershipClassVersion) XCTAssertEqual(relationship.entityAId, channelId) @@ -55,6 +57,7 @@ final class DataSyncMembershipEventIntegrationTests: XCTestCase { XCTAssertEqual(relationship.status, "active") XCTAssertFalse(relationship.eTag.isEmpty) XCTAssertNotNil(relationship.payload) + createExpect.fulfill() } @@ -122,16 +125,20 @@ final class DataSyncMembershipEventIntegrationTests: XCTestCase { ) subscription.onDataSync = { event in - guard case let .relationshipUpdated(relationship) = event, relationship.id == membershipId else { + guard case let .relationshipUpdated(relationshipEvent) = event, + relationshipEvent.relationship.id == membershipId else { return } + let relationship = relationshipEvent.relationship + XCTAssertEqual(relationshipEvent.kind, .membership) XCTAssertEqual(relationship.className, "Membership") XCTAssertEqual(relationship.classVersion, self.membershipClassVersion) XCTAssertEqual(relationship.entityAId, channelId) XCTAssertEqual(relationship.entityBId, userId) XCTAssertFalse(relationship.eTag.isEmpty) XCTAssertNotNil(relationship.payload) + updateExpect.fulfill() } @@ -195,12 +202,15 @@ final class DataSyncMembershipEventIntegrationTests: XCTestCase { ) subscription.onDataSync = { event in - guard case let .relationshipDeleted(removed) = event, removed.id == membershipId else { + guard case let .relationshipDeleted(relationshipEvent) = event, relationshipEvent.removed.id == membershipId else { return } + let removed = relationshipEvent.removed + XCTAssertEqual(relationshipEvent.kind, .membership) XCTAssertEqual(removed.className, "Membership") XCTAssertEqual(removed.classVersion, self.membershipClassVersion) + deleteExpect.fulfill() } diff --git a/Tests/PubNubIntegrationTests/DataSyncRelationshipEndpointIntegrationTests.swift b/Tests/PubNubIntegrationTests/DataSyncRelationshipEndpointIntegrationTests.swift index 8566f873..c780050b 100644 --- a/Tests/PubNubIntegrationTests/DataSyncRelationshipEndpointIntegrationTests.swift +++ b/Tests/PubNubIntegrationTests/DataSyncRelationshipEndpointIntegrationTests.swift @@ -496,7 +496,7 @@ class DataSyncRelationshipEndpointIntegrationTests: XCTestCase { patchExpect.fulfill() case let .failure(error): XCTAssertNotNil(error.pubNubError) - XCTAssertEqual(error.pubNubError?.reason, .badRequest) + XCTAssertEqual(error.pubNubError?.reason, .conflict) client.dataSync.getRelationship(id: relationshipId) { fetchResult in switch fetchResult { diff --git a/Tests/PubNubIntegrationTests/DataSyncUserEventIntegrationTests.swift b/Tests/PubNubIntegrationTests/DataSyncUserEventIntegrationTests.swift index 2c29b001..d0284301 100644 --- a/Tests/PubNubIntegrationTests/DataSyncUserEventIntegrationTests.swift +++ b/Tests/PubNubIntegrationTests/DataSyncUserEventIntegrationTests.swift @@ -36,16 +36,19 @@ final class DataSyncUserEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .entityCreated(entity) = event, entity.id == userId else { + guard case let .entityCreated(entityEvent) = event, entityEvent.entity.id == userId else { return } + let entity = entityEvent.entity + XCTAssertEqual(entityEvent.kind, .user) XCTAssertEqual(entity.className, "User") XCTAssertEqual(entity.classLevel, .global) XCTAssertEqual(entity.classVersion, self.userClassVersion) XCTAssertEqual(entity.status, "active") XCTAssertFalse(entity.eTag.isEmpty) XCTAssertNotNil(entity.payload) + createExpect.fulfill() } @@ -99,15 +102,18 @@ final class DataSyncUserEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .entityUpdated(entity) = event, entity.id == userId else { + guard case let .entityUpdated(entityEvent) = event, entityEvent.entity.id == userId else { return } + let entity = entityEvent.entity + XCTAssertEqual(entityEvent.kind, .user) XCTAssertEqual(entity.className, "User") XCTAssertEqual(entity.classLevel, .global) XCTAssertEqual(entity.classVersion, self.userClassVersion) XCTAssertFalse(entity.eTag.isEmpty) XCTAssertNotNil(entity.payload) + updateExpect.fulfill() } @@ -159,13 +165,16 @@ final class DataSyncUserEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .entityDeleted(removed) = event, removed.id == userId else { + guard case let .entityDeleted(entityEvent) = event, entityEvent.removed.id == userId else { return } + let removed = entityEvent.removed + XCTAssertEqual(entityEvent.kind, .user) XCTAssertEqual(removed.className, "User") XCTAssertEqual(removed.classLevel, .global) XCTAssertEqual(removed.classVersion, self.userClassVersion) + deleteExpect.fulfill() } diff --git a/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift b/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift index 71843ec1..88f3bbe6 100644 --- a/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift +++ b/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift @@ -103,7 +103,7 @@ extension SubscribeRouterTests { } func test_Subscribe_WithDataSyncEntityDeleteEvent_ReceivesRemovedEntity() throws { - let expectedRemoval = PubNubDataSyncRemovedObject( + let expectedRemoval = PubNubDataSyncRemovedEntity( id: "hcn-patient-dubois", className: "patient", classLevel: .subKey, @@ -213,7 +213,7 @@ extension SubscribeRouterTests { ] for (rawClassLevel, expectedLevel) in levels { - let event = mockDataSyncPayload(classLevel: rawClassLevel).asPubNubEvent() + let event = try XCTUnwrap(mockDataSyncPayload(classLevel: rawClassLevel).asPubNubEvent()) let entity = try XCTUnwrap(event.createdDataSyncEntity, "\(rawClassLevel) should decode") XCTAssertEqual(entity.classLevel, expectedLevel, "\(rawClassLevel) should decode as \(expectedLevel)") @@ -222,7 +222,7 @@ extension SubscribeRouterTests { } func test_Subscribe_WithUnrecognizedDataSyncClassLevel_ReceivesUnknownLevel() throws { - let event = mockDataSyncPayload(classLevel: "Space").asPubNubEvent() + let event = try XCTUnwrap(mockDataSyncPayload(classLevel: "Space").asPubNubEvent()) let entity = try XCTUnwrap(event.createdDataSyncEntity) XCTAssertEqual(entity.classLevel, .unknown("Space")) @@ -230,13 +230,13 @@ extension SubscribeRouterTests { } func test_Subscribe_WithDataSyncNullableFieldsAbsent_ReceivesEntity() throws { - let event = mockDataSyncPayload(data: [ + let event = try XCTUnwrap(mockDataSyncPayload(data: [ "id": "hcn-patient-alice", "createdAt": "2026-07-28T09:11:17.077390Z", "updatedAt": "2026-07-28T09:11:17.077390Z", "eTag": "3w5e111hppk83", "expiresAt": "2027-07-29T00:00:00Z" - ]).asPubNubEvent() + ]).asPubNubEvent()) let entity = try XCTUnwrap(event.createdDataSyncEntity) @@ -245,7 +245,7 @@ extension SubscribeRouterTests { } func test_Subscribe_WithDataSyncNullableFieldsNull_ReceivesEntity() throws { - let event = mockDataSyncPayload(data: [ + let event = try XCTUnwrap(mockDataSyncPayload(data: [ "id": "hcn-patient-alice", "createdAt": "2026-07-28T09:11:17.077390Z", "updatedAt": "2026-07-28T09:11:17.077390Z", @@ -253,7 +253,7 @@ extension SubscribeRouterTests { "expiresAt": "2027-07-29T00:00:00Z", "status": NSNull(), "payload": NSNull() - ]).asPubNubEvent() + ]).asPubNubEvent()) let entity = try XCTUnwrap(event.createdDataSyncEntity) @@ -261,7 +261,7 @@ extension SubscribeRouterTests { XCTAssertNil(entity.payload) } - func test_Subscribe_WithDataSyncMissingRequiredFields_ReceivesMessage() throws { + func test_Subscribe_WithDataSyncMissingRequiredFields_DropsEvent() { for missingField in ["eTag"] { var data: [String: Any] = [ "id": "hcn-patient-alice", @@ -273,19 +273,41 @@ extension SubscribeRouterTests { data.removeValue(forKey: missingField) let payload = mockDataSyncPayload(data: data) - let event = payload.asPubNubEvent() - XCTAssertNotNil(event.message, "An envelope missing \(missingField) should degrade to a message") + XCTAssertNil(payload.asPubNubEvent(), "An envelope missing \(missingField) should be dropped") } } - func test_Subscribe_WithMalformedDataSyncEnvelope_ReceivesMessage() { + func test_Subscribe_WithMalformedDataSyncEnvelope_DropsEvent() { let payload = generateMessage(with: .dataSync, payload: MalformedDataSyncPayload().codableValue) - XCTAssertNotNil(payload.asPubNubEvent().message) + XCTAssertNil(payload.asPubNubEvent()) } - func test_Subscribe_WithUnrecognizedDataSyncObjectType_ReceivesMessage() { - XCTAssertNotNil(mockDataSyncPayload(type: "organization").asPubNubEvent().message) + func test_Subscribe_WithUnrecognizedDataSyncObjectType_DropsEvent() { + XCTAssertNil(mockDataSyncPayload(type: "organization").asPubNubEvent()) + } + + func test_Subscribe_WithUnrecognizedDataSyncAction_DropsEvent() { + XCTAssertNil(mockDataSyncPayload(event: "archive").asPubNubEvent()) + } + + func test_Subscribe_WithDataSyncEntityKinds_PreservesMetadataTypeForExtendedClasses() throws { + let kinds: [String: (className: String, expected: PubNubDataSyncEntityKind)] = [ + "entity": ("patient", .custom), + "user": ("MyUser", .user), + "channel": ("MyChannel", .channel) + ] + + for (type, kind) in kinds { + let event = try XCTUnwrap(mockDataSyncPayload(type: type, className: kind.className).asPubNubEvent()) + let entityEvent = try XCTUnwrap(event.createdDataSyncEntityEvent) + + XCTAssertEqual(entityEvent.kind, kind.expected) + XCTAssertEqual(entityEvent.entity.className, kind.className) + } + + XCTAssertEqual(PubNubDataSyncEntityKind.custom.rawValue, "custom") + XCTAssertEqual(PubNubDataSyncRelationshipKind.custom.rawValue, "custom") } func test_DataSyncAction_MapsToUnknownMessageType() { @@ -351,7 +373,7 @@ extension SubscribeRouterTests { XCTAssertEqual( removed, - PubNubDataSyncRemovedObject( + PubNubDataSyncRemovedEntity( id: "alice", className: "User", classLevel: .global, @@ -402,7 +424,7 @@ extension SubscribeRouterTests { XCTAssertEqual( removed, - PubNubDataSyncRemovedObject( + PubNubDataSyncRemovedEntity( id: "general", className: "Channel", classLevel: .global, @@ -480,6 +502,50 @@ extension SubscribeRouterTests { // MARK: - DataSync Delivery Through The Subscribe Loop extension SubscribeRouterTests { + func test_Subscribe_WithUnsupportedDataSyncEvent_DropsItFromModernDelivery() { + let pubnub = PubNub(configuration: config) + let subscription = pubnub.channel("test-channel").subscription() + let dataSyncExpectation = expectation(description: "Valid DataSync event") + let messageExpectation = expectation(description: "No fallback message") + messageExpectation.isInverted = true + + subscription.onDataSync = { _ in + dataSyncExpectation.fulfill() + } + subscription.onMessage = { _ in + messageExpectation.fulfill() + } + + let events = subscription.onPayloadsReceived(payloads: [ + mockDataSyncPayload(channel: "test-channel", type: "organization"), + mockDataSyncPayload(channel: "test-channel") + ]) + + XCTAssertEqual(events.count, 1) + wait(for: [dataSyncExpectation, messageExpectation], timeout: 0.1) + } + + func test_Subscribe_WithUnsupportedDataSyncEvent_DropsItFromLegacyDelivery() { + let listener = SubscriptionListener(queue: .main) + let dataSyncExpectation = expectation(description: "Valid DataSync event") + let messageExpectation = expectation(description: "No fallback message") + messageExpectation.isInverted = true + + listener.didReceiveDataSyncEvent = { _ in + dataSyncExpectation.fulfill() + } + listener.didReceiveMessage = { _ in + messageExpectation.fulfill() + } + + listener.emit(batch: [ + mockDataSyncPayload(type: "organization"), + mockDataSyncPayload() + ]) + + wait(for: [dataSyncExpectation, messageExpectation], timeout: 0.1) + } + func test_Subscribe_WithDataSyncEntityCreateEvent_EmitsToListeners() throws { let events = try emittedDataSyncEvents( fromFixture: "subscription_dataSyncEntityCreate_success", @@ -746,33 +812,33 @@ private extension SubscribeRouterTests { private extension PubNubDataSyncEvent { var createdEntity: PubNubDataSyncEntity? { - guard case let .entityCreated(e) = self else { return nil } - return e + guard case let .entityCreated(entityEvent) = self else { return nil } + return entityEvent.entity } var updatedEntity: PubNubDataSyncEntity? { - guard case let .entityUpdated(e) = self else { return nil } - return e + guard case let .entityUpdated(entityEvent) = self else { return nil } + return entityEvent.entity } - var deletedEntity: PubNubDataSyncRemovedObject? { - guard case let .entityDeleted(e) = self else { return nil } - return e + var deletedEntity: PubNubDataSyncRemovedEntity? { + guard case let .entityDeleted(entityEvent) = self else { return nil } + return entityEvent.removed } var createdRelationship: PubNubDataSyncRelationship? { - guard case let .relationshipCreated(r) = self else { return nil } - return r + guard case let .relationshipCreated(relationshipEvent) = self else { return nil } + return relationshipEvent.relationship } var updatedRelationship: PubNubDataSyncRelationship? { - guard case let .relationshipUpdated(r) = self else { return nil } - return r + guard case let .relationshipUpdated(relationshipEvent) = self else { return nil } + return relationshipEvent.relationship } var deletedRelationship: PubNubDataSyncRemovedRelationship? { - guard case let .relationshipDeleted(r) = self else { return nil } - return r + guard case let .relationshipDeleted(relationshipEvent) = self else { return nil } + return relationshipEvent.removed } } @@ -780,32 +846,40 @@ private extension PubNubDataSyncEvent { private extension PubNubEvent { var createdDataSyncEntity: PubNubDataSyncEntity? { - guard case let .dataSyncChanged(.entityCreated(e)) = self else { return nil } - return e + createdDataSyncEntityEvent?.entity } var updatedDataSyncEntity: PubNubDataSyncEntity? { - guard case let .dataSyncChanged(.entityUpdated(e)) = self else { return nil } - return e + guard case let .dataSyncChanged(.entityUpdated(entityEvent)) = self else { return nil } + return entityEvent.entity } - var deletedDataSyncEntity: PubNubDataSyncRemovedObject? { - guard case let .dataSyncChanged(.entityDeleted(e)) = self else { return nil } - return e + var deletedDataSyncEntity: PubNubDataSyncRemovedEntity? { + guard case let .dataSyncChanged(.entityDeleted(entityEvent)) = self else { return nil } + return entityEvent.removed } var createdDataSyncRelationship: PubNubDataSyncRelationship? { - guard case let .dataSyncChanged(.relationshipCreated(r)) = self else { return nil } - return r + createdDataSyncRelationshipEvent?.relationship } var updatedDataSyncRelationship: PubNubDataSyncRelationship? { - guard case let .dataSyncChanged(.relationshipUpdated(r)) = self else { return nil } - return r + guard case let .dataSyncChanged(.relationshipUpdated(relationshipEvent)) = self else { return nil } + return relationshipEvent.relationship } var deletedDataSyncRelationship: PubNubDataSyncRemovedRelationship? { - guard case let .dataSyncChanged(.relationshipDeleted(r)) = self else { return nil } - return r + guard case let .dataSyncChanged(.relationshipDeleted(relationshipEvent)) = self else { return nil } + return relationshipEvent.removed + } + + var createdDataSyncEntityEvent: PubNubDataSyncEntityEvent? { + guard case let .dataSyncChanged(.entityCreated(entityEvent)) = self else { return nil } + return entityEvent + } + + var createdDataSyncRelationshipEvent: PubNubDataSyncRelationshipEvent? { + guard case let .dataSyncChanged(.relationshipCreated(relationshipEvent)) = self else { return nil } + return relationshipEvent } } diff --git a/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests.swift b/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests.swift index 72131f58..9f8a232c 100644 --- a/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests.swift +++ b/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests.swift @@ -220,7 +220,7 @@ extension SubscribeRouterTests { func decodeEvent(from resource: String) throws -> PubNubEvent { let payload = try XCTUnwrap(decodeSubscribeResponse(from: resource).messages.first) - return payload.asPubNubEvent() + return try XCTUnwrap(payload.asPubNubEvent()) } } From 74e0dae2aca55832a7fda5a45a9f1f1e2d443ba4 Mon Sep 17 00:00:00 2001 From: jguz-pubnub Date: Tue, 6 Oct 2026 19:02:43 +0200 Subject: [PATCH 2/5] Fixes according to review --- PubNub.xcodeproj/project.pbxproj | 4 +- Snippets/ListenersOld/listeners-old.swift | 7 +- Snippets/SubscribeNew/subscriptions.swift | 7 +- .../KMP/Wrappers/KMPDataSyncEvent.swift | 103 +++++++++--- Sources/PubNub/Models/PubNubMessage.swift | 1 + .../SubscribeDataSyncPayload.swift | 57 +++++-- .../Networking/Routers/SubscribeRouter.swift | 2 +- .../Subscribe/API/PubNubDataSyncEvent.swift | 83 ++++++++-- ...DataSyncChannelEventIntegrationTests.swift | 6 +- .../DataSyncEntityEventIntegrationTests.swift | 16 +- ...aSyncMembershipEventIntegrationTests.swift | 15 +- .../DataSyncUserEventIntegrationTests.swift | 6 +- .../SubscribeRouterTests+DataSync.swift | 154 +++++++++++++++--- .../Legacy/SubscriptionStreamTests.swift | 2 +- .../Factories/SubscribePayloadFactory.swift | 5 +- 15 files changed, 367 insertions(+), 101 deletions(-) diff --git a/PubNub.xcodeproj/project.pbxproj b/PubNub.xcodeproj/project.pbxproj index f1201b19..e05845eb 100644 --- a/PubNub.xcodeproj/project.pbxproj +++ b/PubNub.xcodeproj/project.pbxproj @@ -253,7 +253,6 @@ 35C6B6DD22F501780054F242 /* Encodable+PubNub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C6B6DC22F501780054F242 /* Encodable+PubNub.swift */; }; 35C6B6E322F515760054F242 /* SubscribeRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 350EFBD722C926C300FA33AA /* SubscribeRouter.swift */; }; 35C6B6E422F516040054F242 /* subscription_mixed_success.json in Resources */ = {isa = PBXBuildFile; fileRef = 35C6B6DF22F513D80054F242 /* subscription_mixed_success.json */; }; - DA7A5C000000000000000201 /* subscription_unknownMessageType_success.json in Resources */ = {isa = PBXBuildFile; fileRef = DA7A5C000000000000000200 /* subscription_unknownMessageType_success.json */; }; 35C6B6E622F51A060054F242 /* AnyJSONType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C6B6E522F51A060054F242 /* AnyJSONType.swift */; }; 35C829DC23147AC000F59D3C /* SubscriptionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C829DB23147AC000F59D3C /* SubscriptionState.swift */; }; 35C8FDC625000BC80069E89E /* FileManagementRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35AE6A3525000AC700BBFA37 /* FileManagementRouterTests.swift */; }; @@ -635,6 +634,7 @@ DA7A5C00000000000000004C /* DataSyncQueryParameterAPITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7A5C00000000000000004D /* DataSyncQueryParameterAPITests.swift */; }; DA7A5C00000000000000004E /* DataSyncErrorAPITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7A5C00000000000000004F /* DataSyncErrorAPITests.swift */; }; DA7A5C000000000000000060 /* Subscribable+PubNub.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7A5C000000000000000061 /* Subscribable+PubNub.swift */; }; + DA7A5C000000000000000201 /* subscription_unknownMessageType_success.json in Resources */ = {isa = PBXBuildFile; fileRef = DA7A5C000000000000000200 /* subscription_unknownMessageType_success.json */; }; DC1FB72D98DF046B1FA1EED0 /* subscription_dataSyncEntityDelete_success.json in Resources */ = {isa = PBXBuildFile; fileRef = F663C27813A8D0288A85B9E3 /* subscription_dataSyncEntityDelete_success.json */; }; F56167435CA49717E3E0A5A3 /* subscription_dataSyncUserUpdate_success.json in Resources */ = {isa = PBXBuildFile; fileRef = EB86FDD4F6AE4E75660509FA /* subscription_dataSyncUserUpdate_success.json */; }; FEECFED552025EE3D7897E0C /* subscription_dataSyncRelationshipUpdate_success.json in Resources */ = {isa = PBXBuildFile; fileRef = BCCCA408812B8BD920DE6401 /* subscription_dataSyncRelationshipUpdate_success.json */; }; @@ -1013,7 +1013,6 @@ 35B6FBB122F357A7005EE490 /* publish_invalid_json.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = publish_invalid_json.json; sourceTree = ""; }; 35C6B6DC22F501780054F242 /* Encodable+PubNub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Encodable+PubNub.swift"; sourceTree = ""; }; 35C6B6DF22F513D80054F242 /* subscription_mixed_success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = subscription_mixed_success.json; sourceTree = ""; }; - DA7A5C000000000000000200 /* subscription_unknownMessageType_success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = subscription_unknownMessageType_success.json; sourceTree = ""; }; 35C6B6E522F51A060054F242 /* AnyJSONType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyJSONType.swift; sourceTree = ""; }; 35C829DB23147AC000F59D3C /* SubscriptionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionState.swift; sourceTree = ""; }; 35C8FDCA25003A9F0069E89E /* file_generateURL_success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = file_generateURL_success.json; sourceTree = ""; }; @@ -1394,6 +1393,7 @@ DA7A5C00000000000000004D /* DataSyncQueryParameterAPITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSyncQueryParameterAPITests.swift; sourceTree = ""; }; DA7A5C00000000000000004F /* DataSyncErrorAPITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSyncErrorAPITests.swift; sourceTree = ""; }; DA7A5C000000000000000061 /* Subscribable+PubNub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Subscribable+PubNub.swift"; sourceTree = ""; }; + DA7A5C000000000000000200 /* subscription_unknownMessageType_success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = subscription_unknownMessageType_success.json; sourceTree = ""; }; DA7B5C000000000000000065 /* data-sync-users.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "data-sync-users.swift"; sourceTree = ""; }; DA7B5C000000000000000066 /* data-sync-channels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "data-sync-channels.swift"; sourceTree = ""; }; DA7B5C000000000000000067 /* data-sync-memberships.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "data-sync-memberships.swift"; sourceTree = ""; }; diff --git a/Snippets/ListenersOld/listeners-old.swift b/Snippets/ListenersOld/listeners-old.swift index ed4bbc88..ef3dc017 100644 --- a/Snippets/ListenersOld/listeners-old.swift +++ b/Snippets/ListenersOld/listeners-old.swift @@ -134,7 +134,9 @@ listener.didReceiveBatchSubscription = { events in } case .dataSyncChanged(let dataSyncEvent): - switch dataSyncEvent { + print("Received on \(dataSyncEvent.channel) at \(dataSyncEvent.timetoken)") + + switch dataSyncEvent.change { case let .entityCreated(entityEvent): let entity = entityEvent.entity print("Entity \(entity.id) was created.") @@ -159,6 +161,9 @@ listener.didReceiveBatchSubscription = { events in let removedRelationship = relationshipEvent.removed print("Relationship \(removedRelationship.id) was deleted.") print("Relationship kind: \(relationshipEvent.kind)") + case let .unknown(unknownEvent): + // An object type or action this SDK version doesn't recognize + print("Unrecognized \(unknownEvent.type) change: \(unknownEvent.event)") } } } diff --git a/Snippets/SubscribeNew/subscriptions.swift b/Snippets/SubscribeNew/subscriptions.swift index 7cba2517..f8d04d2d 100644 --- a/Snippets/SubscribeNew/subscriptions.swift +++ b/Snippets/SubscribeNew/subscriptions.swift @@ -201,7 +201,9 @@ subscription.onAppContext = { appContextEvent in // snippet.on-data-sync // Add a listener to receive Data Sync entity and relationship events dataSyncSubscription.onDataSync = { dataSyncEvent in - switch dataSyncEvent { + print("Received on \(dataSyncEvent.channel) at \(dataSyncEvent.timetoken)") + + switch dataSyncEvent.change { case let .entityCreated(entityEvent): let entity = entityEvent.entity print("Entity \(entity.id) was created.") @@ -226,6 +228,9 @@ dataSyncSubscription.onDataSync = { dataSyncEvent in let removedRelationship = relationshipEvent.removed print("Relationship \(removedRelationship.id) was deleted.") print("Relationship kind: \(relationshipEvent.kind)") + case let .unknown(unknownEvent): + // An object type or action this SDK version doesn't recognize + print("Unrecognized \(unknownEvent.type) change: \(unknownEvent.event)") } } // snippet.end diff --git a/Sources/PubNub/KMP/Wrappers/KMPDataSyncEvent.swift b/Sources/PubNub/KMP/Wrappers/KMPDataSyncEvent.swift index 698a7d24..dc29c9db 100644 --- a/Sources/PubNub/KMP/Wrappers/KMPDataSyncEvent.swift +++ b/Sources/PubNub/KMP/Wrappers/KMPDataSyncEvent.swift @@ -21,10 +21,14 @@ import Foundation @objc public class KMPDataSyncEvent: NSObject { - @objc public let event: String - - init(event: String = "") { - self.event = event + @objc public let channel: String + @objc public let subscription: String? + @objc public let timetoken: Timetoken + + init(channel: String = "", subscription: String? = nil, timetoken: Timetoken = 0) { + self.channel = channel + self.subscription = subscription + self.timetoken = timetoken } } @@ -35,11 +39,11 @@ public class KMPDataSyncEntityCreatedResult: KMPDataSyncEvent { @objc public let kind: String @objc public let entity: KMPDataSyncEntity - init(entityEvent: PubNubDataSyncEntityEvent) { + init(entityEvent: PubNubDataSyncEntityEvent, channel: String, subscription: String?, timetoken: Timetoken) { self.kind = entityEvent.kind.rawValue self.entity = KMPDataSyncEntity(entity: entityEvent.entity) - super.init(event: "entityCreated") + super.init(channel: channel, subscription: subscription, timetoken: timetoken) } } @@ -50,11 +54,11 @@ public class KMPDataSyncEntityUpdatedResult: KMPDataSyncEvent { @objc public let kind: String @objc public let entity: KMPDataSyncEntity - init(entityEvent: PubNubDataSyncEntityEvent) { + init(entityEvent: PubNubDataSyncEntityEvent, channel: String, subscription: String?, timetoken: Timetoken) { self.kind = entityEvent.kind.rawValue self.entity = KMPDataSyncEntity(entity: entityEvent.entity) - super.init(event: "entityUpdated") + super.init(channel: channel, subscription: subscription, timetoken: timetoken) } } @@ -65,11 +69,11 @@ public class KMPDataSyncEntityDeletedResult: KMPDataSyncEvent { @objc public let kind: String @objc public let removedEntity: KMPDataSyncRemovedEntity - init(entityEvent: PubNubDataSyncEntityDeletedEvent) { + init(entityEvent: PubNubDataSyncEntityDeletedEvent, channel: String, subscription: String?, timetoken: Timetoken) { self.kind = entityEvent.kind.rawValue self.removedEntity = KMPDataSyncRemovedEntity(removedEntity: entityEvent.removed) - super.init(event: "entityDeleted") + super.init(channel: channel, subscription: subscription, timetoken: timetoken) } } @@ -80,11 +84,11 @@ public class KMPDataSyncRelationshipCreatedResult: KMPDataSyncEvent { @objc public let kind: String @objc public let relationship: KMPDataSyncRelationship - init(relationshipEvent: PubNubDataSyncRelationshipEvent) { + init(relationshipEvent: PubNubDataSyncRelationshipEvent, channel: String, subscription: String?, timetoken: Timetoken) { self.kind = relationshipEvent.kind.rawValue self.relationship = KMPDataSyncRelationship(relationship: relationshipEvent.relationship) - super.init(event: "relationshipCreated") + super.init(channel: channel, subscription: subscription, timetoken: timetoken) } } @@ -95,11 +99,11 @@ public class KMPDataSyncRelationshipUpdatedResult: KMPDataSyncEvent { @objc public let kind: String @objc public let relationship: KMPDataSyncRelationship - init(relationshipEvent: PubNubDataSyncRelationshipEvent) { + init(relationshipEvent: PubNubDataSyncRelationshipEvent, channel: String, subscription: String?, timetoken: Timetoken) { self.kind = relationshipEvent.kind.rawValue self.relationship = KMPDataSyncRelationship(relationship: relationshipEvent.relationship) - super.init(event: "relationshipUpdated") + super.init(channel: channel, subscription: subscription, timetoken: timetoken) } } @@ -110,11 +114,37 @@ public class KMPDataSyncRelationshipDeletedResult: KMPDataSyncEvent { @objc public let kind: String @objc public let removedRelationship: KMPDataSyncRemovedRelationship - init(relationshipEvent: PubNubDataSyncRelationshipDeletedEvent) { + init(relationshipEvent: PubNubDataSyncRelationshipDeletedEvent, channel: String, subscription: String?, timetoken: Timetoken) { self.kind = relationshipEvent.kind.rawValue self.removedRelationship = KMPDataSyncRemovedRelationship(removedRelationship: relationshipEvent.removed) - super.init(event: "relationshipDeleted") + super.init(channel: channel, subscription: subscription, timetoken: timetoken) + } +} + +// MARK: - KMPDataSyncUnknownResult + +@objc +public class KMPDataSyncUnknownResult: KMPDataSyncEvent { + @objc public let type: String + @objc public let event: String + // Named `objectClass` rather than `className` because `NSObject` already exposes a `className` selector + @objc public let objectClass: String? + @objc public let classLevel: String? + @objc public let classVersion: NSNumber? + @objc public let metadata: KMPAnyJSON? + @objc public let payload: KMPAnyJSON? + + init(unknownEvent: PubNubDataSyncUnknownEvent, channel: String, subscription: String?, timetoken: Timetoken) { + self.type = unknownEvent.type + self.event = unknownEvent.event + self.objectClass = unknownEvent.className + self.classLevel = unknownEvent.classLevel?.stringValue + self.classVersion = if let version = unknownEvent.classVersion { NSNumber(value: version) } else { nil } + self.metadata = if let metadata = unknownEvent.concreteMetadata { KMPAnyJSON(metadata) } else { nil } + self.payload = if let payload = unknownEvent.concretePayload { KMPAnyJSON(payload) } else { nil } + + super.init(channel: channel, subscription: subscription, timetoken: timetoken) } } @@ -160,19 +190,46 @@ public class KMPDataSyncRemovedRelationship: NSObject { extension KMPDataSyncEvent { static func from(event: PubNubDataSyncEvent) -> KMPDataSyncEvent { - switch event { + let channel = event.channel + let subscription = event.subscription + let timetoken = event.timetoken + + switch event.change { case .entityCreated(let entityEvent): - return KMPDataSyncEntityCreatedResult(entityEvent: entityEvent) + return KMPDataSyncEntityCreatedResult( + entityEvent: entityEvent, + channel: channel, subscription: subscription, timetoken: timetoken + ) case .entityUpdated(let entityEvent): - return KMPDataSyncEntityUpdatedResult(entityEvent: entityEvent) + return KMPDataSyncEntityUpdatedResult( + entityEvent: entityEvent, + channel: channel, subscription: subscription, timetoken: timetoken + ) case .entityDeleted(let entityEvent): - return KMPDataSyncEntityDeletedResult(entityEvent: entityEvent) + return KMPDataSyncEntityDeletedResult( + entityEvent: entityEvent, + channel: channel, subscription: subscription, timetoken: timetoken + ) case .relationshipCreated(let relationshipEvent): - return KMPDataSyncRelationshipCreatedResult(relationshipEvent: relationshipEvent) + return KMPDataSyncRelationshipCreatedResult( + relationshipEvent: relationshipEvent, + channel: channel, subscription: subscription, timetoken: timetoken + ) case .relationshipUpdated(let relationshipEvent): - return KMPDataSyncRelationshipUpdatedResult(relationshipEvent: relationshipEvent) + return KMPDataSyncRelationshipUpdatedResult( + relationshipEvent: relationshipEvent, + channel: channel, subscription: subscription, timetoken: timetoken + ) case .relationshipDeleted(let relationshipEvent): - return KMPDataSyncRelationshipDeletedResult(relationshipEvent: relationshipEvent) + return KMPDataSyncRelationshipDeletedResult( + relationshipEvent: relationshipEvent, + channel: channel, subscription: subscription, timetoken: timetoken + ) + case .unknown(let unknownEvent): + return KMPDataSyncUnknownResult( + unknownEvent: unknownEvent, + channel: channel, subscription: subscription, timetoken: timetoken + ) } } } diff --git a/Sources/PubNub/Models/PubNubMessage.swift b/Sources/PubNub/Models/PubNubMessage.swift index 9f1a9752..465fe45b 100644 --- a/Sources/PubNub/Models/PubNubMessage.swift +++ b/Sources/PubNub/Models/PubNubMessage.swift @@ -16,6 +16,7 @@ public enum PubNubMessageType: Int, Codable, Hashable { case object = 2 case messageAction = 3 case file = 4 + case dataSync = 5 case unknown = 999 } diff --git a/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift b/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift index fd892bee..9cbed09f 100644 --- a/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift +++ b/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift @@ -11,9 +11,7 @@ import Foundation struct SubscribeDataSyncPayload { - let version: String - let source: String - let event: PubNubDataSyncEvent + let change: PubNubDataSyncEvent.Change enum Action: String, Codable, Hashable { case create @@ -32,7 +30,6 @@ struct SubscribeDataSyncPayload { extension SubscribeDataSyncPayload: Decodable { enum CodingKeys: String, CodingKey { - case version case metadata case data } @@ -61,15 +58,44 @@ extension SubscribeDataSyncPayload: Decodable { case userId } + // swiftlint:disable:next function_body_length init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let metadata = try container.nestedContainer(keyedBy: MetadataCodingKeys.self, forKey: .metadata) - version = try container.decode(String.self, forKey: .version) - source = try metadata.decode(String.self, forKey: .source) + let source = try metadata.decode(String.self, forKey: .source) + let rawAction = try metadata.decode(String.self, forKey: .event) + let rawType = try metadata.decode(String.self, forKey: .type) + + // Anything that isn't recognizably a DataSync envelope is discarded rather than reported + // as an unknown change, so `PubNubDataSyncUnknownEvent` only ever describes a DataSync change + guard source == "data-sync", !rawAction.isEmpty, !rawType.isEmpty else { + throw DecodingError.dataCorruptedError( + forKey: .metadata, + in: container, + debugDescription: "Not a DataSync envelope: source '\(source)', event '\(rawAction)', type '\(rawType)'" + ) + } + + // An object type or action added to the service after this SDK version was released is + // surfaced with its envelope intact instead of being dropped + guard let action = Action(rawValue: rawAction), let type = ObjectType(rawValue: rawType) else { + change = .unknown( + PubNubDataSyncUnknownEvent( + type: rawType, + event: rawAction, + className: try metadata.decodeIfPresent(String.self, forKey: .className), + classLevel: try metadata.decodeIfPresent(String.self, forKey: .classLevel).map { + PubNubDataSyncClassLevel(stringValue: $0) + }, + classVersion: try metadata.decodeIfPresent(Int.self, forKey: .classVersion), + metadata: try container.decodeIfPresent(AnyJSON.self, forKey: .metadata), + payload: try container.decodeIfPresent(AnyJSON.self, forKey: .data) + ) + ) + return + } - let action = try metadata.decode(Action.self, forKey: .event) - let type = try metadata.decode(ObjectType.self, forKey: .type) let className = try metadata.decode(String.self, forKey: .className) let classLevel = PubNubDataSyncClassLevel(stringValue: try metadata.decode(String.self, forKey: .classLevel)) let classVersion = try metadata.decode(Int.self, forKey: .classVersion) @@ -96,7 +122,7 @@ extension SubscribeDataSyncPayload: Decodable { entity: entity ) - event = action == .create ? .entityCreated(entityEvent) : .entityUpdated(entityEvent) + change = action == .create ? .entityCreated(entityEvent) : .entityUpdated(entityEvent) case (.relationship, .create), (.relationship, .update), (.membership, .create), (.membership, .update): // A membership frames its two sides as `channelId` and `userId` @@ -121,7 +147,7 @@ extension SubscribeDataSyncPayload: Decodable { relationship: relationship ) - event = action == .create ? .relationshipCreated(relationshipEvent) : .relationshipUpdated(relationshipEvent) + change = action == .create ? .relationshipCreated(relationshipEvent) : .relationshipUpdated(relationshipEvent) case (.entity, .delete), (.user, .delete), (.channel, .delete): let removed = PubNubDataSyncRemovedEntity( @@ -131,7 +157,7 @@ extension SubscribeDataSyncPayload: Decodable { classVersion: classVersion, deletedAt: try data.decode(Date.self, forKey: .deletedAt) ) - event = .entityDeleted( + change = .entityDeleted( PubNubDataSyncEntityDeletedEvent( kind: type.entityKind, removed: removed @@ -139,7 +165,7 @@ extension SubscribeDataSyncPayload: Decodable { ) case (.relationship, .delete), (.membership, .delete): - event = .relationshipDeleted( + change = .relationshipDeleted( PubNubDataSyncRelationshipDeletedEvent( kind: type.relationshipKind, removed: PubNubDataSyncRemovedRelationship( @@ -182,6 +208,11 @@ private extension SubscribeDataSyncPayload.ObjectType { extension SubscribeMessagePayload { func decodeDataSyncEvent() throws -> PubNubDataSyncEvent { - try payload.decode(SubscribeDataSyncPayload.self).event + PubNubDataSyncEvent( + channel: channel, + subscription: subscription, + timetoken: publishTimetoken.timetoken, + change: try payload.decode(SubscribeDataSyncPayload.self).change + ) } } diff --git a/Sources/PubNub/Networking/Routers/SubscribeRouter.swift b/Sources/PubNub/Networking/Routers/SubscribeRouter.swift index ed4dab98..82f24d0f 100644 --- a/Sources/PubNub/Networking/Routers/SubscribeRouter.swift +++ b/Sources/PubNub/Networking/Routers/SubscribeRouter.swift @@ -352,7 +352,7 @@ public struct SubscribeMessagePayload: Codable, Hashable, CustomStringConvertibl case .file: return .file case .dataSync: - return .unknown + return .dataSync case .presence: return .unknown } diff --git a/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift b/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift index 73e8eca6..28f7cf4e 100644 --- a/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift +++ b/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift @@ -60,20 +60,75 @@ public struct PubNubDataSyncRelationshipDeletedEvent: Hashable { public let removed: PubNubDataSyncRemovedRelationship } -/// Possible subevents for DataSync -public enum PubNubDataSyncEvent: Hashable { - /// An entity was created - case entityCreated(PubNubDataSyncEntityEvent) - /// An entity was updated - case entityUpdated(PubNubDataSyncEntityEvent) - /// An entity was deleted - case entityDeleted(PubNubDataSyncEntityDeletedEvent) - /// A relationship was created - case relationshipCreated(PubNubDataSyncRelationshipEvent) - /// A relationship was updated - case relationshipUpdated(PubNubDataSyncRelationshipEvent) - /// A relationship was deleted - case relationshipDeleted(PubNubDataSyncRelationshipDeletedEvent) +/// A DataSync change received over subscribe. +public struct PubNubDataSyncEvent: Hashable { + /// The channel the event was received on + public let channel: String + /// The channel group or wildcard subscription match (if exists) + public let subscription: String? + /// The `Timetoken` for when the event was published + public let timetoken: Timetoken + /// The change the event describes + public let change: Change + + /// Possible DataSync changes + public enum Change: Hashable { + /// An entity was created + case entityCreated(PubNubDataSyncEntityEvent) + /// An entity was updated + case entityUpdated(PubNubDataSyncEntityEvent) + /// An entity was deleted + case entityDeleted(PubNubDataSyncEntityDeletedEvent) + /// A relationship was created + case relationshipCreated(PubNubDataSyncRelationshipEvent) + /// A relationship was updated + case relationshipUpdated(PubNubDataSyncRelationshipEvent) + /// A relationship was deleted + case relationshipDeleted(PubNubDataSyncRelationshipDeletedEvent) + /// A change this version of the SDK doesn't recognize. + case unknown(PubNubDataSyncUnknownEvent) + } +} + +/// A DataSync change whose object type or action this version of the SDK doesn't recognize. +/// +/// A change that is recognized but malformed isn't reported here; it's discarded. +public struct PubNubDataSyncUnknownEvent: Hashable { + /// The object type received from the server, taken from `metadata.type` + public let type: String + /// The action received from the server, taken from `metadata.event` + public let event: String + /// The name of the object's class, if the server sent one + public let className: String? + /// The level the object's class is registered at, if the server sent one + public let classLevel: PubNubDataSyncClassLevel? + /// The version of the object's class, if the server sent one + public let classVersion: Int? + /// The unmodified `metadata` object + public var metadata: JSONCodable? { concreteMetadata } + /// The unmodified `data` object, if the server sent one + public var payload: JSONCodable? { concretePayload } + + let concreteMetadata: AnyJSON? + let concretePayload: AnyJSON? + + init( + type: String, + event: String, + className: String? = nil, + classLevel: PubNubDataSyncClassLevel? = nil, + classVersion: Int? = nil, + metadata: JSONCodable? = nil, + payload: JSONCodable? = nil + ) { + self.type = type + self.event = event + self.className = className + self.classLevel = classLevel + self.classVersion = classVersion + self.concreteMetadata = metadata?.codableValue + self.concretePayload = payload?.codableValue + } } /// A DataSync entity that was deleted. diff --git a/Tests/PubNubIntegrationTests/DataSyncChannelEventIntegrationTests.swift b/Tests/PubNubIntegrationTests/DataSyncChannelEventIntegrationTests.swift index b9faffb6..64b3640f 100644 --- a/Tests/PubNubIntegrationTests/DataSyncChannelEventIntegrationTests.swift +++ b/Tests/PubNubIntegrationTests/DataSyncChannelEventIntegrationTests.swift @@ -36,7 +36,7 @@ final class DataSyncChannelEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .entityCreated(entityEvent) = event, entityEvent.entity.id == channelId else { + guard case let .entityCreated(entityEvent) = event.change, entityEvent.entity.id == channelId else { return } @@ -105,7 +105,7 @@ final class DataSyncChannelEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .entityUpdated(entityEvent) = event, entityEvent.entity.id == channelId else { + guard case let .entityUpdated(entityEvent) = event.change, entityEvent.entity.id == channelId else { return } @@ -168,7 +168,7 @@ final class DataSyncChannelEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .entityDeleted(entityEvent) = event, entityEvent.removed.id == channelId else { + guard case let .entityDeleted(entityEvent) = event.change, entityEvent.removed.id == channelId else { return } diff --git a/Tests/PubNubIntegrationTests/DataSyncEntityEventIntegrationTests.swift b/Tests/PubNubIntegrationTests/DataSyncEntityEventIntegrationTests.swift index 2cdc2317..892e7eb2 100644 --- a/Tests/PubNubIntegrationTests/DataSyncEntityEventIntegrationTests.swift +++ b/Tests/PubNubIntegrationTests/DataSyncEntityEventIntegrationTests.swift @@ -44,7 +44,7 @@ final class DataSyncEntityEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .entityCreated(entityEvent) = event, entityEvent.entity.id == self.patientId else { + guard case let .entityCreated(entityEvent) = event.change, entityEvent.entity.id == self.patientId else { return } @@ -109,7 +109,7 @@ final class DataSyncEntityEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .entityUpdated(entityEvent) = event, entityEvent.entity.id == self.patientId else { + guard case let .entityUpdated(entityEvent) = event.change, entityEvent.entity.id == self.patientId else { return } @@ -170,7 +170,7 @@ final class DataSyncEntityEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .entityDeleted(entityEvent) = event, entityEvent.removed.id == self.patientId else { + guard case let .entityDeleted(entityEvent) = event.change, entityEvent.removed.id == self.patientId else { return } @@ -227,7 +227,7 @@ final class DataSyncEntityEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .relationshipCreated(relEvent) = event, relEvent.relationship.id == self.relationshipId else { + guard case let .relationshipCreated(relEvent) = event.change, relEvent.relationship.id == self.relationshipId else { return } @@ -309,7 +309,7 @@ final class DataSyncEntityEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .relationshipUpdated(relEvent) = event, relEvent.relationship.id == self.relationshipId else { + guard case let .relationshipUpdated(relEvent) = event.change, relEvent.relationship.id == self.relationshipId else { return } @@ -387,12 +387,12 @@ final class DataSyncEntityEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .relationshipDeleted(relationshipEvent) = event, relationshipEvent.removed.id == self.relationshipId else { + guard case let .relationshipDeleted(relEvent) = event.change, relEvent.removed.id == self.relationshipId else { return } - let removed = relationshipEvent.removed - XCTAssertEqual(relationshipEvent.kind, .custom) + let removed = relEvent.removed + XCTAssertEqual(relEvent.kind, .custom) XCTAssertEqual(removed.id, "swift-rel-attending-carter-alice") XCTAssertEqual(removed.className, HealthcareClass.attendingPhysician.name) diff --git a/Tests/PubNubIntegrationTests/DataSyncMembershipEventIntegrationTests.swift b/Tests/PubNubIntegrationTests/DataSyncMembershipEventIntegrationTests.swift index aa7c8413..d64bdbf1 100644 --- a/Tests/PubNubIntegrationTests/DataSyncMembershipEventIntegrationTests.swift +++ b/Tests/PubNubIntegrationTests/DataSyncMembershipEventIntegrationTests.swift @@ -44,12 +44,12 @@ final class DataSyncMembershipEventIntegrationTests: XCTestCase { ) subscription.onDataSync = { event in - guard case let .relationshipCreated(relationshipEvent) = event, relationshipEvent.relationship.id == membershipId else { + guard case let .relationshipCreated(relEvent) = event.change, relEvent.relationship.id == membershipId else { return } - let relationship = relationshipEvent.relationship - XCTAssertEqual(relationshipEvent.kind, .membership) + let relationship = relEvent.relationship + XCTAssertEqual(relEvent.kind, .membership) XCTAssertEqual(relationship.className, "Membership") XCTAssertEqual(relationship.classVersion, self.membershipClassVersion) XCTAssertEqual(relationship.entityAId, channelId) @@ -125,13 +125,12 @@ final class DataSyncMembershipEventIntegrationTests: XCTestCase { ) subscription.onDataSync = { event in - guard case let .relationshipUpdated(relationshipEvent) = event, - relationshipEvent.relationship.id == membershipId else { + guard case let .relationshipUpdated(relEvent) = event.change, relEvent.relationship.id == membershipId else { return } - let relationship = relationshipEvent.relationship - XCTAssertEqual(relationshipEvent.kind, .membership) + let relationship = relEvent.relationship + XCTAssertEqual(relEvent.kind, .membership) XCTAssertEqual(relationship.className, "Membership") XCTAssertEqual(relationship.classVersion, self.membershipClassVersion) XCTAssertEqual(relationship.entityAId, channelId) @@ -202,7 +201,7 @@ final class DataSyncMembershipEventIntegrationTests: XCTestCase { ) subscription.onDataSync = { event in - guard case let .relationshipDeleted(relationshipEvent) = event, relationshipEvent.removed.id == membershipId else { + guard case let .relationshipDeleted(relationshipEvent) = event.change, relationshipEvent.removed.id == membershipId else { return } diff --git a/Tests/PubNubIntegrationTests/DataSyncUserEventIntegrationTests.swift b/Tests/PubNubIntegrationTests/DataSyncUserEventIntegrationTests.swift index d0284301..b0b127ea 100644 --- a/Tests/PubNubIntegrationTests/DataSyncUserEventIntegrationTests.swift +++ b/Tests/PubNubIntegrationTests/DataSyncUserEventIntegrationTests.swift @@ -36,7 +36,7 @@ final class DataSyncUserEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .entityCreated(entityEvent) = event, entityEvent.entity.id == userId else { + guard case let .entityCreated(entityEvent) = event.change, entityEvent.entity.id == userId else { return } @@ -102,7 +102,7 @@ final class DataSyncUserEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .entityUpdated(entityEvent) = event, entityEvent.entity.id == userId else { + guard case let .entityUpdated(entityEvent) = event.change, entityEvent.entity.id == userId else { return } @@ -165,7 +165,7 @@ final class DataSyncUserEventIntegrationTests: XCTestCase { .subscription() subscription.onDataSync = { event in - guard case let .entityDeleted(entityEvent) = event, entityEvent.removed.id == userId else { + guard case let .entityDeleted(entityEvent) = event.change, entityEvent.removed.id == userId else { return } diff --git a/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift b/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift index 88f3bbe6..0fff9c3b 100644 --- a/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift +++ b/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift @@ -283,12 +283,68 @@ extension SubscribeRouterTests { XCTAssertNil(payload.asPubNubEvent()) } - func test_Subscribe_WithUnrecognizedDataSyncObjectType_DropsEvent() { - XCTAssertNil(mockDataSyncPayload(type: "organization").asPubNubEvent()) + func test_Subscribe_WithUnrecognizedDataSyncObjectType_ReportsUnknownChange() throws { + let event = try XCTUnwrap(mockDataSyncPayload(type: "organization").asPubNubEvent()) + let unknown = try XCTUnwrap(event.dataSyncEvent?.unknown) + + XCTAssertEqual(unknown.type, "organization") + XCTAssertEqual(unknown.event, "create") + XCTAssertEqual(unknown.className, "patient") + XCTAssertEqual(unknown.classLevel, .subKey) + XCTAssertEqual(unknown.classVersion, 1) + } + + func test_Subscribe_WithUnrecognizedDataSyncAction_ReportsUnknownChange() throws { + let event = try XCTUnwrap(mockDataSyncPayload(event: "archive").asPubNubEvent()) + let unknown = try XCTUnwrap(event.dataSyncEvent?.unknown) + + XCTAssertEqual(unknown.type, "entity") + XCTAssertEqual(unknown.event, "archive") } - func test_Subscribe_WithUnrecognizedDataSyncAction_DropsEvent() { - XCTAssertNil(mockDataSyncPayload(event: "archive").asPubNubEvent()) + func test_Subscribe_WithUnknownDataSyncChange_PreservesRawEnvelope() throws { + let event = try XCTUnwrap(mockDataSyncPayload(type: "organization").asPubNubEvent()) + let unknown = try XCTUnwrap(event.dataSyncEvent?.unknown) + + XCTAssertEqual(unknown.metadata?.codableValue[rawValue: "type"] as? String, "organization") + XCTAssertEqual(unknown.payload?.codableValue[rawValue: "id"] as? String, "hcn-patient-alice") + } + + func test_Subscribe_WithNonDataSyncSource_DropsEvent() { + XCTAssertNil(mockDataSyncPayload(source: "objects").asPubNubEvent()) + } + + func test_Subscribe_WithEmptyDataSyncDiscriminators_DropsEvent() { + XCTAssertNil(mockDataSyncPayload(event: "").asPubNubEvent()) + XCTAssertNil(mockDataSyncPayload(type: "").asPubNubEvent()) + } + + func test_Subscribe_WithDataSyncEvent_ReportsChannelAndTimetoken() throws { + let payload = mockDataSyncPayload(channel: "__audit__alice") + let event = try XCTUnwrap(payload.asPubNubEvent()?.dataSyncEvent) + + XCTAssertEqual(event.channel, "__audit__alice") + XCTAssertEqual(event.timetoken, 122412) + XCTAssertNil(event.subscription) + XCTAssertNotNil(event.createdEntity) + } + + func test_Subscribe_WithChannelGroupSubscription_DeliversDataSyncEventWithSubscription() { + let pubnub = PubNub(configuration: config) + let subscription = pubnub.channelGroup("patient-refs").subscription() + let dataSyncExpectation = expectation(description: "Group-delivered DataSync event") + + subscription.onDataSync = { event in + XCTAssertEqual(event.channel, "alice") + XCTAssertEqual(event.subscription, "patient-refs") + dataSyncExpectation.fulfill() + } + + subscription.onPayloadsReceived(payloads: [ + mockDataSyncPayload(channel: "alice", subscription: "patient-refs") + ]) + + wait(for: [dataSyncExpectation], timeout: 0.1) } func test_Subscribe_WithDataSyncEntityKinds_PreservesMetadataTypeForExtendedClasses() throws { @@ -310,8 +366,8 @@ extension SubscribeRouterTests { XCTAssertEqual(PubNubDataSyncRelationshipKind.custom.rawValue, "custom") } - func test_DataSyncAction_MapsToUnknownMessageType() { - XCTAssertEqual(SubscribeMessagePayload.Action.dataSync.asPubNubMessageType, .unknown) + func test_DataSyncAction_MapsToDataSyncMessageType() { + XCTAssertEqual(SubscribeMessagePayload.Action.dataSync.asPubNubMessageType, .dataSync) } } @@ -502,10 +558,11 @@ extension SubscribeRouterTests { // MARK: - DataSync Delivery Through The Subscribe Loop extension SubscribeRouterTests { - func test_Subscribe_WithUnsupportedDataSyncEvent_DropsItFromModernDelivery() { + func test_Subscribe_WithBrokenDataSyncEnvelope_DropsItFromModernDelivery() { let pubnub = PubNub(configuration: config) let subscription = pubnub.channel("test-channel").subscription() let dataSyncExpectation = expectation(description: "Valid DataSync event") + let messageExpectation = expectation(description: "No fallback message") messageExpectation.isInverted = true @@ -517,7 +574,7 @@ extension SubscribeRouterTests { } let events = subscription.onPayloadsReceived(payloads: [ - mockDataSyncPayload(channel: "test-channel", type: "organization"), + generateMessage(with: .dataSync, channel: "test-channel", payload: MalformedDataSyncPayload().codableValue), mockDataSyncPayload(channel: "test-channel") ]) @@ -525,7 +582,7 @@ extension SubscribeRouterTests { wait(for: [dataSyncExpectation, messageExpectation], timeout: 0.1) } - func test_Subscribe_WithUnsupportedDataSyncEvent_DropsItFromLegacyDelivery() { + func test_Subscribe_WithBrokenDataSyncEnvelope_DropsItFromLegacyDelivery() { let listener = SubscriptionListener(queue: .main) let dataSyncExpectation = expectation(description: "Valid DataSync event") let messageExpectation = expectation(description: "No fallback message") @@ -539,13 +596,56 @@ extension SubscribeRouterTests { } listener.emit(batch: [ - mockDataSyncPayload(type: "organization"), + generateMessage(with: .dataSync, payload: MalformedDataSyncPayload().codableValue), mockDataSyncPayload() ]) wait(for: [dataSyncExpectation, messageExpectation], timeout: 0.1) } + func test_Subscribe_WithUnknownDataSyncChange_DeliversItToModernListener() { + let pubnub = PubNub(configuration: config) + let subscription = pubnub.channel("test-channel").subscription() + let dataSyncExpectation = expectation(description: "Unknown DataSync change") + let messageExpectation = expectation(description: "No fallback message") + messageExpectation.isInverted = true + + subscription.onDataSync = { event in + if case .unknown = event.change { + dataSyncExpectation.fulfill() + } + } + subscription.onMessage = { _ in + messageExpectation.fulfill() + } + + subscription.onPayloadsReceived(payloads: [ + mockDataSyncPayload(channel: "test-channel", type: "organization") + ]) + + wait(for: [dataSyncExpectation, messageExpectation], timeout: 0.1) + } + + func test_Subscribe_WithUnknownDataSyncChange_DeliversItToLegacyListener() { + let listener = SubscriptionListener(queue: .main) + let dataSyncExpectation = expectation(description: "Unknown DataSync change") + let messageExpectation = expectation(description: "No fallback message") + messageExpectation.isInverted = true + + listener.didReceiveDataSyncEvent = { event in + if case .unknown = event.change { + dataSyncExpectation.fulfill() + } + } + listener.didReceiveMessage = { _ in + messageExpectation.fulfill() + } + + listener.emit(batch: [mockDataSyncPayload(event: "archive")]) + + wait(for: [dataSyncExpectation, messageExpectation], timeout: 0.1) + } + func test_Subscribe_WithDataSyncEntityCreateEvent_EmitsToListeners() throws { let events = try emittedDataSyncEvents( fromFixture: "subscription_dataSyncEntityCreate_success", @@ -812,50 +912,60 @@ private extension SubscribeRouterTests { private extension PubNubDataSyncEvent { var createdEntity: PubNubDataSyncEntity? { - guard case let .entityCreated(entityEvent) = self else { return nil } + guard case let .entityCreated(entityEvent) = change else { return nil } return entityEvent.entity } var updatedEntity: PubNubDataSyncEntity? { - guard case let .entityUpdated(entityEvent) = self else { return nil } + guard case let .entityUpdated(entityEvent) = change else { return nil } return entityEvent.entity } var deletedEntity: PubNubDataSyncRemovedEntity? { - guard case let .entityDeleted(entityEvent) = self else { return nil } + guard case let .entityDeleted(entityEvent) = change else { return nil } return entityEvent.removed } var createdRelationship: PubNubDataSyncRelationship? { - guard case let .relationshipCreated(relationshipEvent) = self else { return nil } + guard case let .relationshipCreated(relationshipEvent) = change else { return nil } return relationshipEvent.relationship } var updatedRelationship: PubNubDataSyncRelationship? { - guard case let .relationshipUpdated(relationshipEvent) = self else { return nil } + guard case let .relationshipUpdated(relationshipEvent) = change else { return nil } return relationshipEvent.relationship } var deletedRelationship: PubNubDataSyncRemovedRelationship? { - guard case let .relationshipDeleted(relationshipEvent) = self else { return nil } + guard case let .relationshipDeleted(relationshipEvent) = change else { return nil } return relationshipEvent.removed } + + var unknown: PubNubDataSyncUnknownEvent? { + guard case let .unknown(unknownEvent) = change else { return nil } + return unknownEvent + } } // MARK: Helpers private extension PubNubEvent { + var dataSyncEvent: PubNubDataSyncEvent? { + guard case let .dataSyncChanged(dataSyncEvent) = self else { return nil } + return dataSyncEvent + } + var createdDataSyncEntity: PubNubDataSyncEntity? { createdDataSyncEntityEvent?.entity } var updatedDataSyncEntity: PubNubDataSyncEntity? { - guard case let .dataSyncChanged(.entityUpdated(entityEvent)) = self else { return nil } + guard case let .entityUpdated(entityEvent)? = dataSyncEvent?.change else { return nil } return entityEvent.entity } var deletedDataSyncEntity: PubNubDataSyncRemovedEntity? { - guard case let .dataSyncChanged(.entityDeleted(entityEvent)) = self else { return nil } + guard case let .entityDeleted(entityEvent)? = dataSyncEvent?.change else { return nil } return entityEvent.removed } @@ -864,22 +974,22 @@ private extension PubNubEvent { } var updatedDataSyncRelationship: PubNubDataSyncRelationship? { - guard case let .dataSyncChanged(.relationshipUpdated(relationshipEvent)) = self else { return nil } + guard case let .relationshipUpdated(relationshipEvent)? = dataSyncEvent?.change else { return nil } return relationshipEvent.relationship } var deletedDataSyncRelationship: PubNubDataSyncRemovedRelationship? { - guard case let .dataSyncChanged(.relationshipDeleted(relationshipEvent)) = self else { return nil } + guard case let .relationshipDeleted(relationshipEvent)? = dataSyncEvent?.change else { return nil } return relationshipEvent.removed } var createdDataSyncEntityEvent: PubNubDataSyncEntityEvent? { - guard case let .dataSyncChanged(.entityCreated(entityEvent)) = self else { return nil } + guard case let .entityCreated(entityEvent)? = dataSyncEvent?.change else { return nil } return entityEvent } var createdDataSyncRelationshipEvent: PubNubDataSyncRelationshipEvent? { - guard case let .dataSyncChanged(.relationshipCreated(relationshipEvent)) = self else { return nil } + guard case let .relationshipCreated(relationshipEvent)? = dataSyncEvent?.change else { return nil } return relationshipEvent } } diff --git a/Tests/PubNubUnitTests/Subscribe/Legacy/SubscriptionStreamTests.swift b/Tests/PubNubUnitTests/Subscribe/Legacy/SubscriptionStreamTests.swift index 1a2e6f5a..cdd0eef0 100644 --- a/Tests/PubNubUnitTests/Subscribe/Legacy/SubscriptionStreamTests.swift +++ b/Tests/PubNubUnitTests/Subscribe/Legacy/SubscriptionStreamTests.swift @@ -110,7 +110,7 @@ class SubscriptionListenerTests: XCTestCase { let listener = SubscriptionListener() listener.didReceiveDataSyncEvent = { event in - if case .entityCreated = event { + if case .entityCreated = event.change { expectation.fulfill() } } diff --git a/Tests/PubNubUnitTests/Support/Factories/SubscribePayloadFactory.swift b/Tests/PubNubUnitTests/Support/Factories/SubscribePayloadFactory.swift index ebaf7a95..395e024f 100644 --- a/Tests/PubNubUnitTests/Support/Factories/SubscribePayloadFactory.swift +++ b/Tests/PubNubUnitTests/Support/Factories/SubscribePayloadFactory.swift @@ -113,7 +113,9 @@ func mockPresenceChangePayload( func mockDataSyncPayload( channel: String = "channel", + subscription: String? = nil, event: String = "create", + source: String = "data-sync", type: String = "entity", className: String = "patient", classLevel: String = "SubKey", @@ -129,12 +131,13 @@ func mockDataSyncPayload( ) -> SubscribeMessagePayload { generateMessage( with: .dataSync, + subscription: subscription, channel: channel, payload: AnyJSON([ "version": "1.0", "metadata": [ "event": event, - "source": "data-sync", + "source": source, "type": type, "className": className, "classLevel": classLevel, From 269e6a65e2a16754609083928fe958b09c2cbe13 Mon Sep 17 00:00:00 2001 From: jguz-pubnub Date: Tue, 6 Oct 2026 19:37:25 +0200 Subject: [PATCH 3/5] Fixes according to review (2) --- .../SubscribeDataSyncPayload.swift | 6 +- .../Subscribe/API/PubNubDataSyncEvent.swift | 2 - .../SubscribeMessagePayload+PubNubEvent.swift | 7 ++- .../SubscribeRouterTests+DataSync.swift | 57 ++++++++++++------- 4 files changed, 44 insertions(+), 28 deletions(-) diff --git a/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift b/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift index 9cbed09f..4a09e672 100644 --- a/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift +++ b/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift @@ -67,8 +67,8 @@ extension SubscribeDataSyncPayload: Decodable { let rawAction = try metadata.decode(String.self, forKey: .event) let rawType = try metadata.decode(String.self, forKey: .type) - // Anything that isn't recognizably a DataSync envelope is discarded rather than reported - // as an unknown change, so `PubNubDataSyncUnknownEvent` only ever describes a DataSync change + // Anything that isn't recognizably a DataSync envelope is rejected rather than reported as an + // unknown change, so `PubNubDataSyncUnknownEvent` only ever describes a DataSync change guard source == "data-sync", !rawAction.isEmpty, !rawType.isEmpty else { throw DecodingError.dataCorruptedError( forKey: .metadata, @@ -78,7 +78,7 @@ extension SubscribeDataSyncPayload: Decodable { } // An object type or action added to the service after this SDK version was released is - // surfaced with its envelope intact instead of being dropped + // surfaced as a DataSync change with its envelope intact, rather than falling back to a message guard let action = Action(rawValue: rawAction), let type = ObjectType(rawValue: rawType) else { change = .unknown( PubNubDataSyncUnknownEvent( diff --git a/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift b/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift index 28f7cf4e..78a12783 100644 --- a/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift +++ b/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift @@ -91,8 +91,6 @@ public struct PubNubDataSyncEvent: Hashable { } /// A DataSync change whose object type or action this version of the SDK doesn't recognize. -/// -/// A change that is recognized but malformed isn't reported here; it's discarded. public struct PubNubDataSyncUnknownEvent: Hashable { /// The object type received from the server, taken from `metadata.type` public let type: String diff --git a/Sources/PubNub/Subscribe/Mapping/SubscribeMessagePayload+PubNubEvent.swift b/Sources/PubNub/Subscribe/Mapping/SubscribeMessagePayload+PubNubEvent.swift index 78a4fe27..d0451957 100644 --- a/Sources/PubNub/Subscribe/Mapping/SubscribeMessagePayload+PubNubEvent.swift +++ b/Sources/PubNub/Subscribe/Mapping/SubscribeMessagePayload+PubNubEvent.swift @@ -43,9 +43,10 @@ extension SubscribeMessagePayload { } return .fileChanged(.uploaded(fileMessage)) case .dataSync: - // Discard DataSync envelopes this SDK cannot decode, including events - // with metadata types or actions this SDK version does not recognize - return try? .dataSyncChanged(decodeDataSyncEvent()) + guard let event = try? decodeDataSyncEvent() else { + return .messageReceived(PubNubMessageBase(from: self)) + } + return .dataSyncChanged(event) case .presence: guard let presence = PubNubPresenceChangeBase(from: self) else { return .messageReceived(PubNubMessageBase(from: self)) diff --git a/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift b/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift index 0fff9c3b..bd337c7e 100644 --- a/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift +++ b/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift @@ -261,7 +261,7 @@ extension SubscribeRouterTests { XCTAssertNil(entity.payload) } - func test_Subscribe_WithDataSyncMissingRequiredFields_DropsEvent() { + func test_Subscribe_WithDataSyncMissingRequiredFields_FallsBackToMessage() throws { for missingField in ["eTag"] { var data: [String: Any] = [ "id": "hcn-patient-alice", @@ -273,14 +273,29 @@ extension SubscribeRouterTests { data.removeValue(forKey: missingField) let payload = mockDataSyncPayload(data: data) + let event = try XCTUnwrap(payload.asPubNubEvent(), "An envelope missing \(missingField) should be reported") + let message = try XCTUnwrap(event.message, "An envelope missing \(missingField) should fall back to a message") - XCTAssertNil(payload.asPubNubEvent(), "An envelope missing \(missingField) should be dropped") + XCTAssertEqual(message.messageType, .dataSync) + XCTAssertNil(event.dataSyncEvent) } } - func test_Subscribe_WithMalformedDataSyncEnvelope_DropsEvent() { - let payload = generateMessage(with: .dataSync, payload: MalformedDataSyncPayload().codableValue) - XCTAssertNil(payload.asPubNubEvent()) + func test_Subscribe_WithMalformedDataSyncEnvelope_FallsBackToMessage() throws { + let payload = generateMessage( + with: .dataSync, + subscription: "patient-refs", + channel: "alice", + payload: MalformedDataSyncPayload().codableValue + ) + let message = try XCTUnwrap(payload.asPubNubEvent()?.message) + + XCTAssertEqual(message.messageType, .dataSync) + XCTAssertEqual(message.payload.codableValue, MalformedDataSyncPayload().codableValue) + XCTAssertEqual(message.channel, "alice") + XCTAssertEqual(message.subscription, "patient-refs") + XCTAssertEqual(message.published, 122412) + XCTAssertEqual(message.publisher, "publisher") } func test_Subscribe_WithUnrecognizedDataSyncObjectType_ReportsUnknownChange() throws { @@ -310,13 +325,14 @@ extension SubscribeRouterTests { XCTAssertEqual(unknown.payload?.codableValue[rawValue: "id"] as? String, "hcn-patient-alice") } - func test_Subscribe_WithNonDataSyncSource_DropsEvent() { - XCTAssertNil(mockDataSyncPayload(source: "objects").asPubNubEvent()) + func test_Subscribe_WithNonDataSyncSource_FallsBackToMessage() throws { + let message = try XCTUnwrap(mockDataSyncPayload(source: "objects").asPubNubEvent()?.message) + XCTAssertEqual(message.messageType, .dataSync) } - func test_Subscribe_WithEmptyDataSyncDiscriminators_DropsEvent() { - XCTAssertNil(mockDataSyncPayload(event: "").asPubNubEvent()) - XCTAssertNil(mockDataSyncPayload(type: "").asPubNubEvent()) + func test_Subscribe_WithEmptyDataSyncDiscriminators_FallsBackToMessage() throws { + XCTAssertNotNil(try XCTUnwrap(mockDataSyncPayload(event: "").asPubNubEvent()).message) + XCTAssertNotNil(try XCTUnwrap(mockDataSyncPayload(type: "").asPubNubEvent()).message) } func test_Subscribe_WithDataSyncEvent_ReportsChannelAndTimetoken() throws { @@ -558,18 +574,17 @@ extension SubscribeRouterTests { // MARK: - DataSync Delivery Through The Subscribe Loop extension SubscribeRouterTests { - func test_Subscribe_WithBrokenDataSyncEnvelope_DropsItFromModernDelivery() { + func test_Subscribe_WithBrokenDataSyncEnvelope_DeliversItAsMessageToModernListener() { let pubnub = PubNub(configuration: config) let subscription = pubnub.channel("test-channel").subscription() let dataSyncExpectation = expectation(description: "Valid DataSync event") - - let messageExpectation = expectation(description: "No fallback message") - messageExpectation.isInverted = true + let messageExpectation = expectation(description: "Fallback message") subscription.onDataSync = { _ in dataSyncExpectation.fulfill() } - subscription.onMessage = { _ in + subscription.onMessage = { message in + XCTAssertEqual(message.messageType, .dataSync) messageExpectation.fulfill() } @@ -578,20 +593,20 @@ extension SubscribeRouterTests { mockDataSyncPayload(channel: "test-channel") ]) - XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.count, 2) wait(for: [dataSyncExpectation, messageExpectation], timeout: 0.1) } - func test_Subscribe_WithBrokenDataSyncEnvelope_DropsItFromLegacyDelivery() { + func test_Subscribe_WithBrokenDataSyncEnvelope_DeliversItAsMessageToLegacyListener() { let listener = SubscriptionListener(queue: .main) let dataSyncExpectation = expectation(description: "Valid DataSync event") - let messageExpectation = expectation(description: "No fallback message") - messageExpectation.isInverted = true + let messageExpectation = expectation(description: "Fallback message") listener.didReceiveDataSyncEvent = { _ in dataSyncExpectation.fulfill() } - listener.didReceiveMessage = { _ in + listener.didReceiveMessage = { message in + XCTAssertEqual(message.messageType, .dataSync) messageExpectation.fulfill() } @@ -993,3 +1008,5 @@ private extension PubNubEvent { return relationshipEvent } } + +// swiftlint:disable:this file_length From 7db45bf720f1d0900c287045b04827ad8fe25df8 Mon Sep 17 00:00:00 2001 From: jguz-pubnub Date: Wed, 7 Oct 2026 16:01:01 +0200 Subject: [PATCH 4/5] Fix build issues + fixes according to review --- .../Example-iOS.xcodeproj/project.pbxproj | 4 +- .../Sources/DetailTableViewController.swift | 26 ++++++++---- .../KMP/Wrappers/KMPDataSyncEvent.swift | 42 ++++++------------- .../SubscribeDataSyncPayload.swift | 5 ++- .../Subscribe/API/PubNubDataSyncEvent.swift | 30 ++++--------- .../SubscribeRouterTests+DataSync.swift | 30 ++++++------- 6 files changed, 59 insertions(+), 78 deletions(-) diff --git a/Examples/Example-iOS/Example-iOS.xcodeproj/project.pbxproj b/Examples/Example-iOS/Example-iOS.xcodeproj/project.pbxproj index 8aca4138..fe5d6e4d 100644 --- a/Examples/Example-iOS/Example-iOS.xcodeproj/project.pbxproj +++ b/Examples/Example-iOS/Example-iOS.xcodeproj/project.pbxproj @@ -340,7 +340,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -400,7 +400,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; diff --git a/Examples/Example-iOS/Sources/DetailTableViewController.swift b/Examples/Example-iOS/Sources/DetailTableViewController.swift index 5e1eaf0b..6b58d163 100644 --- a/Examples/Example-iOS/Sources/DetailTableViewController.swift +++ b/Examples/Example-iOS/Sources/DetailTableViewController.swift @@ -328,18 +328,26 @@ class DetailTableViewController: UITableViewController { } self.listener?.didReceiveDataSyncEvent = { dataSyncEvent in - switch dataSyncEvent { - case let .entityCreated(entity), let .entityUpdated(entity): - print("The \(entity.className) entity \(entity.id) was created or updated at \(entity.updatedAt)") + print("Received on \(dataSyncEvent.channel) at \(dataSyncEvent.timetoken)") + + switch dataSyncEvent.change { + case let .entityCreated(entityEvent), let .entityUpdated(entityEvent): + let entity = entityEvent.entity + print("The \(entity.className) \(entityEvent.kind) \(entity.id) was created or updated at \(entity.updatedAt)") print("Its revision is \(entity.eTag) and it expires at \(entity.expiresAt)") print("Payload: \(entity.payload ?? "none")") - case let .relationshipCreated(relationship), let .relationshipUpdated(relationship): - print("The \(relationship.className) relationship \(relationship.id) was created or updated") + case let .relationshipCreated(relationshipEvent), let .relationshipUpdated(relationshipEvent): + let relationship = relationshipEvent.relationship + print("The \(relationship.className) \(relationshipEvent.kind) \(relationship.id) was created or updated") print("It connects entity \(relationship.entityAId) to entity \(relationship.entityBId)") - case let .entityDeleted(removed): - print("The \(removed.className) entity \(removed.id) was deleted at \(removed.deletedAt)") - case let .relationshipDeleted(removed): - print("The \(removed.className) relationship \(removed.id) was deleted at \(removed.deletedAt)") + case let .entityDeleted(entityEvent): + let removed = entityEvent.removed + print("The \(removed.className) \(entityEvent.kind) \(removed.id) was deleted at \(removed.deletedAt)") + case let .relationshipDeleted(relationshipEvent): + let removed = relationshipEvent.removed + print("The \(removed.className) \(relationshipEvent.kind) \(removed.id) was deleted at \(removed.deletedAt)") + case let .unknown(unknownEvent): + print("Unrecognized \(unknownEvent.type) change: \(unknownEvent.event)") } } diff --git a/Sources/PubNub/KMP/Wrappers/KMPDataSyncEvent.swift b/Sources/PubNub/KMP/Wrappers/KMPDataSyncEvent.swift index dc29c9db..b410091c 100644 --- a/Sources/PubNub/KMP/Wrappers/KMPDataSyncEvent.swift +++ b/Sources/PubNub/KMP/Wrappers/KMPDataSyncEvent.swift @@ -67,11 +67,11 @@ public class KMPDataSyncEntityUpdatedResult: KMPDataSyncEvent { @objc public class KMPDataSyncEntityDeletedResult: KMPDataSyncEvent { @objc public let kind: String - @objc public let removedEntity: KMPDataSyncRemovedEntity + @objc public let removedObject: KMPDataSyncRemovedObject init(entityEvent: PubNubDataSyncEntityDeletedEvent, channel: String, subscription: String?, timetoken: Timetoken) { self.kind = entityEvent.kind.rawValue - self.removedEntity = KMPDataSyncRemovedEntity(removedEntity: entityEvent.removed) + self.removedObject = KMPDataSyncRemovedObject(removedObject: entityEvent.removed) super.init(channel: channel, subscription: subscription, timetoken: timetoken) } @@ -112,11 +112,11 @@ public class KMPDataSyncRelationshipUpdatedResult: KMPDataSyncEvent { @objc public class KMPDataSyncRelationshipDeletedResult: KMPDataSyncEvent { @objc public let kind: String - @objc public let removedRelationship: KMPDataSyncRemovedRelationship + @objc public let removedObject: KMPDataSyncRemovedObject init(relationshipEvent: PubNubDataSyncRelationshipDeletedEvent, channel: String, subscription: String?, timetoken: Timetoken) { self.kind = relationshipEvent.kind.rawValue - self.removedRelationship = KMPDataSyncRemovedRelationship(removedRelationship: relationshipEvent.removed) + self.removedObject = KMPDataSyncRemovedObject(removedObject: relationshipEvent.removed) super.init(channel: channel, subscription: subscription, timetoken: timetoken) } @@ -148,10 +148,10 @@ public class KMPDataSyncUnknownResult: KMPDataSyncEvent { } } -// MARK: - KMPDataSyncRemovedEntity +// MARK: - KMPDataSyncRemovedObject @objc -public class KMPDataSyncRemovedEntity: NSObject { +public class KMPDataSyncRemovedObject: NSObject { @objc public let id: String // Named `objectClass` rather than `className` because `NSObject` already exposes a `className` selector @objc public let objectClass: String @@ -159,30 +159,12 @@ public class KMPDataSyncRemovedEntity: NSObject { @objc public let classVersion: Int @objc public let deletedAt: Date - init(removedEntity: PubNubDataSyncRemovedEntity) { - self.id = removedEntity.id - self.objectClass = removedEntity.className - self.classLevel = removedEntity.classLevel.stringValue - self.classVersion = removedEntity.classVersion - self.deletedAt = removedEntity.deletedAt - } -} - -// MARK: - KMPDataSyncRemovedRelationship - -@objc -public class KMPDataSyncRemovedRelationship: NSObject { - @objc public let id: String - // Named `objectClass` rather than `className` because `NSObject` already exposes a `className` selector - @objc public let objectClass: String - @objc public let classVersion: Int - @objc public let deletedAt: Date - - init(removedRelationship: PubNubDataSyncRemovedRelationship) { - self.id = removedRelationship.id - self.objectClass = removedRelationship.className - self.classVersion = removedRelationship.classVersion - self.deletedAt = removedRelationship.deletedAt + init(removedObject: PubNubDataSyncRemovedObject) { + self.id = removedObject.id + self.objectClass = removedObject.className + self.classLevel = removedObject.classLevel.stringValue + self.classVersion = removedObject.classVersion + self.deletedAt = removedObject.deletedAt } } diff --git a/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift b/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift index 4a09e672..a77541b8 100644 --- a/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift +++ b/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift @@ -150,7 +150,7 @@ extension SubscribeDataSyncPayload: Decodable { change = action == .create ? .relationshipCreated(relationshipEvent) : .relationshipUpdated(relationshipEvent) case (.entity, .delete), (.user, .delete), (.channel, .delete): - let removed = PubNubDataSyncRemovedEntity( + let removed = PubNubDataSyncRemovedObject( id: identifier, className: className, classLevel: classLevel, @@ -168,9 +168,10 @@ extension SubscribeDataSyncPayload: Decodable { change = .relationshipDeleted( PubNubDataSyncRelationshipDeletedEvent( kind: type.relationshipKind, - removed: PubNubDataSyncRemovedRelationship( + removed: PubNubDataSyncRemovedObject( id: identifier, className: className, + classLevel: classLevel, classVersion: classVersion, deletedAt: try data.decode(Date.self, forKey: .deletedAt) ) diff --git a/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift b/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift index 78a12783..dc35d6f9 100644 --- a/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift +++ b/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift @@ -41,7 +41,7 @@ public struct PubNubDataSyncEntityDeletedEvent: Hashable { /// The entity type public let kind: PubNubDataSyncEntityKind /// Information identifying the deleted entity - public let removed: PubNubDataSyncRemovedEntity + public let removed: PubNubDataSyncRemovedObject } /// A created or updated DataSync relationship event. @@ -57,7 +57,7 @@ public struct PubNubDataSyncRelationshipDeletedEvent: Hashable { /// The relationship type public let kind: PubNubDataSyncRelationshipKind /// Information identifying the deleted relationship - public let removed: PubNubDataSyncRemovedRelationship + public let removed: PubNubDataSyncRemovedObject } /// A DataSync change received over subscribe. @@ -129,28 +129,16 @@ public struct PubNubDataSyncUnknownEvent: Hashable { } } -/// A DataSync entity that was deleted. -public struct PubNubDataSyncRemovedEntity: Hashable { - /// The unique identifier of the deleted entity +/// A DataSync object, either an entity or a relationship, that was deleted. +public struct PubNubDataSyncRemovedObject: Hashable { + /// The unique identifier of the deleted object public let id: String - /// The name of the deleted entity's class + /// The name of the deleted object's class public let className: String - /// The level the deleted entity's class is registered at + /// The level the deleted object's class is registered at public let classLevel: PubNubDataSyncClassLevel - /// The version of the deleted entity's class + /// The version of the deleted object's class public let classVersion: Int - /// The date the entity was deleted - public let deletedAt: Date -} - -/// A DataSync relationship that was deleted. -public struct PubNubDataSyncRemovedRelationship: Hashable { - /// The unique identifier of the deleted relationship - public let id: String - /// The name of the deleted relationship's class - public let className: String - /// The version of the deleted relationship's class - public let classVersion: Int - /// The date the relationship was deleted + /// The date the object was deleted public let deletedAt: Date } diff --git a/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift b/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift index bd337c7e..202fd4be 100644 --- a/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift +++ b/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift @@ -102,8 +102,8 @@ extension SubscribeRouterTests { XCTAssertPayload(entity.payload, equals: expectedPayload) } - func test_Subscribe_WithDataSyncEntityDeleteEvent_ReceivesRemovedEntity() throws { - let expectedRemoval = PubNubDataSyncRemovedEntity( + func test_Subscribe_WithDataSyncEntityDeleteEvent_ReceivesRemovedObject() throws { + let expectedRemoval = PubNubDataSyncRemovedObject( id: "hcn-patient-dubois", className: "patient", classLevel: .subKey, @@ -191,10 +191,11 @@ extension SubscribeRouterTests { XCTAssertPayload(relationship.payload, equals: expectedPayload) } - func test_Subscribe_WithDataSyncRelationshipDeleteEvent_ReceivesRemovedRelationship() throws { - let expectedRemoval = PubNubDataSyncRemovedRelationship( + func test_Subscribe_WithDataSyncRelationshipDeleteEvent_ReceivesRemovedObject() throws { + let expectedRemoval = PubNubDataSyncRemovedObject( id: "hcn-rel-attending-tanaka-dubois", className: "attending-physician", + classLevel: .subKey, classVersion: 1, deletedAt: try XCTUnwrap(DateFormatter.iso8601.date(from: "2026-07-28T09:07:47.243657Z")) ) @@ -439,13 +440,13 @@ extension SubscribeRouterTests { ) } - func test_Subscribe_WithDataSyncUserDeleteEvent_ReceivesRemovedEntity() throws { + func test_Subscribe_WithDataSyncUserDeleteEvent_ReceivesRemovedObject() throws { let event = try decodeEvent(from: "subscription_dataSyncUserDelete_success") let removed = try XCTUnwrap(event.deletedDataSyncEntity) XCTAssertEqual( removed, - PubNubDataSyncRemovedEntity( + PubNubDataSyncRemovedObject( id: "alice", className: "User", classLevel: .global, @@ -490,13 +491,13 @@ extension SubscribeRouterTests { ) } - func test_Subscribe_WithDataSyncChannelDeleteEvent_ReceivesRemovedEntity() throws { + func test_Subscribe_WithDataSyncChannelDeleteEvent_ReceivesRemovedObject() throws { let event = try decodeEvent(from: "subscription_dataSyncChannelDelete_success") let removed = try XCTUnwrap(event.deletedDataSyncEntity) XCTAssertEqual( removed, - PubNubDataSyncRemovedEntity( + PubNubDataSyncRemovedObject( id: "general", className: "Channel", classLevel: .global, @@ -555,15 +556,16 @@ extension SubscribeRouterTests { ) } - func test_Subscribe_WithDataSyncMembershipDeleteEvent_ReceivesRemovedRelationship() throws { + func test_Subscribe_WithDataSyncMembershipDeleteEvent_ReceivesRemovedObject() throws { let event = try decodeEvent(from: "subscription_dataSyncMembershipDelete_success") let removed = try XCTUnwrap(event.deletedDataSyncRelationship) XCTAssertEqual( removed, - PubNubDataSyncRemovedRelationship( + PubNubDataSyncRemovedObject( id: "general__alice", className: "Membership", + classLevel: .global, classVersion: 1, deletedAt: try XCTUnwrap(DateFormatter.iso8601.date(from: "2026-08-13T16:05:15.029431Z")) ) @@ -936,7 +938,7 @@ private extension PubNubDataSyncEvent { return entityEvent.entity } - var deletedEntity: PubNubDataSyncRemovedEntity? { + var deletedEntity: PubNubDataSyncRemovedObject? { guard case let .entityDeleted(entityEvent) = change else { return nil } return entityEvent.removed } @@ -951,7 +953,7 @@ private extension PubNubDataSyncEvent { return relationshipEvent.relationship } - var deletedRelationship: PubNubDataSyncRemovedRelationship? { + var deletedRelationship: PubNubDataSyncRemovedObject? { guard case let .relationshipDeleted(relationshipEvent) = change else { return nil } return relationshipEvent.removed } @@ -979,7 +981,7 @@ private extension PubNubEvent { return entityEvent.entity } - var deletedDataSyncEntity: PubNubDataSyncRemovedEntity? { + var deletedDataSyncEntity: PubNubDataSyncRemovedObject? { guard case let .entityDeleted(entityEvent)? = dataSyncEvent?.change else { return nil } return entityEvent.removed } @@ -993,7 +995,7 @@ private extension PubNubEvent { return relationshipEvent.relationship } - var deletedDataSyncRelationship: PubNubDataSyncRemovedRelationship? { + var deletedDataSyncRelationship: PubNubDataSyncRemovedObject? { guard case let .relationshipDeleted(relationshipEvent)? = dataSyncEvent?.change else { return nil } return relationshipEvent.removed } From 5ba4b22589d11992dca9b11ed662fb10c1f4f5b0 Mon Sep 17 00:00:00 2001 From: jguz-pubnub Date: Thu, 8 Oct 2026 11:56:40 +0200 Subject: [PATCH 5/5] PubNubDataSyncRemovedEntity + PubNubDataSyncRemovedRelationship --- .../KMP/Wrappers/KMPDataSyncEvent.swift | 44 ++++++++++++++----- .../SubscribeDataSyncPayload.swift | 4 +- .../Subscribe/API/PubNubDataSyncEvent.swift | 32 ++++++++++---- .../SubscribeRouterTests+DataSync.swift | 28 ++++++------ 4 files changed, 71 insertions(+), 37 deletions(-) diff --git a/Sources/PubNub/KMP/Wrappers/KMPDataSyncEvent.swift b/Sources/PubNub/KMP/Wrappers/KMPDataSyncEvent.swift index b410091c..6bc85a21 100644 --- a/Sources/PubNub/KMP/Wrappers/KMPDataSyncEvent.swift +++ b/Sources/PubNub/KMP/Wrappers/KMPDataSyncEvent.swift @@ -67,11 +67,11 @@ public class KMPDataSyncEntityUpdatedResult: KMPDataSyncEvent { @objc public class KMPDataSyncEntityDeletedResult: KMPDataSyncEvent { @objc public let kind: String - @objc public let removedObject: KMPDataSyncRemovedObject + @objc public let removedEntity: KMPDataSyncRemovedEntity init(entityEvent: PubNubDataSyncEntityDeletedEvent, channel: String, subscription: String?, timetoken: Timetoken) { self.kind = entityEvent.kind.rawValue - self.removedObject = KMPDataSyncRemovedObject(removedObject: entityEvent.removed) + self.removedEntity = KMPDataSyncRemovedEntity(removedEntity: entityEvent.removed) super.init(channel: channel, subscription: subscription, timetoken: timetoken) } @@ -112,11 +112,11 @@ public class KMPDataSyncRelationshipUpdatedResult: KMPDataSyncEvent { @objc public class KMPDataSyncRelationshipDeletedResult: KMPDataSyncEvent { @objc public let kind: String - @objc public let removedObject: KMPDataSyncRemovedObject + @objc public let removedRelationship: KMPDataSyncRemovedRelationship init(relationshipEvent: PubNubDataSyncRelationshipDeletedEvent, channel: String, subscription: String?, timetoken: Timetoken) { self.kind = relationshipEvent.kind.rawValue - self.removedObject = KMPDataSyncRemovedObject(removedObject: relationshipEvent.removed) + self.removedRelationship = KMPDataSyncRemovedRelationship(removedRelationship: relationshipEvent.removed) super.init(channel: channel, subscription: subscription, timetoken: timetoken) } @@ -148,10 +148,10 @@ public class KMPDataSyncUnknownResult: KMPDataSyncEvent { } } -// MARK: - KMPDataSyncRemovedObject +// MARK: - KMPDataSyncRemovedEntity @objc -public class KMPDataSyncRemovedObject: NSObject { +public class KMPDataSyncRemovedEntity: NSObject { @objc public let id: String // Named `objectClass` rather than `className` because `NSObject` already exposes a `className` selector @objc public let objectClass: String @@ -159,12 +159,32 @@ public class KMPDataSyncRemovedObject: NSObject { @objc public let classVersion: Int @objc public let deletedAt: Date - init(removedObject: PubNubDataSyncRemovedObject) { - self.id = removedObject.id - self.objectClass = removedObject.className - self.classLevel = removedObject.classLevel.stringValue - self.classVersion = removedObject.classVersion - self.deletedAt = removedObject.deletedAt + init(removedEntity: PubNubDataSyncRemovedEntity) { + self.id = removedEntity.id + self.objectClass = removedEntity.className + self.classLevel = removedEntity.classLevel.stringValue + self.classVersion = removedEntity.classVersion + self.deletedAt = removedEntity.deletedAt + } +} + +// MARK: - KMPDataSyncRemovedRelationship + +@objc +public class KMPDataSyncRemovedRelationship: NSObject { + @objc public let id: String + // Named `objectClass` rather than `className` because `NSObject` already exposes a `className` selector + @objc public let objectClass: String + @objc public let classLevel: String + @objc public let classVersion: Int + @objc public let deletedAt: Date + + init(removedRelationship: PubNubDataSyncRemovedRelationship) { + self.id = removedRelationship.id + self.objectClass = removedRelationship.className + self.classLevel = removedRelationship.classLevel.stringValue + self.classVersion = removedRelationship.classVersion + self.deletedAt = removedRelationship.deletedAt } } diff --git a/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift b/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift index a77541b8..f23679dc 100644 --- a/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift +++ b/Sources/PubNub/Networking/Routers/Subscribe Payloads/SubscribeDataSyncPayload.swift @@ -150,7 +150,7 @@ extension SubscribeDataSyncPayload: Decodable { change = action == .create ? .relationshipCreated(relationshipEvent) : .relationshipUpdated(relationshipEvent) case (.entity, .delete), (.user, .delete), (.channel, .delete): - let removed = PubNubDataSyncRemovedObject( + let removed = PubNubDataSyncRemovedEntity( id: identifier, className: className, classLevel: classLevel, @@ -168,7 +168,7 @@ extension SubscribeDataSyncPayload: Decodable { change = .relationshipDeleted( PubNubDataSyncRelationshipDeletedEvent( kind: type.relationshipKind, - removed: PubNubDataSyncRemovedObject( + removed: PubNubDataSyncRemovedRelationship( id: identifier, className: className, classLevel: classLevel, diff --git a/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift b/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift index dc35d6f9..d5e878ce 100644 --- a/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift +++ b/Sources/PubNub/Subscribe/API/PubNubDataSyncEvent.swift @@ -41,7 +41,7 @@ public struct PubNubDataSyncEntityDeletedEvent: Hashable { /// The entity type public let kind: PubNubDataSyncEntityKind /// Information identifying the deleted entity - public let removed: PubNubDataSyncRemovedObject + public let removed: PubNubDataSyncRemovedEntity } /// A created or updated DataSync relationship event. @@ -57,7 +57,7 @@ public struct PubNubDataSyncRelationshipDeletedEvent: Hashable { /// The relationship type public let kind: PubNubDataSyncRelationshipKind /// Information identifying the deleted relationship - public let removed: PubNubDataSyncRemovedObject + public let removed: PubNubDataSyncRemovedRelationship } /// A DataSync change received over subscribe. @@ -129,16 +129,30 @@ public struct PubNubDataSyncUnknownEvent: Hashable { } } -/// A DataSync object, either an entity or a relationship, that was deleted. -public struct PubNubDataSyncRemovedObject: Hashable { - /// The unique identifier of the deleted object +/// A DataSync entity that was deleted. +public struct PubNubDataSyncRemovedEntity: Hashable { + /// The unique identifier of the deleted entity public let id: String - /// The name of the deleted object's class + /// The name of the deleted entity's class public let className: String - /// The level the deleted object's class is registered at + /// The level the deleted entity's class is registered at public let classLevel: PubNubDataSyncClassLevel - /// The version of the deleted object's class + /// The version of the deleted entity's class public let classVersion: Int - /// The date the object was deleted + /// The date the entity was deleted + public let deletedAt: Date +} + +/// A DataSync relationship that was deleted. +public struct PubNubDataSyncRemovedRelationship: Hashable { + /// The unique identifier of the deleted relationship + public let id: String + /// The name of the deleted relationship's class + public let className: String + /// The level the deleted relationship's class is registered at + public let classLevel: PubNubDataSyncClassLevel + /// The version of the deleted relationship's class + public let classVersion: Int + /// The date the relationship was deleted public let deletedAt: Date } diff --git a/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift b/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift index 202fd4be..7474596d 100644 --- a/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift +++ b/Tests/PubNubUnitTests/Networking/Routers/SubscribeRouterTests+DataSync.swift @@ -102,8 +102,8 @@ extension SubscribeRouterTests { XCTAssertPayload(entity.payload, equals: expectedPayload) } - func test_Subscribe_WithDataSyncEntityDeleteEvent_ReceivesRemovedObject() throws { - let expectedRemoval = PubNubDataSyncRemovedObject( + func test_Subscribe_WithDataSyncEntityDeleteEvent_ReceivesRemovedEntity() throws { + let expectedRemoval = PubNubDataSyncRemovedEntity( id: "hcn-patient-dubois", className: "patient", classLevel: .subKey, @@ -191,8 +191,8 @@ extension SubscribeRouterTests { XCTAssertPayload(relationship.payload, equals: expectedPayload) } - func test_Subscribe_WithDataSyncRelationshipDeleteEvent_ReceivesRemovedObject() throws { - let expectedRemoval = PubNubDataSyncRemovedObject( + func test_Subscribe_WithDataSyncRelationshipDeleteEvent_ReceivesRemovedRelationship() throws { + let expectedRemoval = PubNubDataSyncRemovedRelationship( id: "hcn-rel-attending-tanaka-dubois", className: "attending-physician", classLevel: .subKey, @@ -440,13 +440,13 @@ extension SubscribeRouterTests { ) } - func test_Subscribe_WithDataSyncUserDeleteEvent_ReceivesRemovedObject() throws { + func test_Subscribe_WithDataSyncUserDeleteEvent_ReceivesRemovedEntity() throws { let event = try decodeEvent(from: "subscription_dataSyncUserDelete_success") let removed = try XCTUnwrap(event.deletedDataSyncEntity) XCTAssertEqual( removed, - PubNubDataSyncRemovedObject( + PubNubDataSyncRemovedEntity( id: "alice", className: "User", classLevel: .global, @@ -491,13 +491,13 @@ extension SubscribeRouterTests { ) } - func test_Subscribe_WithDataSyncChannelDeleteEvent_ReceivesRemovedObject() throws { + func test_Subscribe_WithDataSyncChannelDeleteEvent_ReceivesRemovedEntity() throws { let event = try decodeEvent(from: "subscription_dataSyncChannelDelete_success") let removed = try XCTUnwrap(event.deletedDataSyncEntity) XCTAssertEqual( removed, - PubNubDataSyncRemovedObject( + PubNubDataSyncRemovedEntity( id: "general", className: "Channel", classLevel: .global, @@ -556,13 +556,13 @@ extension SubscribeRouterTests { ) } - func test_Subscribe_WithDataSyncMembershipDeleteEvent_ReceivesRemovedObject() throws { + func test_Subscribe_WithDataSyncMembershipDeleteEvent_ReceivesRemovedRelationship() throws { let event = try decodeEvent(from: "subscription_dataSyncMembershipDelete_success") let removed = try XCTUnwrap(event.deletedDataSyncRelationship) XCTAssertEqual( removed, - PubNubDataSyncRemovedObject( + PubNubDataSyncRemovedRelationship( id: "general__alice", className: "Membership", classLevel: .global, @@ -938,7 +938,7 @@ private extension PubNubDataSyncEvent { return entityEvent.entity } - var deletedEntity: PubNubDataSyncRemovedObject? { + var deletedEntity: PubNubDataSyncRemovedEntity? { guard case let .entityDeleted(entityEvent) = change else { return nil } return entityEvent.removed } @@ -953,7 +953,7 @@ private extension PubNubDataSyncEvent { return relationshipEvent.relationship } - var deletedRelationship: PubNubDataSyncRemovedObject? { + var deletedRelationship: PubNubDataSyncRemovedRelationship? { guard case let .relationshipDeleted(relationshipEvent) = change else { return nil } return relationshipEvent.removed } @@ -981,7 +981,7 @@ private extension PubNubEvent { return entityEvent.entity } - var deletedDataSyncEntity: PubNubDataSyncRemovedObject? { + var deletedDataSyncEntity: PubNubDataSyncRemovedEntity? { guard case let .entityDeleted(entityEvent)? = dataSyncEvent?.change else { return nil } return entityEvent.removed } @@ -995,7 +995,7 @@ private extension PubNubEvent { return relationshipEvent.relationship } - var deletedDataSyncRelationship: PubNubDataSyncRemovedObject? { + var deletedDataSyncRelationship: PubNubDataSyncRemovedRelationship? { guard case let .relationshipDeleted(relationshipEvent)? = dataSyncEvent?.change else { return nil } return relationshipEvent.removed }