Compare commits
No commits in common. "b83b89b9ac359a74c9d4590dd9d6822acf3a8d3e" and "e7561f5c108d8122e0fea1c39172168cdbf17c32" have entirely different histories.
b83b89b9ac
...
e7561f5c10
@ -32,17 +32,6 @@ 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**:
|
||||||
|
|
||||||
|
|||||||
@ -50,13 +50,11 @@ 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 multiple `StorageKeyCatalog`s (e.g., one per module) to generate audit reports and enforce key registration. Registration is additive and validates:
|
Apps can register a `StorageKeyCatalog` to generate audit reports and enforce key registration. Registration validates:
|
||||||
- Duplicate key names (across all registered catalogs)
|
- Duplicate key names
|
||||||
- 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.
|
||||||
|
|
||||||
|
|||||||
40
README.md
40
README.md
@ -194,17 +194,10 @@ 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.
|
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.
|
||||||
|
|
||||||
> [!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
|
||||||
// Module A
|
try await StorageRouter.shared.registerCatalog(AppStorageCatalog.self, migrateImmediately: true)
|
||||||
try await StorageRouter.shared.registerCatalog(AuthCatalog())
|
|
||||||
|
|
||||||
// Module B
|
|
||||||
try await StorageRouter.shared.registerCatalog(ProfileCatalog())
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Storage Design Philosophy
|
## Storage Design Philosophy
|
||||||
@ -378,43 +371,32 @@ LocalData can generate a catalog of all configured storage keys, even if no data
|
|||||||
|
|
||||||
```swift
|
```swift
|
||||||
struct AppStorageCatalog: StorageKeyCatalog {
|
struct AppStorageCatalog: StorageKeyCatalog {
|
||||||
let allKeys: [AnyStorageKey] = [
|
static var allKeys: [AnyStorageKey] {
|
||||||
.key(StorageKeys.AppVersionKey()),
|
[
|
||||||
.key(StorageKeys.UserPreferencesKey())
|
.key(StorageKeys.AppVersionKey()),
|
||||||
]
|
.key(StorageKeys.UserPreferencesKey())
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
2) Generate a report:
|
2) Generate a report:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
let report = StorageAuditReport.renderText(AppStorageCatalog())
|
let report = StorageAuditReport.renderText(for: AppStorageCatalog.self)
|
||||||
print(report)
|
print(report)
|
||||||
```
|
```
|
||||||
|
|
||||||
3) Create and register the catalog to enforce usage and catch duplicates:
|
3) Register the catalog to enforce usage and catch duplicates:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
let appCatalog = AppStorageCatalog()
|
|
||||||
do {
|
do {
|
||||||
try await StorageRouter.shared.registerCatalog(appCatalog)
|
try StorageRouter.shared.registerCatalog(AppStorageCatalog.self)
|
||||||
} 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.
|
||||||
|
|||||||
@ -1,46 +1,18 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct StorageAuditReport: Sendable {
|
public struct StorageAuditReport: Sendable {
|
||||||
public static func items(for catalog: some StorageKeyCatalog) -> [StorageKeyDescriptor] {
|
public static func items<C: StorageKeyCatalog>(for catalog: C.Type) -> [StorageKeyDescriptor] {
|
||||||
catalog.allKeys.map { $0.descriptor.withCatalog(catalog.name) }
|
catalog.allKeys.map(\.descriptor)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func renderText(_ catalog: some StorageKeyCatalog) -> String {
|
public static func renderText<C: StorageKeyCatalog>(for catalog: C.Type) -> 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)")
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
public struct AnyStorageKey: Sendable {
|
public struct AnyStorageKey: Sendable {
|
||||||
public internal(set) var descriptor: StorageKeyDescriptor
|
public let 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,20 +9,10 @@ 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)
|
||||||
|
|||||||
@ -10,7 +10,6 @@ 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,
|
||||||
@ -21,8 +20,7 @@ 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
|
||||||
@ -33,12 +31,10 @@ 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,
|
||||||
@ -49,24 +45,7 @@ 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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,3 @@
|
|||||||
public protocol StorageKeyCatalog: Sendable {
|
public protocol StorageKeyCatalog {
|
||||||
var name: String { get }
|
static var allKeys: [AnyStorageKey] { 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,8 +8,8 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
public static let shared = StorageRouter()
|
public static let shared = StorageRouter()
|
||||||
|
|
||||||
private var catalogRegistries: [String: [AnyStorageKey]] = [:]
|
private var registeredKeyNames: Set<String> = []
|
||||||
private var registeredKeys: [String: AnyStorageKey] = [:]
|
private var registeredEntries: [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,52 +76,23 @@ 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(_ catalog: some StorageKeyCatalog, migrateImmediately: Bool = false) async throws {
|
public func registerCatalog<C: StorageKeyCatalog>(_ catalog: C.Type, 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 })
|
||||||
// Validate against existing registrations to prevent collisions across catalogs
|
registeredEntries = entries
|
||||||
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 registeredKeys.values {
|
for entry in registeredEntries {
|
||||||
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")
|
||||||
@ -271,13 +242,11 @@ public actor StorageRouter: StorageProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func validateCatalogRegistration<Key: StorageKey>(for key: Key) throws {
|
private func validateCatalogRegistration<Key: StorageKey>(for key: Key) throws {
|
||||||
guard !registeredKeys.isEmpty else { return }
|
guard !registeredKeyNames.isEmpty else { return }
|
||||||
guard registeredKeys[key.name] != nil else {
|
guard registeredKeyNames.contains(key.name) 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(errorMessage)
|
assertionFailure("StorageKey not registered in catalog: \(key.name)")
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
throw StorageError.unregisteredKey(key.name)
|
throw StorageError.unregisteredKey(key.name)
|
||||||
@ -500,7 +469,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 = registeredKeys[keyName] else {
|
guard let entry = registeredEntries.first(where: { $0.descriptor.name == 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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import Testing
|
|||||||
@Suite struct AuditTests {
|
@Suite struct AuditTests {
|
||||||
|
|
||||||
private struct AuditCatalog: StorageKeyCatalog {
|
private struct AuditCatalog: StorageKeyCatalog {
|
||||||
var allKeys: [AnyStorageKey] {
|
static 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(AuditCatalog())
|
let text = StorageAuditReport.renderText(for: AuditCatalog.self)
|
||||||
|
|
||||||
#expect(text.contains("name=k1"))
|
#expect(text.contains("name=k1"))
|
||||||
#expect(text.contains("domain=userDefaults(standard)"))
|
#expect(text.contains("domain=userDefaults(standard)"))
|
||||||
|
|||||||
@ -1,100 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -15,7 +15,7 @@ private struct MockKey: StorageKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct PartialCatalog: StorageKeyCatalog {
|
private struct PartialCatalog: StorageKeyCatalog {
|
||||||
var allKeys: [AnyStorageKey] {
|
static 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())
|
try await router.registerCatalog(PartialCatalog.self)
|
||||||
|
|
||||||
let badKey = MockKey(name: "unregistered.key", domain: .userDefaults(suite: nil))
|
let badKey = MockKey(name: "unregistered.key", domain: .userDefaults(suite: nil))
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,7 @@ private struct TestCatalogKey: StorageKey {
|
|||||||
// MARK: - Test Catalogs
|
// MARK: - Test Catalogs
|
||||||
|
|
||||||
private struct ValidCatalog: StorageKeyCatalog {
|
private struct ValidCatalog: StorageKeyCatalog {
|
||||||
var allKeys: [AnyStorageKey] {
|
static 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 {
|
||||||
var allKeys: [AnyStorageKey] {
|
static 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 {
|
||||||
var allKeys: [AnyStorageKey] { [] }
|
static var allKeys: [AnyStorageKey] { [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct MissingDescriptionCatalog: StorageKeyCatalog {
|
private struct MissingDescriptionCatalog: StorageKeyCatalog {
|
||||||
var allKeys: [AnyStorageKey] {
|
static 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())
|
let items = StorageAuditReport.items(for: ValidCatalog.self)
|
||||||
|
|
||||||
#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(ValidCatalog())
|
let report = StorageAuditReport.renderText(for: ValidCatalog.self)
|
||||||
|
|
||||||
#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())
|
let items = StorageAuditReport.items(for: EmptyCatalog.self)
|
||||||
#expect(items.isEmpty)
|
#expect(items.isEmpty)
|
||||||
|
|
||||||
let report = StorageAuditReport.renderText(EmptyCatalog())
|
let report = StorageAuditReport.renderText(for: EmptyCatalog.self)
|
||||||
#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())
|
try await router.registerCatalog(DuplicateNameCatalog.self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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())
|
try await router.registerCatalog(MissingDescriptionCatalog.self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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())
|
try await router.registerCatalog(ValidCatalog.self)
|
||||||
|
|
||||||
// No error should occur
|
// No error should occur
|
||||||
try await router.migrateAllRegisteredKeys()
|
try await router.migrateAllRegisteredKeys()
|
||||||
|
|||||||
@ -29,14 +29,14 @@ struct SyncIntegrationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct SyncCatalog: StorageKeyCatalog {
|
private struct SyncCatalog: StorageKeyCatalog {
|
||||||
var allKeys: [AnyStorageKey] {
|
static 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())
|
try await router.registerCatalog(SyncCatalog.self)
|
||||||
|
|
||||||
let keyName = "sync.test.key"
|
let keyName = "sync.test.key"
|
||||||
let expectedValue = "Updated from Watch"
|
let expectedValue = "Updated from Watch"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user