mirror of
https://github.com/yattee/yattee.git
synced 2026-10-28 15:37:17 +00:00
A zone deleted from another device — a reset, an iCloud data purge — arrives as an event on every other device, and the handler answered it by calling back into the sync engine from inside the engine's own callback. CloudKit traps on that, in release, so the app died a second after the push; and because the handler destroyed the persisted sync state before doing the work, the relaunch saw a zone that existed again, no local sign anything was lost, and never noticed the wipe. Both devices then reported being in sync with an empty zone. The handler now only writes a durable marker and mutates engine state: the zone is recreated through pending database changes — which also gets past the zone manager's memo — and the re-upload is registered, never sent. The engine sends on its own schedule. The marker is written before any of it and retired only after the engine serializes the state the recovery registered, so a crash anywhere in the middle is redone on the next launch. Accounts lead the re-seed now instead of trailing nine categories behind it. They carry the logins, the tombstones and the selected context, and going last is how a wipe took them with it. A purge is not a reset: CloudKit requires that data deleted that way is not sent back, so those reasons drop what was queued rather than republish it. Local data stays — it is this app's store, not a cache of the zone. And the reset that promises "your local data has been re-uploaded" now does it.
180 lines
6.1 KiB
Swift
180 lines
6.1 KiB
Swift
//
|
|
// CloudKitZoneRecoveryTests.swift
|
|
// YatteeTests
|
|
//
|
|
// Answering a record zone that was deleted on the server.
|
|
//
|
|
|
|
import CloudKit
|
|
import Foundation
|
|
import Testing
|
|
@testable import Yattee
|
|
|
|
@Suite("CloudKit Zone Recovery")
|
|
@MainActor
|
|
struct CloudKitZoneRecoveryTests {
|
|
private func makeDefaults() -> UserDefaults {
|
|
UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")!
|
|
}
|
|
|
|
// MARK: - Reason → behaviour
|
|
|
|
@Suite("Deletion reason")
|
|
@MainActor
|
|
struct DeletionReasonTests {
|
|
@Test("Our own reset elsewhere re-seeds the zone")
|
|
func appDeletionReseeds() {
|
|
#expect(ZoneDeletionResponse(reason: .deleted) == .reseed)
|
|
}
|
|
|
|
@Test("A purge from iCloud settings is never resent")
|
|
func purgeDiscards() {
|
|
#expect(ZoneDeletionResponse(reason: .purged) == .discard)
|
|
}
|
|
|
|
@Test("An encrypted-data reset is never resent")
|
|
func encryptedDataResetDiscards() {
|
|
#expect(ZoneDeletionResponse(reason: .encryptedDataReset) == .discard)
|
|
}
|
|
}
|
|
|
|
// MARK: - Re-seed composition
|
|
|
|
@Suite("Re-seed order")
|
|
@MainActor
|
|
struct ReseedOrderTests {
|
|
@Test("Accounts are registered before anything else")
|
|
func accountsGoFirst() {
|
|
#expect(ReseedCategory.reseedOrder.first == .accounts)
|
|
}
|
|
|
|
@Test("Every category is re-seeded, exactly once")
|
|
func orderCoversEveryCategory() {
|
|
#expect(Set(ReseedCategory.reseedOrder) == Set(ReseedCategory.allCases))
|
|
#expect(ReseedCategory.reseedOrder.count == ReseedCategory.allCases.count)
|
|
}
|
|
}
|
|
|
|
// MARK: - Marker lifecycle
|
|
|
|
@Test("Nothing is pending until a deletion is recorded")
|
|
func noMarkerByDefault() {
|
|
let marker = CloudKitZoneRecoveryMarker(defaults: makeDefaults())
|
|
#expect(marker.pending == nil)
|
|
}
|
|
|
|
@Test("The marker is written before the work and outlives the process")
|
|
func markerSurvivesRelaunch() {
|
|
let defaults = makeDefaults()
|
|
CloudKitZoneRecoveryMarker(defaults: defaults).begin(.reseed)
|
|
|
|
// A new instance is what a relaunch sees.
|
|
#expect(CloudKitZoneRecoveryMarker(defaults: defaults).pending == .reseed)
|
|
}
|
|
|
|
@Test("A state update retires the marker only once the work is registered")
|
|
func markerClearsAfterStatePersists() {
|
|
let defaults = makeDefaults()
|
|
let marker = CloudKitZoneRecoveryMarker(defaults: defaults)
|
|
|
|
marker.begin(.reseed)
|
|
// The engine serializes its state for all sorts of reasons; one that
|
|
// arrives before the re-seed is registered must leave the marker alone.
|
|
marker.stateDidPersist()
|
|
#expect(marker.pending == .reseed)
|
|
|
|
marker.readyToClear()
|
|
marker.stateDidPersist()
|
|
#expect(marker.pending == nil)
|
|
}
|
|
|
|
@Test("A recovery interrupted before it registered is still pending next launch")
|
|
func interruptedRecoveryIsRetried() {
|
|
let defaults = makeDefaults()
|
|
let crashed = CloudKitZoneRecoveryMarker(defaults: defaults)
|
|
crashed.begin(.reseed)
|
|
// ...process dies here: nothing was registered, nothing was cleared.
|
|
|
|
let relaunched = CloudKitZoneRecoveryMarker(defaults: defaults)
|
|
#expect(relaunched.pending == .reseed)
|
|
|
|
// A state update in the new process, before the recovery is redone,
|
|
// must not retire the marker it inherited.
|
|
relaunched.stateDidPersist()
|
|
#expect(relaunched.pending == .reseed)
|
|
|
|
relaunched.begin(.reseed)
|
|
relaunched.readyToClear()
|
|
relaunched.stateDidPersist()
|
|
#expect(relaunched.pending == nil)
|
|
}
|
|
|
|
@Test("Retiring the marker twice is harmless")
|
|
func clearingIsIdempotent() {
|
|
let defaults = makeDefaults()
|
|
let marker = CloudKitZoneRecoveryMarker(defaults: defaults)
|
|
|
|
marker.begin(.discard)
|
|
marker.readyToClear()
|
|
marker.stateDidPersist()
|
|
marker.stateDidPersist()
|
|
#expect(marker.pending == nil)
|
|
}
|
|
|
|
@Test("A fresh deletion is not retired by the previous one's state update")
|
|
func newDeletionRearms() {
|
|
let defaults = makeDefaults()
|
|
let marker = CloudKitZoneRecoveryMarker(defaults: defaults)
|
|
|
|
marker.begin(.reseed)
|
|
marker.readyToClear()
|
|
// A second wipe arrives before the first one's state was serialized.
|
|
marker.begin(.discard)
|
|
marker.stateDidPersist()
|
|
|
|
#expect(marker.pending == .discard)
|
|
}
|
|
|
|
@Test("A marker written by a build that meant something else is ignored")
|
|
func unknownMarkerValueIsIgnored() {
|
|
let defaults = makeDefaults()
|
|
defaults.set("something-else", forKey: "cloudKitZoneNeedsReseed")
|
|
|
|
#expect(CloudKitZoneRecoveryMarker(defaults: defaults).pending == nil)
|
|
}
|
|
|
|
// MARK: - Account side of a re-seed
|
|
|
|
@Test("A re-seed re-primes the account outbox, a purge empties it")
|
|
func accountOutboxFollowsTheResponse() {
|
|
let defaults = makeDefaults()
|
|
let accounts = AccountsManager(
|
|
identityScope: CredentialIdentityScope(fixedScope: "identity-A"),
|
|
instancesManager: nil,
|
|
defaults: defaults
|
|
)
|
|
let account = accounts.addAccount(instanceID: UUID(), displayName: "Main")
|
|
accounts.confirmUpload(account.id)
|
|
#expect(accounts.pendingUploads.isEmpty)
|
|
|
|
// What a re-seed does: the server holds nothing, so everything is owed
|
|
// again even though nothing changed locally.
|
|
accounts.markAllRecordsForUpload()
|
|
#expect(accounts.pendingUploads.contains(account.id))
|
|
|
|
// What a purge does: the account stays, the intent to upload it does not.
|
|
accounts.clearPendingUploads()
|
|
#expect(accounts.pendingUploads.isEmpty)
|
|
#expect(accounts.account(id: account.id) != nil)
|
|
|
|
// And it is durable, so a relaunch does not resend what was purged.
|
|
let relaunched = AccountsManager(
|
|
identityScope: CredentialIdentityScope(fixedScope: "identity-A"),
|
|
instancesManager: nil,
|
|
defaults: defaults
|
|
)
|
|
#expect(relaunched.pendingUploads.isEmpty)
|
|
#expect(relaunched.account(id: account.id) != nil)
|
|
}
|
|
}
|