Compare commits

...

3 Commits

13 changed files with 280 additions and 49 deletions

View File

@ -32,6 +32,17 @@ Run all tests from the package root:
swift test swift test
``` ```
### Xcode Command Line
For packages that use platform-specific features (like `WatchConnectivity`), use `xcodebuild`:
```bash
# Run on an available simulator (e.g., iPhone 16)
xcodebuild test -scheme LocalData -destination 'platform=iOS Simulator,name=iPhone 16'
# Or using the specific device ID
xcodebuild test -scheme LocalData -destination 'id=4BF0FAE0-8FC1-4E19-89F4-2EDF12A28847'
```
### Xcode Test Plans ### Xcode Test Plans
For granular control (enabling/disabling specific tests or trying different configurations), use **Xcode Test Plans**: For granular control (enabling/disabling specific tests or trying different configurations), use **Xcode Test Plans**:

View File

@ -50,11 +50,13 @@ Each helper is a dedicated actor providing thread-safe access to a specific stor
Apps extend StorageKeys with their own key types and use StorageRouter.shared. This follows the Notification.Name pattern for discoverable keys. Apps extend StorageKeys with their own key types and use StorageRouter.shared. This follows the Notification.Name pattern for discoverable keys.
## Audit & Validation ## Audit & Validation
Apps can register a `StorageKeyCatalog` to generate audit reports and enforce key registration. Registration validates: Apps can register multiple `StorageKeyCatalog`s (e.g., one per module) to generate audit reports and enforce key registration. Registration is additive and validates:
- Duplicate key names - Duplicate key names (across all registered catalogs)
- Missing descriptions - Missing descriptions
- Unregistered keys at runtime (debug assertions) - Unregistered keys at runtime (debug assertions)
The `StorageRouter` provides discovery APIs to retrieve all registered keys for global audit reporting.
## Sync Behavior ## Sync Behavior
StorageRouter can call WCSession.updateApplicationContext for manual or automaticSmall sync policies when availability allows it. Session activation and receiving data are owned by the app. StorageRouter can call WCSession.updateApplicationContext for manual or automaticSmall sync policies when availability allows it. Session activation and receiving data are owned by the app.

View File

@ -194,10 +194,17 @@ try await StorageRouter.shared.migrate(for: StorageKeys.ModernKey())
``` ```
#### Automated Startup Sweep #### Automated Startup Sweep
When registering a catalog, you can enable `migrateImmediately` to perform a global sweep of all legacy keys for every key in the catalog. This ensures your storage is clean at every app launch. When registering a catalog, you can enable `migrateImmediately` to perform a global sweep of all legacy keys for every key in the catalog.
> [!NOTE]
> **Modular Registration**: `registerCatalog` is additive. You can call it multiple times from different modules to build an aggregate registry. The library will throw an error if multiple catalogs attempt to register the same key name.
```swift ```swift
try await StorageRouter.shared.registerCatalog(AppStorageCatalog.self, migrateImmediately: true) // Module A
try await StorageRouter.shared.registerCatalog(AuthCatalog())
// Module B
try await StorageRouter.shared.registerCatalog(ProfileCatalog())
``` ```
## Storage Design Philosophy ## Storage Design Philosophy
@ -371,32 +378,43 @@ LocalData can generate a catalog of all configured storage keys, even if no data
```swift ```swift
struct AppStorageCatalog: StorageKeyCatalog { struct AppStorageCatalog: StorageKeyCatalog {
static var allKeys: [AnyStorageKey] { let allKeys: [AnyStorageKey] = [
[
.key(StorageKeys.AppVersionKey()), .key(StorageKeys.AppVersionKey()),
.key(StorageKeys.UserPreferencesKey()) .key(StorageKeys.UserPreferencesKey())
] ]
}
} }
``` ```
2) Generate a report: 2) Generate a report:
```swift ```swift
let report = StorageAuditReport.renderText(for: AppStorageCatalog.self) let report = StorageAuditReport.renderText(AppStorageCatalog())
print(report) print(report)
``` ```
3) Register the catalog to enforce usage and catch duplicates: 3) Create and register the catalog to enforce usage and catch duplicates:
```swift ```swift
let appCatalog = AppStorageCatalog()
do { do {
try StorageRouter.shared.registerCatalog(AppStorageCatalog.self) try await StorageRouter.shared.registerCatalog(appCatalog)
} catch { } catch {
assertionFailure("Storage catalog registration failed: \(error)") assertionFailure("Storage catalog registration failed: \(error)")
} }
``` ```
4) Render a global report of all registered keys across all catalogs:
```swift
// Flat list
let globalReport = await StorageAuditReport.renderGlobalRegistry()
print(globalReport)
// Grouped by catalog module
let groupedReport = await StorageAuditReport.renderGlobalRegistryGrouped()
print(groupedReport)
```
Each `StorageKey` must provide a human-readable `description` used in audit reports. Each `StorageKey` must provide a human-readable `description` used in audit reports.
Dynamic key names are intentionally not supported in the core API to keep storage auditing strict and predictable. Dynamic key names are intentionally not supported in the core API to keep storage auditing strict and predictable.
If you need this later, see `FutureEnhancements.md` for a proposed design. If you need this later, see `FutureEnhancements.md` for a proposed design.

