Skip to content

Commit 787bcc3

Browse files
committed
add tests for OSResilientStorage, refresh(), identifier fallback, start gate
- OSResilientStorageTests: 8 tests covering the public API - OSModelStoreRefreshTests: 4 tests for the post-unlock refresh path - OneSignalIdentifiersFallbackTests: 6 tests for the UD→mirror fallback - OneSignalUserTests: regression test for start() defer-then-proceed contract, captures the async-seed bug surfaced during review
1 parent bcfcf7e commit 787bcc3

4 files changed

Lines changed: 394 additions & 0 deletions

File tree

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: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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+
// MARK: - setStrings (batch)
82+
83+
func testSetStrings_setsMultipleKeysAtomically() {
84+
OSResilientStorage.setStrings([keyA: "alpha", keyB: "beta"])
85+
XCTAssertEqual(OSResilientStorage.string(forKey: keyA), "alpha")
86+
XCTAssertEqual(OSResilientStorage.string(forKey: keyB), "beta")
87+
}
88+
89+
func testSetStrings_emptyValueRemovesCorrespondingKey() {
90+
OSResilientStorage.setStrings([keyA: "alpha", keyB: "beta"])
91+
OSResilientStorage.setStrings([keyA: ""])
92+
XCTAssertNil(OSResilientStorage.string(forKey: keyA))
93+
XCTAssertEqual(OSResilientStorage.string(forKey: keyB), "beta")
94+
}
95+
96+
func testSetStrings_preservesKeysNotInTheUpdateDict() {
97+
OSResilientStorage.setStrings([keyA: "alpha", keyB: "beta"])
98+
OSResilientStorage.setStrings([keyA: "alpha_updated"])
99+
XCTAssertEqual(OSResilientStorage.string(forKey: keyA), "alpha_updated")
100+
XCTAssertEqual(OSResilientStorage.string(forKey: keyB), "beta")
101+
}
102+
103+
func testSetStrings_emptyDictIsNoOp() {
104+
OSResilientStorage.setStrings([keyA: "alpha"])
105+
OSResilientStorage.setStrings([:])
106+
XCTAssertEqual(OSResilientStorage.string(forKey: keyA), "alpha")
107+
}
108+
109+
// MARK: - snapshot
110+
111+
func testSnapshot_reflectsCurrentContents() {
112+
OSResilientStorage.setStrings([keyA: "alpha", keyB: "beta"])
113+
let snap = OSResilientStorage.snapshot()
114+
XCTAssertEqual(snap[keyA], "alpha")
115+
XCTAssertEqual(snap[keyB], "beta")
116+
}
117+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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 the UserDefaults-first → OSResilientStorage-fallback read paths.
34+
/// During iOS prewarm before first unlock the shared UserDefaults file is encrypted and
35+
/// returns nil/empty for these keys, so callers (LA executors, NSE) must transparently
36+
/// recover the value from the unencrypted mirror.
37+
final class OneSignalIdentifiersFallbackTests: XCTestCase {
38+
39+
override func setUp() {
40+
super.setUp()
41+
clearUserDefaultsKey(OSUD_APP_ID)
42+
clearUserDefaultsKey(OSUD_PUSH_SUBSCRIPTION_ID)
43+
OSResilientStorage.setString(nil, forKey: OSResilientStorage.keyAppId)
44+
OSResilientStorage.setString(nil, forKey: OSResilientStorage.keySubscriptionId)
45+
// Force the async OSResilientStorage write queue to drain before the next test step.
46+
_ = OSResilientStorage.snapshot()
47+
}
48+
49+
override func tearDown() {
50+
clearUserDefaultsKey(OSUD_APP_ID)
51+
clearUserDefaultsKey(OSUD_PUSH_SUBSCRIPTION_ID)
52+
OSResilientStorage.setString(nil, forKey: OSResilientStorage.keyAppId)
53+
OSResilientStorage.setString(nil, forKey: OSResilientStorage.keySubscriptionId)
54+
_ = OSResilientStorage.snapshot()
55+
super.tearDown()
56+
}
57+
58+
private func clearUserDefaultsKey(_ key: String) {
59+
OneSignalUserDefaults.initShared().removeValue(forKey: key)
60+
}
61+
62+
// MARK: - storedAppId
63+
64+
func testStoredAppId_returnsUserDefaultsValue_whenUDPopulated() {
65+
OneSignalUserDefaults.initShared().saveString(forKey: OSUD_APP_ID, withValue: "app_id_ud")
66+
OSResilientStorage.setString("app_id_mirror", forKey: OSResilientStorage.keyAppId)
67+
_ = OSResilientStorage.snapshot()
68+
69+
XCTAssertEqual(OneSignalIdentifiers.storedAppId, "app_id_ud",
70+
"UD value should take precedence when present")
71+
}
72+
73+
func testStoredAppId_fallsBackToMirror_whenUDAbsent() {
74+
OSResilientStorage.setString("app_id_mirror", forKey: OSResilientStorage.keyAppId)
75+
_ = OSResilientStorage.snapshot()
76+
77+
XCTAssertEqual(OneSignalIdentifiers.storedAppId, "app_id_mirror",
78+
"Mirror should be returned when UD is empty (locked-storage scenario)")
79+
}
80+
81+
func testStoredAppId_returnsNil_whenBothEmpty() {
82+
XCTAssertNil(OneSignalIdentifiers.storedAppId)
83+
}
84+
85+
// MARK: - subscriptionId
86+
87+
func testSubscriptionId_returnsUserDefaultsValue_whenUDPopulated() {
88+
OneSignalUserDefaults.initShared().saveString(forKey: OSUD_PUSH_SUBSCRIPTION_ID, withValue: "sub_ud")
89+
OSResilientStorage.setString("sub_mirror", forKey: OSResilientStorage.keySubscriptionId)
90+
_ = OSResilientStorage.snapshot()
91+
92+
XCTAssertEqual(OneSignalIdentifiers.subscriptionId, "sub_ud")
93+
}
94+
95+
func testSubscriptionId_fallsBackToMirror_whenUDAbsent() {
96+
OSResilientStorage.setString("sub_mirror", forKey: OSResilientStorage.keySubscriptionId)
97+
_ = OSResilientStorage.snapshot()
98+
99+
XCTAssertEqual(OneSignalIdentifiers.subscriptionId, "sub_mirror")
100+
}
101+
102+
func testSubscriptionId_returnsNil_whenBothEmpty() {
103+
XCTAssertNil(OneSignalIdentifiers.subscriptionId)
104+
}
105+
}

0 commit comments

Comments
 (0)