From c8cce5c3225073071b9d9d516d528af2062a6ea1 Mon Sep 17 00:00:00 2001 From: Lysann Tranvouez Date: Mon, 9 Mar 2026 14:04:12 +0100 Subject: [PATCH] PersistenceController tests --- pass.xcodeproj/project.pbxproj | 12 +++ .../Controllers/PersistenceController.swift | 12 +-- .../PersistenceControllerTest.swift | 93 +++++++++++++++++++ passKitTests/CoreData/CoreDataTestCase.swift | 2 +- 4 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 passKitTests/Controllers/PersistenceControllerTest.swift diff --git a/pass.xcodeproj/project.pbxproj b/pass.xcodeproj/project.pbxproj index 33d5fd0..d04dd81 100644 --- a/pass.xcodeproj/project.pbxproj +++ b/pass.xcodeproj/project.pbxproj @@ -115,6 +115,7 @@ 5F9D7B0E27AF6FCA00A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 5F9D7B0F27AF6FD200A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 8A4716692F5EF56900C7A64D /* AppKeychainTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */; }; + 8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */; }; 8AD8EBF32F5E2723007475AB /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 8AD8EBF22F5E268D007475AB /* Fixtures */; }; 9A1D1CE526E5D1CE0052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE426E5D1CE0052028E /* OneTimePassword */; }; 9A1D1CE726E5D2230052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE626E5D2230052028E /* OneTimePassword */; }; @@ -425,6 +426,7 @@ 30FD2F77214D9E0E005E0A92 /* ParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParserTest.swift; sourceTree = ""; }; 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoTokenKit.framework; path = System/Library/Frameworks/CryptoTokenKit.framework; sourceTree = SDKROOT; }; 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKeychainTest.swift; sourceTree = ""; }; + 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerTest.swift; sourceTree = ""; }; 8AD8EBF22F5E268D007475AB /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = ""; }; 9A1EF0B324C50DD80074FEAC /* passBeta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBeta.entitlements; sourceTree = ""; }; 9A1EF0B424C50E780074FEAC /* passBetaAutoFillExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaAutoFillExtension.entitlements; sourceTree = ""; }; @@ -766,6 +768,14 @@ path = Crypto; sourceTree = ""; }; + 8A4716702F5EF7A900C7A64D /* Controllers */ = { + isa = PBXGroup; + children = ( + 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */, + ); + path = Controllers; + sourceTree = ""; + }; 9A58664F25AADB66006719C2 /* Services */ = { isa = PBXGroup; children = ( @@ -884,6 +894,7 @@ A26075861EEC6F34005DB03E /* passKitTests */ = { isa = PBXGroup; children = ( + 8A4716702F5EF7A900C7A64D /* Controllers */, DC64745A2D29BD43004B4BBC /* CoreData */, 30A86F93230F235800F821A4 /* Crypto */, 30BAC8C322E3BA4300438475 /* Testbase */, @@ -1630,6 +1641,7 @@ 30A86F95230F237000F821A4 /* CryptoFrameworkTest.swift in Sources */, 30A1D2AC21B32C2A00E2D1F7 /* TokenBuilderTest.swift in Sources */, 30DAFD4C240985E3002456E7 /* Array+SlicesTest.swift in Sources */, + 8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */, 301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */, 9ADC954124418A5F0005402E /* PasswordStoreTest.swift in Sources */, 30BAC8CB22E3BB6C00438475 /* DictBasedKeychain.swift in Sources */, diff --git a/passKit/Controllers/PersistenceController.swift b/passKit/Controllers/PersistenceController.swift index 67ad4fe..5d05001 100644 --- a/passKit/Controllers/PersistenceController.swift +++ b/passKit/Controllers/PersistenceController.swift @@ -18,19 +18,19 @@ public class PersistenceController { let container: NSPersistentContainer - init(isUnitTest: Bool = false) { + init(storeURL: URL? = nil) { self.container = NSPersistentContainer(name: Self.modelName, managedObjectModel: .sharedModel) let description = container.persistentStoreDescriptions.first description?.shouldMigrateStoreAutomatically = false description?.shouldInferMappingModelAutomatically = false - if isUnitTest { - description?.url = URL(fileURLWithPath: "/dev/null") - } else { - description?.url = URL(fileURLWithPath: Globals.dbPath) - } + description?.url = storeURL ?? URL(fileURLWithPath: Globals.dbPath) setup() } + static func forUnitTests() -> PersistenceController { + PersistenceController(storeURL: URL(fileURLWithPath: "/dev/null")) + } + func setup() { container.loadPersistentStores { _, error in if error != nil { diff --git a/passKitTests/Controllers/PersistenceControllerTest.swift b/passKitTests/Controllers/PersistenceControllerTest.swift new file mode 100644 index 0000000..4ae642f --- /dev/null +++ b/passKitTests/Controllers/PersistenceControllerTest.swift @@ -0,0 +1,93 @@ +// +// PersistenceControllerTest.swift +// passKitTests +// +// Created by Lysann Tranvouez on 9/3/26. +// Copyright © 2026 Bob Sun. All rights reserved. +// + +import CoreData +import XCTest + +@testable import passKit + +final class PersistenceControllerTest: XCTestCase { + func testModelLoads() { + let controller = PersistenceController.forUnitTests() + let context = controller.viewContext() + + let entityNames = context.persistentStoreCoordinator!.managedObjectModel.entities.map(\.name) + XCTAssertEqual(entityNames, ["PasswordEntity"]) + } + + func testInsertAndFetch() { + let controller = PersistenceController.forUnitTests() + let context = controller.viewContext() + XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 0) + + PasswordEntity.insert(name: "test", path: "test.gpg", isDir: false, into: context) + try? context.save() + + XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 1) + } + + func testReinitializePersistentStoreClearsData() { + let controller = PersistenceController.forUnitTests() + let context = controller.viewContext() + + PasswordEntity.insert(name: "test1", path: "test1.gpg", isDir: false, into: context) + PasswordEntity.insert(name: "test2", path: "test2.gpg", isDir: false, into: context) + try? context.save() + XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 2) + + controller.reinitializePersistentStore() + + // After reinitialize, old data should be gone + // (reinitializePersistentStore calls initPasswordEntityCoreData with the default repo URL, + // which won't exist in tests, so the result should be an empty store) + let remaining = PasswordEntity.fetchAll(in: context) + XCTAssertEqual(remaining.count, 0) + } + + func testMultipleControllersAreIndependent() { + let controller1 = PersistenceController.forUnitTests() + let controller2 = PersistenceController.forUnitTests() + + let context1 = controller1.viewContext() + let context2 = controller2.viewContext() + + PasswordEntity.insert(name: "only-in-1", path: "only-in-1.gpg", isDir: false, into: context1) + try? context1.save() + + XCTAssertEqual(PasswordEntity.fetchAll(in: context1).count, 1) + XCTAssertEqual(PasswordEntity.fetchAll(in: context2).count, 0) + } + + func testSaveAndLoadFromFile() throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let storeURL = tempDir.appendingPathComponent("test.sqlite") + + // Write + let controller1 = PersistenceController(storeURL: storeURL) + let context1 = controller1.viewContext() + PasswordEntity.insert(name: "saved", path: "saved.gpg", isDir: false, into: context1) + PasswordEntity.insert(name: "dir", path: "dir", isDir: true, into: context1) + controller1.save() + + // Load in a fresh controller from the same file + let controller2 = PersistenceController(storeURL: storeURL) + let context2 = controller2.viewContext() + let allEntities = PasswordEntity.fetchAll(in: context2) + + XCTAssertEqual(allEntities.count, 2) + XCTAssertNotNil(allEntities.first { $0.name == "saved" && !$0.isDir }) + XCTAssertNotNil(allEntities.first { $0.name == "dir" && $0.isDir }) + } + + func testSaveError() throws { + // NOTE: save() calls fatalError on Core Data save failures, so error propagation + // cannot be tested without refactoring save() to throw... + } +} diff --git a/passKitTests/CoreData/CoreDataTestCase.swift b/passKitTests/CoreData/CoreDataTestCase.swift index fa356bf..b11ddd1 100644 --- a/passKitTests/CoreData/CoreDataTestCase.swift +++ b/passKitTests/CoreData/CoreDataTestCase.swift @@ -20,7 +20,7 @@ class CoreDataTestCase: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() - controller = PersistenceController(isUnitTest: true) + controller = PersistenceController.forUnitTests() } override func tearDown() {