diff options
| author | Christian Cleberg <[email protected]> | 2026-07-24 23:35:18 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-24 23:37:00 -0500 |
| commit | 99e4623af1b08f36120a01b67cbe60df99668651 (patch) | |
| tree | 07c8365f0c0054837f2b63611d71d1fd9c2f4e6c /DomainDigTests/DataMigrationServiceTests.swift | |
| parent | a52dee116d4066d1b59bd90b4ebc4def4e1597d6 (diff) | |
| download | domain-dig-99e4623af1b08f36120a01b67cbe60df99668651.tar.gz domain-dig-99e4623af1b08f36120a01b67cbe60df99668651.tar.bz2 domain-dig-99e4623af1b08f36120a01b67cbe60df99668651.zip | |
feat: versioned store-migration policy for persisted data (v5 step 2)
Third v5.0.0 roadmap item: define and implement a migration policy for the
on-device persisted store (tracked domains, history/snapshots, audits,
workflows, monitoring, settings), so data upgrades cleanly across app versions
instead of relying on a one-shot marker.
- DataMigrationService is reworked from a single boolean marker
(`data.migrations.v3_4_0`) into a versioned runner keyed by an integer store
schema version (`data.storeSchemaVersion`). It runs each step once in
ascending order up to `currentStoreSchemaVersion`, stamping the version as it
goes. Adding a future migration is now a `case N:` plus a version bump.
Policy guarantees, all covered by tests:
- Forward-only and idempotent; every step must be safe on an empty/older store.
- Never downgrades: a store written by a newer build (higher version) is left
byte-for-byte untouched.
- Pre-versioning installs are handled: a set legacy boolean marker reads as
"already at v1", so the v1 normalization never re-runs for them.
v1 is the existing normalization pass (dedup + drop the legacy `watchedDomains`
key + sanitize monitoring settings), now expressed as migration step 1.
- Docs/data-migration.md documents the persisted surface, the two independent
version lines (store vs. backup export), when to use lenient decoding vs. a
migration step, the runner contract, an "adding a migration" checklist, and
backup-import compatibility. Linked from the README.
- DataMigrationServiceTests: 6 tests over legacy fixtures — fresh-store stamping,
legacy `watchedDomains` migration + key drop, in-place dedup of the stored
blob, idempotence, legacy-marker-as-v1, and the no-downgrade guard. Full unit
suite: 58 passing.
Diffstat (limited to 'DomainDigTests/DataMigrationServiceTests.swift')
| -rw-r--r-- | DomainDigTests/DataMigrationServiceTests.swift | 109 |
1 files changed, 109 insertions, 0 deletions
diff --git a/DomainDigTests/DataMigrationServiceTests.swift b/DomainDigTests/DataMigrationServiceTests.swift new file mode 100644 index 0000000..3a6cdd9 --- /dev/null +++ b/DomainDigTests/DataMigrationServiceTests.swift @@ -0,0 +1,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") + } +} |
