Skip to content

Commit fa929fd

Browse files
committed
add tests
1 parent 2c9b980 commit fa929fd

5 files changed

Lines changed: 413 additions & 0 deletions

File tree

iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@
7070
3C14E3A12AFAE461006ED053 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3C14E3A02AFAE461006ED053 /* PrivacyInfo.xcprivacy */; };
7171
3C14E3A42AFAE54C006ED053 /* OneSignalSwiftInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC08AFF2947D4E900C81DA3 /* OneSignalSwiftInterface.swift */; };
7272
3C19C6322E919F0C00D6731E /* OSRequestLiveActivityClicked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C19C6312E919F0C00D6731E /* OSRequestLiveActivityClicked.swift */; };
73+
3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */; };
74+
3C23A21D2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */; };
75+
3C23A21F2FCE0AA1001D32E3 /* OSResilientStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */; };
7376
3C24B0EC2BD09D7A0052E771 /* OneSignalCoreObjCTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C24B0EB2BD09D7A0052E771 /* OneSignalCoreObjCTests.m */; };
7477
3C277D7E2BD76E0000857606 /* OSIdentityModelRepo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C277D7D2BD76E0000857606 /* OSIdentityModelRepo.swift */; };
7578
3C2C7DC8288F3C020020F9AE /* OSSubscriptionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2C7DC7288F3C020020F9AE /* OSSubscriptionModel.swift */; };
@@ -1333,6 +1336,9 @@
13331336
3C14E39E2AFAE39B006ED053 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
13341337
3C14E3A02AFAE461006ED053 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
13351338
3C19C6312E919F0C00D6731E /* OSRequestLiveActivityClicked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestLiveActivityClicked.swift; sourceTree = "<group>"; };
1339+
3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalIdentifiersFallbackTests.swift; sourceTree = "<group>"; };
1340+
3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSModelStoreRefreshTests.swift; sourceTree = "<group>"; };
1341+
3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSResilientStorageTests.swift; sourceTree = "<group>"; };
13361342
3C24B0EA2BD09D790052E771 /* OneSignalCoreTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "OneSignalCoreTests-Bridging-Header.h"; sourceTree = "<group>"; };
13371343
3C24B0EB2BD09D7A0052E771 /* OneSignalCoreObjCTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalCoreObjCTests.m; sourceTree = "<group>"; };
13381344
3C277D7D2BD76E0000857606 /* OSIdentityModelRepo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIdentityModelRepo.swift; sourceTree = "<group>"; };
@@ -2516,6 +2522,9 @@
25162522
isa = PBXGroup;
25172523
children = (
25182524
5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */,
2525+
3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */,
2526+
3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */,
2527+
3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */,
25192528
);
25202529
path = OneSignalOSCoreTests;
25212530
sourceTree = "<group>";
@@ -4531,6 +4540,9 @@
45314540
buildActionMask = 2147483647;
45324541
files = (
45334542
5B053FC32CAE0843002F30C4 /* OSConsistencyManagerTests.swift in Sources */,
4543+
3C23A21F2FCE0AA1001D32E3 /* OSResilientStorageTests.swift in Sources */,
4544+
3C23A21D2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift in Sources */,
4545+
3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */,
45344546
);
45354547
runOnlyForDeploymentPostprocessing = 0;
45364548
};
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
Modified MIT License
3+
4+
Copyright 2026 OneSignal
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
1. The above copyright notice and this permission notice shall be included in
14+
all copies or substantial portions of the Software.
15+
16+
2. All copies of substantial portions of the Software may only be used in connection
17+
with services provided by OneSignal.
18+
19+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25+
THE SOFTWARE.
26+
*/
27+
28+
import Foundation
29+
import XCTest
30+
import OneSignalCore
31+
@testable import OneSignalOSCore
32+
33+
/// Validates `OSModelStore.refresh()` — the post-unlock recovery path that the SDK's
34+
/// `start()` relies on so model stores that loaded empty during iOS prewarm re-hydrate
35+
/// from disk before Path 1 runs.
36+
final class OSModelStoreRefreshTests: XCTestCase {
37+
38+
private let storeKey = "OSModelStoreRefreshTests_storeKey"
39+
40+
override func setUp() {
41+
super.setUp()
42+
OneSignalUserDefaults.initShared().removeValue(forKey: storeKey)
43+
}
44+
45+
override func tearDown() {
46+
OneSignalUserDefaults.initShared().removeValue(forKey: storeKey)
47+
super.tearDown()
48+
}
49+
50+
/// Seed UserDefaults with a serialized models dict, the same way `OSModelStore.add`
51+
/// persists. Mimics "prior session wrote models to disk".
52+
private func seedUserDefaults(with models: [String: OSModel]) {
53+
OneSignalUserDefaults.initShared().saveCodeableData(forKey: storeKey, withValue: models)
54+
}
55+
56+
private func makeStore() -> OSModelStore<OSModel> {
57+
return OSModelStore<OSModel>(changeSubscription: OSEventProducer(), storeKey: storeKey)
58+
}
59+
60+
/// Simulates the prewarm case: at OSModelStore.init time UserDefaults returned nil,
61+
/// then disk became readable. refresh() must pick up what's on disk.
62+
func testRefresh_hydratesFromUserDefaults_whenStoreLoadedEmpty() {
63+
// 1. Store inits empty (UD has no entry for storeKey).
64+
let store = makeStore()
65+
XCTAssertTrue(store.getModels().isEmpty, "Precondition: store should load empty")
66+
67+
// 2. Disk gets populated after the fact (simulates the prior session's persisted state).
68+
let model = OSModel(changeNotifier: OSEventProducer())
69+
seedUserDefaults(with: ["key_x": model])
70+
71+
// 3. refresh() should hydrate.
72+
store.refresh()
73+
74+
XCTAssertEqual(store.getModels().count, 1, "refresh should hydrate the in-memory dict")
75+
XCTAssertNotNil(store.getModel(key: "key_x"))
76+
}
77+
78+
/// refresh() must be a no-op when the store is already populated — never clobber
79+
/// in-memory state that may have diverged from disk via writes that haven't flushed.
80+
func testRefresh_isNoOp_whenStoreAlreadyPopulated() {
81+
// 1. Seed disk with model A; store init() will load it.
82+
let modelA = OSModel(changeNotifier: OSEventProducer())
83+
seedUserDefaults(with: ["key_x": modelA])
84+
let store = makeStore()
85+
XCTAssertEqual(store.getModels().count, 1)
86+
let loadedAId = store.getModel(key: "key_x")?.modelId
87+
88+
// 2. Disk gets a different model B for the same key.
89+
let modelB = OSModel(changeNotifier: OSEventProducer())
90+
seedUserDefaults(with: ["key_x": modelB])
91+
92+
// 3. refresh() must NOT replace the existing in-memory entry.
93+
store.refresh()
94+
95+
XCTAssertEqual(store.getModel(key: "key_x")?.modelId, loadedAId,
96+
"refresh must not replace already-loaded model")
97+
}
98+
99+
/// refresh() should subscribe the store to each hydrated model's change notifier so
100+
/// downstream mutations persist via the normal onModelUpdated path.
101+
func testRefresh_subscribesStoreToHydratedModelNotifiers() {
102+
let store = makeStore()
103+
XCTAssertTrue(store.getModels().isEmpty)
104+
105+
let model = OSModel(changeNotifier: OSEventProducer())
106+
seedUserDefaults(with: ["key_x": model])
107+
108+
store.refresh()
109+
110+
guard let loaded = store.getModel(key: "key_x") else {
111+
XCTFail("Expected model to be loaded by refresh")
112+
return
113+
}
114+
115+
// Mutate via set(property:) — triggers fire() on changeNotifier. If refresh() wired
116+
// the subscription, the store's onModelUpdated will receive it and persist the
117+
// dict back to UserDefaults.
118+
OneSignalUserDefaults.initShared().removeValue(forKey: storeKey)
119+
loaded.set(property: "test_prop", newValue: "test_value")
120+
121+
// After the mutation, UD should be repopulated by onModelUpdated.
122+
let written = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: storeKey, defaultValue: nil)
123+
XCTAssertNotNil(written, "Mutating a refresh()-hydrated model should persist via the store's onModelUpdated handler")
124+
}
125+
126+
/// Sanity: when there's nothing on disk, refresh() leaves an empty store empty.
127+
func testRefresh_doesNothing_whenDiskIsEmpty() {
128+
let store = makeStore()
129+
XCTAssertTrue(store.getModels().isEmpty)
130+
131+
store.refresh()
132+
133+
XCTAssertTrue(store.getModels().isEmpty)
134+
}
135+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
Modified MIT License
3+
4+
Copyright 2026 OneSignal
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
1. The above copyright notice and this permission notice shall be included in
14+
all copies or substantial portions of the Software.
15+
16+
2. All copies of substantial portions of the Software may only be used in connection
17+
with services provided by OneSignal.
18+
19+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25+
THE SOFTWARE.
26+
*/
27+
28+
import Foundation
29+
import XCTest
30+
@testable import OneSignalOSCore
31+
32+
final class OSResilientStorageTests: XCTestCase {
33+
34+
/// Test keys. We avoid the real key constants so collisions with anything written by
35+
/// other tests / fixtures during the same simulator session can't bleed into assertions.
36+
private let keyA = "test_key_a"
37+
private let keyB = "test_key_b"
38+
39+
override func setUp() {
40+
super.setUp()
41+
clearAllTestKeys()
42+
}
43+
44+
override func tearDown() {
45+
clearAllTestKeys()
46+
super.tearDown()
47+
}
48+
49+
private func clearAllTestKeys() {
50+
OSResilientStorage.setString(nil, forKey: keyA)
51+
OSResilientStorage.setString(nil, forKey: keyB)
52+
// Writes are queue.async; force ordering with a queue.sync read.
53+
_ = OSResilientStorage.snapshot()
54+
}
55+
56+
// MARK: - setString / string(forKey:)
57+
58+
func testSetThenGet_returnsTheStoredValue() {
59+
OSResilientStorage.setString("alpha", forKey: keyA)
60+
XCTAssertEqual(OSResilientStorage.string(forKey: keyA), "alpha")
61+
}
62+
63+
func testGet_returnsNilWhenAbsent() {
64+
XCTAssertNil(OSResilientStorage.string(forKey: "never_written_\(UUID().uuidString)"))
65+
}
66+
67+
func testSetWithNil_removesKey() {
68+
OSResilientStorage.setString("alpha", forKey: keyA)
69+
XCTAssertEqual(OSResilientStorage.string(forKey: keyA), "alpha")
70+
71+
OSResilientStorage.setString(nil, forKey: keyA)
72+
XCTAssertNil(OSResilientStorage.string(forKey: keyA))
73+
}
74+
75+
func testSetWithEmptyString_removesKey() {
76+
OSResilientStorage.setString("alpha", forKey: keyA)
77+
OSResilientStorage.setString("", forKey: keyA)
78+
XCTAssertNil(OSResilientStorage.string(forKey: keyA))
79+
}
80+
81+
func testGet_returnsNilWhenStoredValueIsEmpty() {
82+
// Direct insert via setStrings to verify the read-side empty-guard, not just the write side.
83+
OSResilientStorage.setStrings([keyA: "alpha"])
84+
OSResilientStorage.setStrings([keyA: ""])
85+
XCTAssertNil(OSResilientStorage.string(forKey: keyA))
86+
}
87+
88+
// MARK: - setStrings (batch)
89+
90+
func testSetStrings_setsMultipleKeysAtomically() {
91+
OSResilientStorage.setStrings([keyA: "alpha", keyB: "beta"])
92+
XCTAssertEqual(OSResilientStorage.string(forKey: keyA), "alpha")
93+
XCTAssertEqual(OSResilientStorage.string(forKey: keyB), "beta")
94+
}
95+
96+
func testSetStrings_emptyValueRemovesCorrespondingKey() {
97+
OSResilientStorage.setStrings([keyA: "alpha", keyB: "beta"])
98+
OSResilientStorage.setStrings([keyA: ""])
99+
XCTAssertNil(OSResilientStorage.string(forKey: keyA))
100+
XCTAssertEqual(OSResilientStorage.string(forKey: keyB), "beta")
101+
}
102+
103+
func testSetStrings_preservesKeysNotInTheUpdateDict() {
104+
OSResilientStorage.setStrings([keyA: "alpha", keyB: "beta"])
105+
OSResilientStorage.setStrings([keyA: "alpha_updated"])
106+
XCTAssertEqual(OSResilientStorage.string(forKey: keyA), "alpha_updated")
107+
XCTAssertEqual(OSResilientStorage.string(forKey: keyB), "beta")
108+
}
109+
110+
func testSetStrings_emptyDictIsNoOp() {
111+
OSResilientStorage.setStrings([keyA: "alpha"])
112+
OSResilientStorage.setStrings([:])
113+
XCTAssertEqual(OSResilientStorage.string(forKey: keyA), "alpha")
114+
}
115+
116+
// MARK: - snapshot
117+
118+
func testSnapshot_reflectsCurrentContents() {
119+
OSResilientStorage.setStrings([keyA: "alpha", keyB: "beta"])
120+
let snap = OSResilientStorage.snapshot()
121+
XCTAssertEqual(snap[keyA], "alpha")
122+
XCTAssertEqual(snap[keyB], "beta")
123+
}
124+
}

0 commit comments

Comments
 (0)