aboutsummaryrefslogtreecommitdiff
path: root/DomainDigTests/DataMigrationServiceTests.swift
blob: 3a6cdd9c9984e51fc57fff392ed8e837db3b1df5 (plain) (blame)
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
import XCTest
@testable import DomainDig

/// Characterization tests for the versioned store-migration runner. They drive
/// `migrateIfNeeded` against legacy on-disk fixtures in an ephemeral
/// `UserDefaults` suite and pin the policy: forward-only, idempotent, and
/// non-destructive to a store written by a newer build.
///
/// The raw storage-key strings ("trackedDomains", "watchedDomains", the legacy
/// marker) are duplicated here on purpose — they are the on-disk contract, and
/// hard-coding them means an accidental rename shows up as a failing migration.
final class DataMigrationServiceTests: XCTestCase {
    private let suiteName = "DomainDigTests.migration"
    private var defaults: UserDefaults!
    private let base = Date(timeIntervalSince1970: 1_700_000_000)

    override func setUp() {
        super.setUp()
        defaults = UserDefaults(suiteName: suiteName)
        defaults.removePersistentDomain(forName: suiteName)
    }

    override func tearDown() {
        defaults.removePersistentDomain(forName: suiteName)
        defaults = nil
        super.tearDown()
    }

    func testFreshStoreIsStampedAtCurrentVersion() {
        XCTAssertEqual(DataMigrationService.storeSchemaVersion(defaults: defaults), 0)

        DataMigrationService.migrateIfNeeded(defaults: defaults)

        XCTAssertEqual(
            DataMigrationService.storeSchemaVersion(defaults: defaults),
            DataMigrationService.currentStoreSchemaVersion
        )
    }

    func testLegacyWatchedDomainsAreMigratedAndTheOldKeyIsDropped() throws {
        let legacy = [WatchedDomain(domain: "legacy.example", createdAt: base, lastKnownAvailability: .registered)]
        defaults.set(try JSONEncoder().encode(legacy), forKey: "watchedDomains")

        DataMigrationService.migrateIfNeeded(defaults: defaults)

        let tracked = DomainDataPortabilityService.loadTrackedDomains(defaults: defaults)
        XCTAssertEqual(tracked.map(\.domain), ["legacy.example"])
        XCTAssertNil(defaults.data(forKey: "watchedDomains"), "legacy key is dropped after migration")
        XCTAssertNotNil(defaults.data(forKey: "trackedDomains"), "data is rewritten under the current key")
        XCTAssertEqual(DataMigrationService.storeSchemaVersion(defaults: defaults), 1)
    }

    func testMigrationDeduplicatesTheStoredBlobInPlace() throws {
        let dupes = [
            TrackedDomain(domain: "dupe.example", updatedAt: base.addingTimeInterval(-10)),
            TrackedDomain(domain: "DUPE.example", updatedAt: base)
        ]
        defaults.set(try JSONEncoder().encode(dupes), forKey: "trackedDomains")

        DataMigrationService.migrateIfNeeded(defaults: defaults)

        let data = try XCTUnwrap(defaults.data(forKey: "trackedDomains"))
        let stored = try JSONDecoder().decode([TrackedDomain].self, from: data)
        XCTAssertEqual(stored.count, 1, "the persisted blob is deduplicated, not just the load result")
    }

    func testMigrationIsIdempotent() throws {
        defaults.set(
            try JSONEncoder().encode([TrackedDomain(domain: "a.example", updatedAt: base)]),
            forKey: "trackedDomains"
        )

        DataMigrationService.migrateIfNeeded(defaults: defaults)
        let afterFirst = defaults.data(forKey: "trackedDomains")

        DataMigrationService.migrateIfNeeded(defaults: defaults)
        let afterSecond = defaults.data(forKey: "trackedDomains")

        XCTAssertEqual(afterFirst, afterSecond, "a second run makes no further changes")
        XCTAssertEqual(
            DataMigrationService.storeSchemaVersion(defaults: defaults),
            DataMigrationService.currentStoreSchemaVersion
        )
    }

    func testLegacyBooleanMarkerCountsAsVersionOne() {
        defaults.set(true, forKey: "data.migrations.v3_4_0")

        XCTAssertEqual(DataMigrationService.storeSchemaVersion(defaults: defaults), 1)

        // Already at v1, so the v1 step must not re-run: a leftover legacy blob
        // is left exactly as found.
        defaults.set(Data("x".utf8), forKey: "watchedDomains")
        DataMigrationService.migrateIfNeeded(defaults: defaults)
        XCTAssertNotNil(defaults.data(forKey: "watchedDomains"), "v1 is treated as already applied; no re-run")
    }

    func testNewerStoreVersionIsNeverDowngradedOrRewritten() throws {
        let future = DataMigrationService.currentStoreSchemaVersion + 1
        defaults.set(future, forKey: DataMigrationService.storeSchemaVersionKey)
        let blob = try JSONEncoder().encode([TrackedDomain(domain: "keep.example", updatedAt: base)])
        defaults.set(blob, forKey: "trackedDomains")

        DataMigrationService.migrateIfNeeded(defaults: defaults)

        XCTAssertEqual(DataMigrationService.storeSchemaVersion(defaults: defaults), future, "must never downgrade")
        XCTAssertEqual(defaults.data(forKey: "trackedDomains"), blob, "future-version data is left byte-for-byte")
    }
}