Skip to content

Commit 2f0dfa2

Browse files
belleklaviyoclaude
andcommitted
refactor(KlaviyoSwift): compose ProfileData into KlaviyoState (MAGE-749)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9ac6a7d commit 2f0dfa2

7 files changed

Lines changed: 184 additions & 34 deletions

File tree

Sources/KlaviyoSwift/StateManagement/KlaviyoState.swift

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,29 @@ struct KlaviyoState: Equatable, Codable {
4545

4646
// state related stuff
4747
var apiKey: String?
48-
var email: String?
49-
var anonymousId: String?
50-
var phoneNumber: String?
51-
var externalId: String?
48+
var identity: ProfileData
49+
50+
// Computed shims forwarding to `identity.*` so the rest of KlaviyoSwift compiles unchanged.
51+
var email: String? {
52+
get { identity.email }
53+
set { identity.email = newValue }
54+
}
55+
56+
var phoneNumber: String? {
57+
get { identity.phoneNumber }
58+
set { identity.phoneNumber = newValue }
59+
}
60+
61+
var externalId: String? {
62+
get { identity.externalId }
63+
set { identity.externalId = newValue }
64+
}
65+
66+
var anonymousId: String? {
67+
get { identity.anonymousId }
68+
set { identity.anonymousId = newValue }
69+
}
70+
5271
var pushTokenData: PushTokenData?
5372

5473
// queueing related stuff
@@ -64,14 +83,73 @@ struct KlaviyoState: Equatable, Codable {
6483

6584
enum CodingKeys: CodingKey {
6685
case apiKey
67-
case email
68-
case anonymousId
69-
case phoneNumber
70-
case externalId
86+
case identity
7187
case queue
7288
case pushTokenData
7389
}
7490

91+
/// Legacy coding keys for migrating state files written before identity was composed into `ProfileData`.
92+
private enum LegacyCodingKeys: CodingKey {
93+
case email, anonymousId, phoneNumber, externalId
94+
}
95+
96+
init(from decoder: Decoder) throws {
97+
let container = try decoder.container(keyedBy: CodingKeys.self)
98+
apiKey = try container.decodeIfPresent(String.self, forKey: .apiKey)
99+
pushTokenData = try container.decodeIfPresent(PushTokenData.self, forKey: .pushTokenData)
100+
queue = try container.decodeIfPresent([KlaviyoRequest].self, forKey: .queue) ?? []
101+
102+
if let identity = try container.decodeIfPresent(ProfileData.self, forKey: .identity) {
103+
// New format: identity is a nested object.
104+
self.identity = identity
105+
} else {
106+
// Legacy format: identity fields were stored at the top level.
107+
let legacy = try decoder.container(keyedBy: LegacyCodingKeys.self)
108+
self.identity = ProfileData(
109+
email: try legacy.decodeIfPresent(String.self, forKey: .email),
110+
phoneNumber: try legacy.decodeIfPresent(String.self, forKey: .phoneNumber),
111+
externalId: try legacy.decodeIfPresent(String.self, forKey: .externalId),
112+
anonymousId: try legacy.decodeIfPresent(String.self, forKey: .anonymousId)
113+
)
114+
}
115+
}
116+
117+
init(
118+
apiKey: String? = nil,
119+
email: String? = nil,
120+
anonymousId: String? = nil,
121+
phoneNumber: String? = nil,
122+
externalId: String? = nil,
123+
pushTokenData: PushTokenData? = nil,
124+
queue: [KlaviyoRequest],
125+
requestsInFlight: [KlaviyoRequest] = [],
126+
initalizationState: InitializationState = .uninitialized,
127+
flushing: Bool = false,
128+
flushInterval: Double = StateManagementConstants.wifiFlushInterval,
129+
retryState: RetryState = .retry(StateManagementConstants.initialAttempt),
130+
pendingRequests: [PendingRequest] = [],
131+
pendingProfile: [Profile.ProfileKey: AnyEncodable]? = nil,
132+
isProcessingDeepLink: Bool = false
133+
) {
134+
self.apiKey = apiKey
135+
identity = ProfileData(
136+
email: email,
137+
phoneNumber: phoneNumber,
138+
externalId: externalId,
139+
anonymousId: anonymousId
140+
)
141+
self.pushTokenData = pushTokenData
142+
self.queue = queue
143+
self.requestsInFlight = requestsInFlight
144+
self.initalizationState = initalizationState
145+
self.flushing = flushing
146+
self.flushInterval = flushInterval
147+
self.retryState = retryState
148+
self.pendingRequests = pendingRequests
149+
self.pendingProfile = pendingProfile
150+
self.isProcessingDeepLink = isProcessingDeepLink
151+
}
152+
75153
mutating func enqueueRequest(request: KlaviyoRequest) {
76154
guard queue.count + 1 < StateManagementConstants.maxQueueSize else {
77155
return

Tests/KlaviyoSwiftTests/KlaviyoStateTests.swift

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,4 +193,70 @@ final class KlaviyoStateTests: XCTestCase {
193193
// Fake value to test availability
194194
XCTAssertEqual(PushEnablement.create(from: UNAuthorizationStatus(rawValue: 50)!), .notDetermined)
195195
}
196+
197+
// MARK: - ProfileData migration
198+
199+
func testDecodesLegacyFlatIdentityJSON() throws {
200+
let jsonString = """
201+
{
202+
"apiKey": "company-id",
203+
"email": "a@b.com",
204+
"phoneNumber": "+15555555555",
205+
"externalId": "ext-1",
206+
"anonymousId": "anon-1",
207+
"queue": []
208+
}
209+
"""
210+
let json = Data(jsonString.utf8)
211+
212+
let state = try JSONDecoder().decode(KlaviyoState.self, from: json)
213+
214+
XCTAssertEqual(state.identity.email, "a@b.com")
215+
XCTAssertEqual(state.identity.phoneNumber, "+15555555555")
216+
XCTAssertEqual(state.identity.externalId, "ext-1")
217+
XCTAssertEqual(state.identity.anonymousId, "anon-1")
218+
XCTAssertEqual(state.apiKey, "company-id")
219+
}
220+
221+
func testDecodesNewNestedIdentityJSON() throws {
222+
let jsonString = """
223+
{
224+
"apiKey": "company-id",
225+
"identity": {
226+
"email": "a@b.com",
227+
"phoneNumber": "+15555555555",
228+
"externalId": "ext-1",
229+
"anonymousId": "anon-1"
230+
},
231+
"queue": []
232+
}
233+
"""
234+
let json = Data(jsonString.utf8)
235+
236+
let state = try JSONDecoder().decode(KlaviyoState.self, from: json)
237+
238+
XCTAssertEqual(state.identity.email, "a@b.com")
239+
XCTAssertEqual(state.identity.phoneNumber, "+15555555555")
240+
XCTAssertEqual(state.identity.externalId, "ext-1")
241+
XCTAssertEqual(state.identity.anonymousId, "anon-1")
242+
}
243+
244+
func testEncodesIdentityAsNestedObject() throws {
245+
let state = KlaviyoState(
246+
apiKey: "company-id",
247+
email: "a@b.com",
248+
anonymousId: "anon-1",
249+
queue: []
250+
)
251+
252+
let data = try JSONEncoder().encode(state)
253+
let object = try JSONSerialization.jsonObject(with: data) as! [String: Any]
254+
255+
XCTAssertNil(object["email"], "identity fields must not be encoded at the top level")
256+
XCTAssertNil(object["phoneNumber"], "identity fields must not be encoded at the top level")
257+
XCTAssertNil(object["externalId"], "identity fields must not be encoded at the top level")
258+
let identity = try XCTUnwrap(object["identity"] as? [String: Any])
259+
XCTAssertEqual(identity["email"] as? String, "a@b.com")
260+
XCTAssertEqual(identity["anonymousId"] as? String, "anon-1")
261+
}
196262
}

Tests/KlaviyoSwiftTests/__Snapshots__/EncodableTests/testKlaviyoState.1.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
{
2-
"anonymousId" : "foo",
3-
"email" : "foo",
4-
"phoneNumber" : "foo",
2+
"identity" : {
3+
"anonymousId" : "foo",
4+
"email" : "foo",
5+
"phoneNumber" : "foo"
6+
},
57
"pushTokenData" : {
68
"deviceData" : {
79
"app_build" : "1",

Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoStateTests/testLoadNewKlaviyoState.1.txt

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
▿ KlaviyoState
2-
▿ anonymousId: Optional<String>
3-
- some: "00000000-0000-0000-0000-000000000001"
42
▿ apiKey: Optional<String>
53
- some: "foo"
6-
- email: Optional<String>.none
7-
- externalId: Optional<String>.none
84
- flushInterval: 10.0
95
- flushing: false
6+
▿ identity: ProfileData
7+
▿ anonymousId: Optional<String>
8+
- some: "00000000-0000-0000-0000-000000000001"
9+
- email: Optional<String>.none
10+
- externalId: Optional<String>.none
11+
- phoneNumber: Optional<String>.none
1012
- initalizationState: InitializationState.uninitialized
1113
- isProcessingDeepLink: false
1214
- pendingProfile: Optional<Dictionary<ProfileKey, AnyEncodable>>.none
1315
- pendingRequests: 0 elements
14-
- phoneNumber: Optional<String>.none
1516
- pushTokenData: Optional<PushTokenData>.none
1617
- queue: 0 elements
1718
- requestsInFlight: 0 elements

Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoStateTests/testStateFileExistsInvalidData.1.txt

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
▿ KlaviyoState
2-
▿ anonymousId: Optional<String>
3-
- some: "00000000-0000-0000-0000-000000000001"
42
▿ apiKey: Optional<String>
53
- some: "foo"
6-
- email: Optional<String>.none
7-
- externalId: Optional<String>.none
84
- flushInterval: 10.0
95
- flushing: false
6+
▿ identity: ProfileData
7+
▿ anonymousId: Optional<String>
8+
- some: "00000000-0000-0000-0000-000000000001"
9+
- email: Optional<String>.none
10+
- externalId: Optional<String>.none
11+
- phoneNumber: Optional<String>.none
1012
- initalizationState: InitializationState.uninitialized
1113
- isProcessingDeepLink: false
1214
- pendingProfile: Optional<Dictionary<ProfileKey, AnyEncodable>>.none
1315
- pendingRequests: 0 elements
14-
- phoneNumber: Optional<String>.none
1516
- pushTokenData: Optional<PushTokenData>.none
1617
- queue: 0 elements
1718
- requestsInFlight: 0 elements

Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoStateTests/testStateFileExistsInvalidJSON.1.txt

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
▿ KlaviyoState
2-
▿ anonymousId: Optional<String>
3-
- some: "00000000-0000-0000-0000-000000000001"
42
▿ apiKey: Optional<String>
53
- some: "foo"
6-
- email: Optional<String>.none
7-
- externalId: Optional<String>.none
84
- flushInterval: 10.0
95
- flushing: false
6+
▿ identity: ProfileData
7+
▿ anonymousId: Optional<String>
8+
- some: "00000000-0000-0000-0000-000000000001"
9+
- email: Optional<String>.none
10+
- externalId: Optional<String>.none
11+
- phoneNumber: Optional<String>.none
1012
- initalizationState: InitializationState.uninitialized
1113
- isProcessingDeepLink: false
1214
- pendingProfile: Optional<Dictionary<ProfileKey, AnyEncodable>>.none
1315
- pendingRequests: 0 elements
14-
- phoneNumber: Optional<String>.none
1516
- pushTokenData: Optional<PushTokenData>.none
1617
- queue: 0 elements
1718
- requestsInFlight: 0 elements

Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoStateTests/testValidStateFileExists.1.txt

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
▿ KlaviyoState
2-
▿ anonymousId: Optional<String>
3-
- some: "00000000-0000-0000-0000-000000000001"
42
▿ apiKey: Optional<String>
53
- some: "foo"
6-
▿ email: Optional<String>
7-
- some: "test@test.com"
8-
▿ externalId: Optional<String>
9-
- some: "externalId"
104
- flushInterval: 10.0
115
- flushing: true
6+
▿ identity: ProfileData
7+
▿ anonymousId: Optional<String>
8+
- some: "00000000-0000-0000-0000-000000000001"
9+
▿ email: Optional<String>
10+
- some: "test@test.com"
11+
▿ externalId: Optional<String>
12+
- some: "externalId"
13+
▿ phoneNumber: Optional<String>
14+
- some: "phoneNumber"
1215
- initalizationState: InitializationState.initialized
1316
- isProcessingDeepLink: false
1417
- pendingProfile: Optional<Dictionary<ProfileKey, AnyEncodable>>.none
1518
- pendingRequests: 0 elements
16-
▿ phoneNumber: Optional<String>
17-
- some: "phoneNumber"
1819
▿ pushTokenData: Optional<PushTokenData>
1920
▿ some: PushTokenData
2021
▿ deviceData: MetaData

0 commit comments

Comments
 (0)