View File

@ -1,18 +1,46 @@
import Foundation import Foundation
public struct StorageAuditReport: Sendable { public struct StorageAuditReport: Sendable {
public static func items<C: StorageKeyCatalog>(for catalog: C.Type) -> [StorageKeyDescriptor] { public static func items(for catalog: some StorageKeyCatalog) -> [StorageKeyDescriptor] {
catalog.allKeys.map(\.descriptor) catalog.allKeys.map { $0.descriptor.withCatalog(catalog.name) }
} }
public static func renderText<C: StorageKeyCatalog>(for catalog: C.Type) -> String { public static func renderText(_ catalog: some StorageKeyCatalog) -> String {
renderText(items(for: catalog)) renderText(items(for: catalog))
} }
public static func renderText(_ entries: [AnyStorageKey]) -> String {
renderText(entries.map(\.descriptor))
}
public static func renderGlobalRegistry() async -> String {
let entries = await StorageRouter.shared.allRegisteredEntries()
return renderText(entries)
}
public static func renderGlobalRegistryGrouped() async -> String {
let catalogs = await StorageRouter.shared.allRegisteredCatalogs()
var reportLines: [String] = []
for catalogName in catalogs.keys.sorted() {
reportLines.append("=== \(catalogName) ===")
if let entries = catalogs[catalogName] {
let keysReport = renderText(entries)
reportLines.append(keysReport)
}
reportLines.append("")
}
return reportLines.joined(separator: "\n")
}
public static func renderText(_ items: [StorageKeyDescriptor]) -> String { public static func renderText(_ items: [StorageKeyDescriptor]) -> String {
let lines = items.map { item in let lines = items.map { item in
var parts: [String] = [] var parts: [String] = []
parts.append("name=\(item.name)") parts.append("name=\(item.name)")
if let catalog = item.catalog {
parts.append("catalog=\(catalog)")
}
parts.append("domain=\(string(for: item.domain))") parts.append("domain=\(string(for: item.domain))")
parts.append("security=\(string(for: item.security))") parts.append("security=\(string(for: item.security))")
parts.append("serializer=\(item.serializer)") parts.append("serializer=\(item.serializer)")

View File

@ -1,5 +1,5 @@
public struct AnyStorageKey: Sendable { public struct AnyStorageKey: Sendable {
public let descriptor: StorageKeyDescriptor public internal(set) var descriptor: StorageKeyDescriptor
private let migrateAction: @Sendable (StorageRouter) async throws -> Void private let migrateAction: @Sendable (StorageRouter) async throws -> Void
public init<Key: StorageKey>(_ key: Key) { public init<Key: StorageKey>(_ key: Key) {
@ -9,10 +9,20 @@ public struct AnyStorageKey: Sendable {
} }
} }
private init(descriptor: StorageKeyDescriptor, migrateAction: @escaping @Sendable (StorageRouter) async throws -> Void) {
self.descriptor = descriptor
self.migrateAction = migrateAction
}
public static func key<Key: StorageKey>(_ key: Key) -> AnyStorageKey { public static func key<Key: StorageKey>(_ key: Key) -> AnyStorageKey {
AnyStorageKey(key) AnyStorageKey(key)
} }
/// Internal use: Returns a copy of this key with the catalog name set.
internal func withCatalog(_ name: String) -> AnyStorageKey {
AnyStorageKey(descriptor: descriptor.withCatalog(name), migrateAction: migrateAction)
}
/// Internal use: Triggers the migration logic for this key. /// Internal use: Triggers the migration logic for this key.
internal func migrate(on router: StorageRouter) async throws { internal func migrate(on router: StorageRouter) async throws {
try await migrateAction(router) try await migrateAction(router)

View File

@ -10,6 +10,7 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
public let availability: PlatformAvailability public let availability: PlatformAvailability
public let syncPolicy: SyncPolicy public let syncPolicy: SyncPolicy
public let description: String public let description: String
public let catalog: String?
init( init(
name: String, name: String,
@ -20,7 +21,8 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
owner: String, owner: String,
availability: PlatformAvailability, availability: PlatformAvailability,
syncPolicy: SyncPolicy, syncPolicy: SyncPolicy,
description: String description: String,
catalog: String? = nil
) { ) {
self.name = name self.name = name
self.domain = domain self.domain = domain
@ -31,10 +33,12 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
self.availability = availability self.availability = availability
self.syncPolicy = syncPolicy self.syncPolicy = syncPolicy
self.description = description self.description = description
self.catalog = catalog
} }
public static func from<Key: StorageKey>( public static func from<Key: StorageKey>(
_ key: Key _ key: Key,
catalog: String? = nil
) -> StorageKeyDescriptor { ) -> StorageKeyDescriptor {
StorageKeyDescriptor( StorageKeyDescriptor(
name: key.name, name: key.name,
@ -45,7 +49,24 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
owner: key.owner, owner: key.owner,
availability: key.availability, availability: key.availability,
syncPolicy: key.syncPolicy, syncPolicy: key.syncPolicy,
description: key.description description: key.description,
catalog: catalog
)
}
/// Returns a new descriptor with the catalog name set.
public func withCatalog(_ catalogName: String) -> StorageKeyDescriptor {
StorageKeyDescriptor(
name: self.name,
domain: domain,
security: security,
serializer: serializer,
valueType: valueType,
owner: owner,
availability: availability,
syncPolicy: syncPolicy,
description: description,
catalog: catalogName
) )
} }
} }

View File

@ -1,3 +1,13 @@
public protocol StorageKeyCatalog { public protocol StorageKeyCatalog: Sendable {
static var allKeys: [AnyStorageKey] { get } var name: String { get }
var allKeys: [AnyStorageKey] { get }
}
extension StorageKeyCatalog {
public var name: String {
let fullName = String(describing: type(of: self))
// Simple cleanup for generic or nested names if needed,
// but describes the type clearly enough for audit.
return fullName
}
} }

View File

@ -8,8 +8,8 @@ public actor StorageRouter: StorageProviding {
public static let shared = StorageRouter() public static let shared = StorageRouter()
private var registeredKeyNames: Set<String> = [] private var catalogRegistries: [String: [AnyStorageKey]] = [:]
private var registeredEntries: [AnyStorageKey] = [] private var registeredKeys: [String: AnyStorageKey] = [:]
private var storageConfiguration: StorageConfiguration = .default private var storageConfiguration: StorageConfiguration = .default
private let keychain: KeychainStoring private let keychain: KeychainStoring
private let encryption: EncryptionHelper private let encryption: EncryptionHelper
@ -76,23 +76,52 @@ public actor StorageRouter: StorageProviding {
/// - Parameters: /// - Parameters:
/// - catalog: The catalog type to register. /// - catalog: The catalog type to register.
/// - migrateImmediately: If true, triggers a proactive migration (sweep) for all keys in the catalog. /// - migrateImmediately: If true, triggers a proactive migration (sweep) for all keys in the catalog.
public func registerCatalog<C: StorageKeyCatalog>(_ catalog: C.Type, migrateImmediately: Bool = false) async throws { public func registerCatalog(_ catalog: some StorageKeyCatalog, migrateImmediately: Bool = false) async throws {
let entries = catalog.allKeys let entries = catalog.allKeys
try validateDescription(entries) try validateDescription(entries)
try validateUniqueKeys(entries) try validateUniqueKeys(entries)
registeredKeyNames = Set(entries.map { $0.descriptor.name })
registeredEntries = entries // Validate against existing registrations to prevent collisions across catalogs
let catalogName = catalog.name
Logger.info(">>> [STORAGE] Registering Catalog: \(catalogName) (\(entries.count) keys)")
for entry in entries {
if let existing = registeredKeys[entry.descriptor.name] {
let existingCatalog = existing.descriptor.catalog ?? "Unknown"
let errorMessage = "STORAGE KEY COLLISION: Key name '\(entry.descriptor.name)' in \(catalogName) is already registered by \(existingCatalog)."
Logger.error(errorMessage)
throw StorageError.duplicateRegisteredKeys([entry.descriptor.name])
}
}
// Add to registry with catalog name context
catalogRegistries[catalogName] = entries
for entry in entries {
registeredKeys[entry.descriptor.name] = entry.withCatalog(catalogName)
}
Logger.info("<<< [STORAGE] Catalog Registered Successfully: \(catalogName)")
if migrateImmediately { if migrateImmediately {
try await migrateAllRegisteredKeys() try await migrateAllRegisteredKeys()
} }
} }
/// Returns all currently registered storage keys, grouped by catalog name.
public func allRegisteredCatalogs() -> [String: [AnyStorageKey]] {
catalogRegistries
}
/// Returns all currently registered storage keys.
public func allRegisteredEntries() -> [AnyStorageKey] {
Array(registeredKeys.values)
}
/// Triggers a proactive migration (sweep) for all registered storage keys. /// Triggers a proactive migration (sweep) for all registered storage keys.
/// This "drains" any legacy data into the modern storage locations. /// This "drains" any legacy data into the modern storage locations.
public func migrateAllRegisteredKeys() async throws { public func migrateAllRegisteredKeys() async throws {
Logger.debug(">>> [STORAGE] STARTING GLOBAL MIGRATION SWEEP") Logger.debug(">>> [STORAGE] STARTING GLOBAL MIGRATION SWEEP")
for entry in registeredEntries { for entry in registeredKeys.values {
try await entry.migrate(on: self) try await entry.migrate(on: self)
} }
Logger.debug("<<< [STORAGE] GLOBAL MIGRATION SWEEP COMPLETE") Logger.debug("<<< [STORAGE] GLOBAL MIGRATION SWEEP COMPLETE")
@ -242,11 +271,13 @@ public actor StorageRouter: StorageProviding {
} }
private func validateCatalogRegistration<Key: StorageKey>(for key: Key) throws { private func validateCatalogRegistration<Key: StorageKey>(for key: Key) throws {
guard !registeredKeyNames.isEmpty else { return } guard !registeredKeys.isEmpty else { return }
guard registeredKeyNames.contains(key.name) else { guard registeredKeys[key.name] != nil else {
let errorMessage = "UNREGISTERED STORAGE KEY: '\(key.name)' (Owner: \(key.owner)) accessed but not found in any registered catalog. Did you forget to call registerCatalog?"
Logger.error(errorMessage)
#if DEBUG #if DEBUG
if !isRunningTests { if !isRunningTests {
assertionFailure("StorageKey not registered in catalog: \(key.name)") assertionFailure(errorMessage)
} }
#endif #endif
throw StorageError.unregisteredKey(key.name) throw StorageError.unregisteredKey(key.name)
@ -469,7 +500,7 @@ public actor StorageRouter: StorageProviding {
/// This is called by SyncHelper when the paired device sends new context. /// This is called by SyncHelper when the paired device sends new context.
func updateFromSync(keyName: String, data: Data) async throws { func updateFromSync(keyName: String, data: Data) async throws {
// Find the registered entry for this key // Find the registered entry for this key
guard let entry = registeredEntries.first(where: { $0.descriptor.name == keyName }) else { guard let entry = registeredKeys[keyName] else {
Logger.debug("Received sync data for unregistered or uncatalogued key: \(keyName)") Logger.debug("Received sync data for unregistered or uncatalogued key: \(keyName)")
return return
} }

View File

@ -5,7 +5,7 @@ import Testing
@Suite struct AuditTests { @Suite struct AuditTests {
private struct AuditCatalog: StorageKeyCatalog { private struct AuditCatalog: StorageKeyCatalog {
static var allKeys: [AnyStorageKey] { var allKeys: [AnyStorageKey] {
[ [
.key(TestKey(name: "k1", domain: .userDefaults(suite: nil))), .key(TestKey(name: "k1", domain: .userDefaults(suite: nil))),
.key(TestKey(name: "k2", domain: .keychain(service: "s"), security: .keychain(accessibility: .afterFirstUnlock, accessControl: .userPresence))), .key(TestKey(name: "k2", domain: .keychain(service: "s"), security: .keychain(accessibility: .afterFirstUnlock, accessControl: .userPresence))),
@ -35,7 +35,7 @@ import Testing
} }
@Test func renderCatalogText() { @Test func renderCatalogText() {
let text = StorageAuditReport.renderText(for: AuditCatalog.self) let text = StorageAuditReport.renderText(AuditCatalog())
#expect(text.contains("name=k1")) #expect(text.contains("name=k1"))
#expect(text.contains("domain=userDefaults(standard)")) #expect(text.contains("domain=userDefaults(standard)"))

View File

@ -0,0 +1,100 @@
import Foundation
import Testing
@testable import LocalData
private struct TestRegistryKey: StorageKey {
typealias Value = String
let name: String
let domain: StorageDomain = .userDefaults(suite: nil)
let security: SecurityPolicy = .none
let serializer: Serializer<String> = .json
let owner: String
let description: String
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
init(name: String, owner: String = "Test", description: String = "Test") {
self.name = name
self.owner = owner
self.description = description
}
}
private struct CatalogA: StorageKeyCatalog {
var allKeys: [AnyStorageKey] {
[.key(TestRegistryKey(name: "key.a", owner: "ModuleA"))]
}
}
private struct CatalogB: StorageKeyCatalog {
var allKeys: [AnyStorageKey] {
[.key(TestRegistryKey(name: "key.b", owner: "ModuleB"))]
}
}
private struct CatalogCollision: StorageKeyCatalog {
var allKeys: [AnyStorageKey] {
[.key(TestRegistryKey(name: "key.a", owner: "ModuleCollision"))]
}
}
@Suite(.serialized)
struct ModularRegistryTests {
@Test func testAdditiveRegistration() async throws {
let router = StorageRouter(keychain: MockKeychainHelper())
try await router.registerCatalog(CatalogA())
#expect(await router.allRegisteredEntries().count == 1)
#expect(await router.allRegisteredEntries().contains { $0.descriptor.name == "key.a" })
try await router.registerCatalog(CatalogB())
#expect(await router.allRegisteredEntries().count == 2)
#expect(await router.allRegisteredEntries().contains { $0.descriptor.name == "key.b" })
}
@Test func testCollisionDetectionAcrossCatalogs() async throws {
let router = StorageRouter(keychain: MockKeychainHelper())
try await router.registerCatalog(CatalogA())
await #expect(throws: StorageError.self) {
try await router.registerCatalog(CatalogCollision())
}
}
@Test func testGlobalAuditReport() async throws {
let router = StorageRouter(keychain: MockKeychainHelper())
try await router.registerCatalog(CatalogA())
try await router.registerCatalog(CatalogB())
// Note: StorageAuditReport.renderGlobalRegistry() uses the shared router.
// For testing isolation, we should probably add a parameter to it or
// rely on the shared instance if we can reset it.
// Since StorageRouter is a singleton-heavy actor, we use renderText directly on the router's entries.
let entries = await router.allRegisteredEntries()
let report = StorageAuditReport.renderText(entries)
#expect(report.contains("key.a"))
#expect(report.contains("key.b"))
#expect(report.contains("catalog=CatalogA"))
#expect(report.contains("catalog=CatalogB"))
#expect(report.contains("ModuleA"))
#expect(report.contains("ModuleB"))
}
@Test func testGlobalAuditReportGrouped() async throws {
let router = StorageRouter(keychain: MockKeychainHelper())
try await router.registerCatalog(CatalogA())
try await router.registerCatalog(CatalogB())
let catalogs = await router.allRegisteredCatalogs()
#expect(catalogs.count == 2)
#expect(catalogs["CatalogA"] != nil)
#expect(catalogs["CatalogB"] != nil)
}
}

View File

@ -15,7 +15,7 @@ private struct MockKey: StorageKey {
} }
private struct PartialCatalog: StorageKeyCatalog { private struct PartialCatalog: StorageKeyCatalog {
static var allKeys: [AnyStorageKey] { var allKeys: [AnyStorageKey] {
[.key(MockKey(name: "registered.key", domain: .userDefaults(suite: nil)))] [.key(MockKey(name: "registered.key", domain: .userDefaults(suite: nil)))]
} }
} }
@ -35,7 +35,7 @@ struct RouterErrorTests {
} }
@Test func unregisteredKeyThrows() async throws { @Test func unregisteredKeyThrows() async throws {
try await router.registerCatalog(PartialCatalog.self) try await router.registerCatalog(PartialCatalog())
let badKey = MockKey(name: "unregistered.key", domain: .userDefaults(suite: nil)) let badKey = MockKey(name: "unregistered.key", domain: .userDefaults(suite: nil))

View File

@ -25,7 +25,7 @@ private struct TestCatalogKey: StorageKey {
// MARK: - Test Catalogs // MARK: - Test Catalogs
private struct ValidCatalog: StorageKeyCatalog { private struct ValidCatalog: StorageKeyCatalog {
static var allKeys: [AnyStorageKey] { var allKeys: [AnyStorageKey] {
[ [
.key(TestCatalogKey(name: "valid.key1", description: "First test key")), .key(TestCatalogKey(name: "valid.key1", description: "First test key")),
.key(TestCatalogKey(name: "valid.key2", description: "Second test key")) .key(TestCatalogKey(name: "valid.key2", description: "Second test key"))
@ -34,7 +34,7 @@ private struct ValidCatalog: StorageKeyCatalog {
} }
private struct DuplicateNameCatalog: StorageKeyCatalog { private struct DuplicateNameCatalog: StorageKeyCatalog {
static var allKeys: [AnyStorageKey] { var allKeys: [AnyStorageKey] {
[ [
.key(TestCatalogKey(name: "duplicate.name", description: "First instance")), .key(TestCatalogKey(name: "duplicate.name", description: "First instance")),
.key(TestCatalogKey(name: "duplicate.name", description: "Second instance")) .key(TestCatalogKey(name: "duplicate.name", description: "Second instance"))
@ -43,11 +43,11 @@ private struct DuplicateNameCatalog: StorageKeyCatalog {
} }
private struct EmptyCatalog: StorageKeyCatalog { private struct EmptyCatalog: StorageKeyCatalog {
static var allKeys: [AnyStorageKey] { [] } var allKeys: [AnyStorageKey] { [] }
} }
private struct MissingDescriptionCatalog: StorageKeyCatalog { private struct MissingDescriptionCatalog: StorageKeyCatalog {
static var allKeys: [AnyStorageKey] { var allKeys: [AnyStorageKey] {
[ [
.key(TestCatalogKey(name: "missing.desc", description: " ")) .key(TestCatalogKey(name: "missing.desc", description: " "))
] ]
@ -61,7 +61,7 @@ struct StorageCatalogTests {
private let router = StorageRouter(keychain: MockKeychainHelper()) private let router = StorageRouter(keychain: MockKeychainHelper())
@Test func auditReportContainsAllKeys() { @Test func auditReportContainsAllKeys() {
let items = StorageAuditReport.items(for: ValidCatalog.self) let items = StorageAuditReport.items(for: ValidCatalog())
#expect(items.count == 2) #expect(items.count == 2)
#expect(items[0].name == "valid.key1") #expect(items[0].name == "valid.key1")
@ -69,7 +69,7 @@ struct StorageCatalogTests {
} }
@Test func auditReportRendersText() { @Test func auditReportRendersText() {
let report = StorageAuditReport.renderText(for: ValidCatalog.self) let report = StorageAuditReport.renderText(ValidCatalog())
#expect(report.contains("valid.key1")) #expect(report.contains("valid.key1"))
#expect(report.contains("valid.key2")) #expect(report.contains("valid.key2"))
@ -89,30 +89,30 @@ struct StorageCatalogTests {
} }
@Test func emptyReportForEmptyCatalog() { @Test func emptyReportForEmptyCatalog() {
let items = StorageAuditReport.items(for: EmptyCatalog.self) let items = StorageAuditReport.items(for: EmptyCatalog())
#expect(items.isEmpty) #expect(items.isEmpty)
let report = StorageAuditReport.renderText(for: EmptyCatalog.self) let report = StorageAuditReport.renderText(EmptyCatalog())
#expect(report.isEmpty) #expect(report.isEmpty)
} }
@Test func catalogRegistrationDetectsDuplicates() async { @Test func catalogRegistrationDetectsDuplicates() async {
// Attempting to register a catalog with duplicate key names should throw // Attempting to register a catalog with duplicate key names should throw
await #expect(throws: StorageError.self) { await #expect(throws: StorageError.self) {
try await router.registerCatalog(DuplicateNameCatalog.self) try await router.registerCatalog(DuplicateNameCatalog())
} }
} }
@Test func catalogRegistrationDetectsMissingDescriptions() async { @Test func catalogRegistrationDetectsMissingDescriptions() async {
// Attempting to register a catalog with missing descriptions should throw // Attempting to register a catalog with missing descriptions should throw
await #expect(throws: StorageError.self) { await #expect(throws: StorageError.self) {
try await router.registerCatalog(MissingDescriptionCatalog.self) try await router.registerCatalog(MissingDescriptionCatalog())
} }
} }
@Test func migrateAllRegisteredKeysInvokesMigrationOnKeys() async throws { @Test func migrateAllRegisteredKeysInvokesMigrationOnKeys() async throws {
// This test verifies that migrateAllRegisteredKeys calling logic works. // This test verifies that migrateAllRegisteredKeys calling logic works.
try await router.registerCatalog(ValidCatalog.self) try await router.registerCatalog(ValidCatalog())
// No error should occur // No error should occur
try await router.migrateAllRegisteredKeys() try await router.migrateAllRegisteredKeys()

View File

@ -29,14 +29,14 @@ struct SyncIntegrationTests {
} }
private struct SyncCatalog: StorageKeyCatalog { private struct SyncCatalog: StorageKeyCatalog {
static var allKeys: [AnyStorageKey] { var allKeys: [AnyStorageKey] {
[.key(SyncKey(name: "sync.test.key"))] [.key(SyncKey(name: "sync.test.key"))]
} }
} }
@Test func updateFromSyncStoresDataLocally() async throws { @Test func updateFromSyncStoresDataLocally() async throws {
// 1. Register catalog so router knows about the key // 1. Register catalog so router knows about the key
try await router.registerCatalog(SyncCatalog.self) try await router.registerCatalog(SyncCatalog())
let keyName = "sync.test.key" let keyName = "sync.test.key"
let expectedValue = "Updated from Watch" let expectedValue = "Updated from Watch"