1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
@testable import Grain
import XCTest
/// Multi-account arrived after the app had already shipped, so every device
/// that updated had per-user state sitting at unsuffixed keys. Migration claims
/// it for whoever was signed in; without it that account appears to lose its
/// watch history and search history on update.
@MainActor
final class LegacyStateMigrationTests: GrainTestCase {
private var did = ""
private let legacyKeys = ["viewedStoryUris", "viewedStoryAuthors", "recentSearchProfiles", "recentSearchText"]
override func setUp() async throws {
try await super.setUp()
did = "did:plc:migration-\(UUID().uuidString)"
clearLegacyKeys()
}
override func tearDown() async throws {
clearLegacyKeys()
AccountScopedStorage.purge(did: did)
try await super.tearDown()
}
private func clearLegacyKeys() {
for key in legacyKeys {
StorageEnvironment.defaults.removeObject(forKey: key)
}
}
private func scoped(_ base: String) -> String {
AccountScopedStorage.key(base, did: did)
}
func testLegacyStateIsClaimedByTheSignedInAccount() {
StorageEnvironment.defaults.set(["at://story-1"], forKey: "viewedStoryUris")
StorageEnvironment.defaults.set(["portra"], forKey: "recentSearchText")
AccountScopedStorage.migrateLegacyState(to: did)
XCTAssertEqual(StorageEnvironment.defaults.stringArray(forKey: scoped("viewedStoryUris")), ["at://story-1"])
XCTAssertEqual(StorageEnvironment.defaults.stringArray(forKey: scoped("recentSearchText")), ["portra"])
}
/// The bare keys have to go, or a second account signing in later would
/// inherit the first one's history.
func testTheUnsuffixedKeysAreRemovedAfterwards() {
StorageEnvironment.defaults.set(["at://story-1"], forKey: "viewedStoryUris")
AccountScopedStorage.migrateLegacyState(to: did)
XCTAssertNil(StorageEnvironment.defaults.object(forKey: "viewedStoryUris"))
}
/// Migration runs on sign-in, which happens again every time an account is
/// re-added — it must not clobber the state that account has since built up.
func testExistingScopedStateWins() {
StorageEnvironment.defaults.set(["at://mine"], forKey: scoped("viewedStoryUris"))
StorageEnvironment.defaults.set(["at://legacy"], forKey: "viewedStoryUris")
AccountScopedStorage.migrateLegacyState(to: did)
XCTAssertEqual(StorageEnvironment.defaults.stringArray(forKey: scoped("viewedStoryUris")), ["at://mine"])
XCTAssertNil(StorageEnvironment.defaults.object(forKey: "viewedStoryUris"), "The legacy key is still cleaned up")
}
/// A fresh install has nothing to migrate.
func testMigratingWithNothingToClaimIsHarmless() {
AccountScopedStorage.migrateLegacyState(to: did)
for key in legacyKeys {
XCTAssertNil(StorageEnvironment.defaults.object(forKey: scoped(key)))
}
}
/// The whole point of the migration is that the storage classes then read
/// it back under the new keys.
func testMigratedViewedStoriesAreVisibleToTheStorage() {
let flushTarget = "did:plc:migration-flush-\(UUID().uuidString)"
defer { AccountScopedStorage.purge(did: flushTarget) }
let legacy = ViewedStoryStorage(did: nil)
legacy.markViewed(uri: "at://story-1", authorDid: "did:plc:author", createdAt: DateFormatting.nowISO())
XCTAssertTrue(legacy.isViewed(uri: "at://story-1"))
// Writes are debounced; switching accounts flushes the pending one
// synchronously, which is what a real sign-in does before migrating.
legacy.switchAccount(did: flushTarget)
AccountScopedStorage.migrateLegacyState(to: did)
XCTAssertTrue(ViewedStoryStorage(did: did).isViewed(uri: "at://story-1"))
}
func testMigratedSearchHistoryIsVisibleToTheStorage() {
let legacy = RecentSearchStorage(did: nil)
legacy.addTextSearch("portra")
legacy.addProfile(did: "did:plc:a", displayName: "A", handle: "a.test", avatar: nil)
AccountScopedStorage.migrateLegacyState(to: did)
let migrated = RecentSearchStorage(did: did)
XCTAssertEqual(migrated.textSearches.map(\.query), ["portra"])
XCTAssertEqual(migrated.profiles.map(\.did), ["did:plc:a"])
}
// MARK: - activeAccountID
/// The launch path reads this instead of the Keychain, so it has to round
/// trip and to clear properly on sign-out.
func testTheActiveAccountIDRoundTripsAndClears() {
let saved = AccountScopedStorage.activeAccountID
defer { AccountScopedStorage.activeAccountID = saved }
AccountScopedStorage.activeAccountID = did
XCTAssertEqual(AccountScopedStorage.activeAccountID, did)
AccountScopedStorage.activeAccountID = nil
XCTAssertNil(AccountScopedStorage.activeAccountID)
}
}