diff --git a/CHANGELOG.md b/CHANGELOG.md index e704cee2c..59d1e9fae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Internal: Swift Testing tests for `DataBrowserViewModel`, `ConnectionFormViewModel`, and `RowDetailViewModel` covering load lifecycle, pagination, sort/filter/search, delete, hydration, validation, edit lifecycle, save paths, and lazy cell load. Runs against in-memory `DatabaseDriver` and `SecureStore` mocks. `loadStoredCredentials`, `testConnection`, `save` on `ConnectionFormViewModel` now accept `any SecureStore` so the keychain backend can be substituted under test - Internal: extract `RowItemLabel` shared row component for the connection list and table list, dropping the inline HStack scaffolding from both - Internal: move per-database-type constants (`defaultPort`, `mobileDisplayName`, `mobileSupportedTypes`) onto a `DatabaseType` extension; the connection form picker and info screen read from the same source instead of duplicating the type-to-string switch - iOS: SQL syntax highlighter uses Swift Regex literals for static patterns (numbers, comments, strings) and consolidates the six per-pattern enumeration loops into a single typed helper diff --git a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj index bd4a8c5dc..b43df3502 100644 --- a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj +++ b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj @@ -34,6 +34,13 @@ remoteGlobalIDString = 5AA136032F82610F00ADCD58; remoteInfo = TableProWidgetExtension; }; + 5AC8A8FC2FAFC99F005DE2A3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5AB9F3D12F7C1C12001F3337 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5AB9F3D82F7C1C12001F3337; + remoteInfo = TableProMobile; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -518,6 +525,7 @@ 5AA313392F7EA5B4008EBA97 /* OpenSSL-SSL.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = "OpenSSL-SSL.xcframework"; path = "../Libs/ios/OpenSSL-SSL.xcframework"; sourceTree = ""; }; 5AA313532F7EC188008EBA97 /* LibSSH2.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = LibSSH2.xcframework; path = ../Libs/ios/LibSSH2.xcframework; sourceTree = ""; }; 5AB9F3D92F7C1C12001F3337 /* TableProMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TableProMobile.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 5AC8A8F82FAFC99F005DE2A3 /* TableProMobileTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TableProMobileTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -563,6 +571,11 @@ path = TableProMobile; sourceTree = ""; }; + 5AC8A8F92FAFC99F005DE2A3 /* TableProMobileTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = TableProMobileTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -595,6 +608,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5AC8A8F52FAFC99F005DE2A3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -1628,6 +1648,7 @@ 5AA136322F82675600ADCD58 /* TableProWidgetExtension.entitlements */, 5AB9F3DB2F7C1C12001F3337 /* TableProMobile */, 5AA136092F82610F00ADCD58 /* TableProWidget */, + 5AC8A8F92FAFC99F005DE2A3 /* TableProMobileTests */, 5AA313332F7EA5B4008EBA97 /* Frameworks */, 5AB9F3DA2F7C1C12001F3337 /* Products */, 5A72D6222F97A69500E2ADE0 /* Secrets.xcconfig */, @@ -1639,6 +1660,7 @@ children = ( 5AB9F3D92F7C1C12001F3337 /* TableProMobile.app */, 5AA136042F82610F00ADCD58 /* TableProWidgetExtension.appex */, + 5AC8A8F82FAFC99F005DE2A3 /* TableProMobileTests.xctest */, ); name = Products; sourceTree = ""; @@ -1698,6 +1720,29 @@ productReference = 5AB9F3D92F7C1C12001F3337 /* TableProMobile.app */; productType = "com.apple.product-type.application"; }; + 5AC8A8F72FAFC99F005DE2A3 /* TableProMobileTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5AC8A8FE2FAFC99F005DE2A3 /* Build configuration list for PBXNativeTarget "TableProMobileTests" */; + buildPhases = ( + 5AC8A8F42FAFC99F005DE2A3 /* Sources */, + 5AC8A8F52FAFC99F005DE2A3 /* Frameworks */, + 5AC8A8F62FAFC99F005DE2A3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 5AC8A8FD2FAFC99F005DE2A3 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 5AC8A8F92FAFC99F005DE2A3 /* TableProMobileTests */, + ); + name = TableProMobileTests; + packageProductDependencies = ( + ); + productName = TableProMobileTests; + productReference = 5AC8A8F82FAFC99F005DE2A3 /* TableProMobileTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -1705,7 +1750,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2650; + LastSwiftUpdateCheck = 2640; LastUpgradeCheck = 2650; TargetAttributes = { 5AA136032F82610F00ADCD58 = { @@ -1714,6 +1759,10 @@ 5AB9F3D82F7C1C12001F3337 = { CreatedOnToolsVersion = 26.4; }; + 5AC8A8F72FAFC99F005DE2A3 = { + CreatedOnToolsVersion = 26.4.1; + TestTargetID = 5AB9F3D82F7C1C12001F3337; + }; }; }; buildConfigurationList = 5AB9F3D42F7C1C12001F3337 /* Build configuration list for PBXProject "TableProMobile" */; @@ -1735,6 +1784,7 @@ targets = ( 5AB9F3D82F7C1C12001F3337 /* TableProMobile */, 5AA136032F82610F00ADCD58 /* TableProWidgetExtension */, + 5AC8A8F72FAFC99F005DE2A3 /* TableProMobileTests */, ); }; /* End PBXProject section */ @@ -1755,6 +1805,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5AC8A8F62FAFC99F005DE2A3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -1772,6 +1829,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5AC8A8F42FAFC99F005DE2A3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -1780,6 +1844,11 @@ target = 5AA136032F82610F00ADCD58 /* TableProWidgetExtension */; targetProxy = 5AA136112F82611000ADCD58 /* PBXContainerItemProxy */; }; + 5AC8A8FD2FAFC99F005DE2A3 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5AB9F3D82F7C1C12001F3337 /* TableProMobile */; + targetProxy = 5AC8A8FC2FAFC99F005DE2A3 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -2054,6 +2123,52 @@ }; name = Release; }; + 5AC8A8FF2FAFC99F005DE2A3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = D7HJ5TFYCU; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = vn.nqd.TableProMobileTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TableProMobile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TableProMobile"; + }; + name = Debug; + }; + 5AC8A9002FAFC99F005DE2A3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = D7HJ5TFYCU; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = vn.nqd.TableProMobileTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TableProMobile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TableProMobile"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -2084,6 +2199,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 5AC8A8FE2FAFC99F005DE2A3 /* Build configuration list for PBXNativeTarget "TableProMobileTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5AC8A8FF2FAFC99F005DE2A3 /* Debug */, + 5AC8A9002FAFC99F005DE2A3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ diff --git a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift index a25a9d789..8a580eea9 100644 --- a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift @@ -105,7 +105,7 @@ final class ConnectionFormViewModel { // MARK: - Credential Hydration - func loadStoredCredentials(secureStore: KeychainSecureStore) async { + func loadStoredCredentials(secureStore: any SecureStore) async { guard let conn = existingConnection else { return } let connKey = "com.TablePro.password.\(conn.id.uuidString)" if let stored = try? secureStore.retrieve(forKey: connKey), !stored.isEmpty { @@ -203,7 +203,7 @@ final class ConnectionFormViewModel { // MARK: - Test Connection - func testConnection(appState: AppState, secureStore: KeychainSecureStore) async { + func testConnection(appState: AppState, secureStore: any SecureStore) async { isTesting = true testResult = nil defer { isTesting = false } @@ -256,7 +256,7 @@ final class ConnectionFormViewModel { // MARK: - Save - func save(appState: AppState, secureStore: KeychainSecureStore) -> DatabaseConnection? { + func save(appState: AppState, secureStore: any SecureStore) -> DatabaseConnection? { let connection = buildConnection() var storageFailed = false diff --git a/TableProMobile/TableProMobileTests/ConnectionFormViewModelTests.swift b/TableProMobile/TableProMobileTests/ConnectionFormViewModelTests.swift new file mode 100644 index 000000000..ab962aed8 --- /dev/null +++ b/TableProMobile/TableProMobileTests/ConnectionFormViewModelTests.swift @@ -0,0 +1,131 @@ +import Foundation +import Testing +import TableProDatabase +import TableProModels +@testable import TableProMobile + +@MainActor +@Suite("ConnectionFormViewModel") +struct ConnectionFormViewModelTests { + + private func makeStoredConnection() -> DatabaseConnection { + var conn = DatabaseConnection( + id: UUID(), + name: "Local", + type: .postgresql, + host: "10.0.0.1", + port: 5432, + username: "alice", + database: "appdb", + sshEnabled: false, + sslEnabled: true, + groupId: nil, + tagId: nil + ) + conn.safeModeLevel = .readOnly + return conn + } + + @Test("init without editing leaves defaults and reads default safe mode") + func newConnectionDefaults() { + UserDefaults.standard.set(SafeModeLevel.confirmWrites.rawValue, forKey: AppPreferences.defaultSafeModeKey) + defer { UserDefaults.standard.removeObject(forKey: AppPreferences.defaultSafeModeKey) } + + let vm = ConnectionFormViewModel() + + #expect(vm.isEditing == false) + #expect(vm.type == .mysql) + #expect(vm.host == "127.0.0.1") + #expect(vm.port == "3306") + #expect(vm.safeModeLevel == .confirmWrites) + } + + @Test("init editing hydrates fields from connection") + func hydration() { + let conn = makeStoredConnection() + let vm = ConnectionFormViewModel(editing: conn) + + #expect(vm.isEditing == true) + #expect(vm.name == "Local") + #expect(vm.type == .postgresql) + #expect(vm.host == "10.0.0.1") + #expect(vm.port == "5432") + #expect(vm.username == "alice") + #expect(vm.database == "appdb") + #expect(vm.sslEnabled == true) + #expect(vm.safeModeLevel == .readOnly) + } + + @Test("changing type updates default port") + func typeChangeUpdatesPort() { + let vm = ConnectionFormViewModel() + #expect(vm.port == "3306") + + vm.type = .postgresql + #expect(vm.port == "5432") + + vm.type = .redis + #expect(vm.port == "6379") + + vm.type = .sqlite + #expect(vm.port == "") + } + + @Test("canSave requires database for SQLite, host for server types") + func canSaveValidation() { + let vm = ConnectionFormViewModel() + vm.type = .mysql + vm.host = "" + #expect(vm.canSave == false) + + vm.host = "localhost" + #expect(vm.canSave == true) + + vm.type = .sqlite + vm.database = "" + #expect(vm.canSave == false) + + vm.database = "/tmp/test.db" + #expect(vm.canSave == true) + } + + @Test("loadStoredCredentials hydrates password from secure store") + func credentialHydration() async { + let conn = makeStoredConnection() + let store = MockSecureStore() + store.seed("com.TablePro.password.\(conn.id.uuidString)", "secret") + store.seed("com.TablePro.sshpassword.\(conn.id.uuidString)", "ssh-secret") + + let vm = ConnectionFormViewModel(editing: conn) + await vm.loadStoredCredentials(secureStore: store) + + #expect(vm.password == "secret") + #expect(vm.sshPassword == "ssh-secret") + } + + @Test("clearSelectedFile resets URL and database") + func clearFile() { + let vm = ConnectionFormViewModel() + vm.type = .sqlite + vm.database = "/some/path.db" + vm.selectedFileURL = URL(fileURLWithPath: "/some/path.db") + + vm.clearSelectedFile() + #expect(vm.selectedFileURL == nil) + #expect(vm.database == "") + } + + @Test("createNewDatabase creates a .db URL in Documents") + func createDatabase() { + let vm = ConnectionFormViewModel() + vm.type = .sqlite + vm.newDatabaseName = "scratch" + + vm.createNewDatabase() + + #expect(vm.selectedFileURL?.lastPathComponent == "scratch.db") + #expect(vm.database.hasSuffix("/scratch.db")) + #expect(vm.name == "scratch") + #expect(vm.newDatabaseName == "") + } +} diff --git a/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift b/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift new file mode 100644 index 000000000..4f5b9ed7e --- /dev/null +++ b/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift @@ -0,0 +1,185 @@ +import Foundation +import Testing +import TableProDatabase +import TableProModels +import TableProQuery +@testable import TableProMobile + +@MainActor +@Suite("DataBrowserViewModel") +struct DataBrowserViewModelTests { + + private func makeSession(driver: MockDatabaseDriver) -> ConnectionSession { + ConnectionSession( + connectionId: UUID(), + driver: driver, + activeDatabase: "test", + tables: [] + ) + } + + private func makeColumns() -> [ColumnInfo] { + [ + ColumnInfo(name: "id", typeName: "INT", isPrimaryKey: true, isNullable: false, ordinalPosition: 0), + ColumnInfo(name: "name", typeName: "VARCHAR(64)", ordinalPosition: 1) + ] + } + + @Test("load without session sets loadError") + func loadWithoutSessionSetsError() async { + let vm = DataBrowserViewModel() + vm.attach(session: nil, table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + + await vm.load(isInitial: true) + + #expect(vm.loadError != nil) + #expect(vm.isLoading == false) + } + + @Test("load with session populates columns and rows") + func loadPopulates() async { + let driver = MockDatabaseDriver() + driver.scriptedColumns = makeColumns() + driver.scriptedExecuteResults = [ + .success(QueryResult( + columns: makeColumns(), + rows: [["1", "Alice"], ["2", "Bob"]], + rowsAffected: 0, + executionTime: 0.01 + )), + .success(QueryResult(columns: [], rows: [["2"]], rowsAffected: 0, executionTime: 0)) + ] + + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + await vm.load(isInitial: true) + + #expect(vm.legacyRows.count == 2) + #expect(vm.columnDetails.count == 2) + #expect(vm.hasPrimaryKeys == true) + #expect(vm.loadError == nil) + #expect(vm.isLoading == false) + } + + @Test("hasActiveSearch reflects activeSearchText") + func searchFlagsTrack() async { + let driver = MockDatabaseDriver() + driver.scriptedColumns = makeColumns() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["0"]], rowsAffected: 0, executionTime: 0)) + ] + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + await vm.load(isInitial: true) + + #expect(vm.hasActiveSearch == false) + + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["0"]], rowsAffected: 0, executionTime: 0)) + ] + await vm.applySearch("alice") + #expect(vm.hasActiveSearch == true) + #expect(vm.activeSearchText == "alice") + + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["0"]], rowsAffected: 0, executionTime: 0)) + ] + await vm.clearSearch() + #expect(vm.hasActiveSearch == false) + #expect(vm.activeSearchText == "") + } + + @Test("pagination prev/next clamps at boundaries") + func paginationClamps() async { + let driver = MockDatabaseDriver() + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + + #expect(vm.pagination.currentPage == 0) + await vm.goToPreviousPage() + #expect(vm.pagination.currentPage == 0, "previous on page 0 should not underflow") + } + + @Test("primaryKeyValues returns only PK columns from row") + func primaryKeyExtraction() async { + let driver = MockDatabaseDriver() + driver.scriptedColumns = makeColumns() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["42", "Alice"]], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["1"]], rowsAffected: 0, executionTime: 0)) + ] + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + await vm.load(isInitial: true) + + let pks = vm.primaryKeyValues(for: ["42", "Alice"]) + #expect(pks.count == 1) + #expect(pks.first?.column == "id") + #expect(pks.first?.value == "42") + } + + @Test("deleteRow returns true on success and runs DELETE SQL") + func deleteSuccess() async { + let driver = MockDatabaseDriver() + driver.scriptedColumns = makeColumns() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["1", "Alice"]], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["1"]], rowsAffected: 0, executionTime: 0)) + ] + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + await vm.load(isInitial: true) + + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [], rowsAffected: 1, executionTime: 0)), + .success(QueryResult(columns: makeColumns(), rows: [], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["0"]], rowsAffected: 0, executionTime: 0)) + ] + + let success = await vm.deleteRow(pkValues: [(column: "id", value: "1")]) + #expect(success == true) + #expect(vm.operationError == nil) + #expect(driver.executedQueries.contains(where: { $0.uppercased().hasPrefix("DELETE") })) + } + + @Test("deleteRow returns false and sets operationError on driver failure") + func deleteFailure() async { + let driver = MockDatabaseDriver() + driver.scriptedColumns = makeColumns() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["1", "Alice"]], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["1"]], rowsAffected: 0, executionTime: 0)) + ] + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + await vm.load(isInitial: true) + + driver.scriptedExecuteResults = [.failure(MockDatabaseDriver.MockError.scripted)] + + let success = await vm.deleteRow(pkValues: [(column: "id", value: "1")]) + #expect(success == false) + #expect(vm.operationError != nil) + } + + @Test("changePageSize resets currentPage and totalRows") + func changePageSizeResets() async { + let driver = MockDatabaseDriver() + driver.scriptedColumns = makeColumns() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["0"]], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: makeColumns(), rows: [], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["0"]], rowsAffected: 0, executionTime: 0)) + ] + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + await vm.load(isInitial: true) + + await vm.changePageSize(50) + #expect(vm.pagination.pageSize == 50) + #expect(vm.pagination.currentPage == 0) + } +} diff --git a/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift b/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift new file mode 100644 index 000000000..f8fe9db5f --- /dev/null +++ b/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift @@ -0,0 +1,86 @@ +import Foundation +import TableProDatabase +import TableProModels + +final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { + enum MockError: Error { case scripted } + + var scriptedExecuteResults: [Result] = [] + var scriptedColumns: [ColumnInfo] = [] + var scriptedForeignKeys: [ForeignKeyInfo] = [] + var scriptedTables: [TableInfo] = [] + var scriptedDatabases: [String] = [] + var scriptedSchemas: [String] = [] + + private(set) var executedQueries: [String] = [] + private(set) var fetchColumnsCalls: Int = 0 + private(set) var fetchForeignKeysCalls: Int = 0 + + var supportsSchemas: Bool = false + var currentSchema: String? = nil + var supportsTransactions: Bool = true + var serverVersion: String? = "Mock 1.0" + + func connect() async throws {} + func disconnect() async throws {} + func ping() async throws -> Bool { true } + func cancelCurrentQuery() async throws {} + + func execute(query: String) async throws -> QueryResult { + executedQueries.append(query) + guard !scriptedExecuteResults.isEmpty else { + return QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0) + } + switch scriptedExecuteResults.removeFirst() { + case .success(let result): return result + case .failure(let error): throw error + } + } + + func fetchTables(schema: String?) async throws -> [TableInfo] { scriptedTables } + + func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { + fetchColumnsCalls += 1 + return scriptedColumns + } + + func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { [] } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { + fetchForeignKeysCalls += 1 + return scriptedForeignKeys + } + + func fetchDatabases() async throws -> [String] { scriptedDatabases } + func fetchSchemas() async throws -> [String] { scriptedSchemas } + func switchDatabase(to name: String) async throws {} + func switchSchema(to name: String) async throws {} + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} +} + +final class MockSecureStore: SecureStore, @unchecked Sendable { + private var storage: [String: String] = [:] + var failNextStore = false + + func store(_ value: String, forKey key: String) throws { + if failNextStore { + failNextStore = false + throw MockDatabaseDriver.MockError.scripted + } + storage[key] = value + } + + func retrieve(forKey key: String) throws -> String? { + storage[key] + } + + func delete(forKey key: String) throws { + storage.removeValue(forKey: key) + } + + func seed(_ key: String, _ value: String) { + storage[key] = value + } +} diff --git a/TableProMobile/TableProMobileTests/README.md b/TableProMobile/TableProMobileTests/README.md new file mode 100644 index 000000000..a0fae318a --- /dev/null +++ b/TableProMobile/TableProMobileTests/README.md @@ -0,0 +1,26 @@ +# TableProMobileTests + +Swift Testing tests for the iOS view models extracted in P1 #5 (PRs #1164, #1165, #1166). + +## One-time Xcode setup + +The test target is not in the Xcode project yet. To enable these tests: + +1. Open `TableProMobile.xcodeproj` in Xcode +2. File → New → Target +3. iOS → Unit Testing Bundle +4. Product Name: `TableProMobileTests` +5. Target to Test: `TableProMobile` +6. Testing System: **Swift Testing** +7. Finish + +Xcode will create a stub `TableProMobileTests.swift` that you can delete. Because the project uses synchronized file groups (Xcode 16+), the existing files in this folder will be picked up automatically once the target points at this directory. + +If Xcode chose a different folder name when creating the target, drag-and-drop these files into the target in the navigator, or rename the test root group to match. + +## Layout + +- `Mocks/MockDatabaseDriver.swift` - in-memory `DatabaseDriver` and `SecureStore` stubs with scriptable results +- `DataBrowserViewModelTests.swift` - load lifecycle, pagination, sort/filter/search, delete, primary key extraction +- `ConnectionFormViewModelTests.swift` - hydration from existing connection, default port on type change, validation, credential hydration, file picker helpers +- `RowDetailViewModelTests.swift` - edit lifecycle, save with/without changes, primary key requirement, lazy cell load diff --git a/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift b/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift new file mode 100644 index 000000000..9fdbcbaa3 --- /dev/null +++ b/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift @@ -0,0 +1,161 @@ +import Foundation +import Testing +import TableProDatabase +import TableProModels +@testable import TableProMobile + +@MainActor +@Suite("RowDetailViewModel") +struct RowDetailViewModelTests { + + private func makeColumns() -> [ColumnInfo] { + [ + ColumnInfo(name: "id", typeName: "INT", isPrimaryKey: true, isNullable: false, ordinalPosition: 0), + ColumnInfo(name: "name", typeName: "VARCHAR(64)", ordinalPosition: 1) + ] + } + + private func makeRows() -> [Row] { + [ + Row(cells: [.text("1"), .text("Alice")]), + Row(cells: [.text("2"), .text("Bob")]) + ] + } + + private func makeSession(driver: MockDatabaseDriver) -> ConnectionSession { + ConnectionSession(connectionId: UUID(), driver: driver, activeDatabase: "test") + } + + @Test("canEdit requires session, table, primary key, and not safe-mode-blocked") + func canEditPreconditions() { + let driver = MockDatabaseDriver() + + let withoutTable = RowDetailViewModel(columns: makeColumns(), rows: makeRows(), initialIndex: 0) + #expect(withoutTable.canEdit == false, "no table → cannot edit") + + let blocked = RowDetailViewModel( + columns: makeColumns(), rows: makeRows(), initialIndex: 0, + table: TableInfo(name: "users"), session: makeSession(driver: driver), + columnDetails: makeColumns(), safeModeLevel: .readOnly + ) + #expect(blocked.canEdit == false, "read-only safe mode → cannot edit") + + let editable = RowDetailViewModel( + columns: makeColumns(), rows: makeRows(), initialIndex: 0, + table: TableInfo(name: "users"), session: makeSession(driver: driver), + columnDetails: makeColumns(), safeModeLevel: .off + ) + #expect(editable.canEdit == true) + } + + @Test("startEditing populates editedValues from current row") + func startEditingCopiesValues() { + let driver = MockDatabaseDriver() + let vm = RowDetailViewModel( + columns: makeColumns(), rows: makeRows(), initialIndex: 0, + table: TableInfo(name: "users"), session: makeSession(driver: driver), + columnDetails: makeColumns() + ) + + vm.startEditing() + #expect(vm.isEditing == true) + #expect(vm.editedValues == ["1", "Alice"]) + } + + @Test("cancelEditing clears edited values") + func cancelEditingResets() { + let vm = RowDetailViewModel(columns: makeColumns(), rows: makeRows(), initialIndex: 0) + vm.startEditing() + vm.setEditedValue("Charlie", at: 1) + + vm.cancelEditing() + #expect(vm.isEditing == false) + #expect(vm.editedValues.isEmpty) + } + + @Test("toggleNull flips between empty string and nil") + func toggleNullFlips() { + let vm = RowDetailViewModel(columns: makeColumns(), rows: makeRows(), initialIndex: 0) + vm.startEditing() + + #expect(vm.editedValues[1] == "Alice") + vm.toggleNull(at: 1) + #expect(vm.editedValues[1] == nil) + + vm.toggleNull(at: 1) + #expect(vm.editedValues[1] == "") + } + + @Test("saveChanges with no changes early-returns true and exits edit mode") + func saveNoChanges() async { + let driver = MockDatabaseDriver() + let vm = RowDetailViewModel( + columns: makeColumns(), rows: makeRows(), initialIndex: 0, + table: TableInfo(name: "users"), session: makeSession(driver: driver), + columnDetails: makeColumns() + ) + vm.startEditing() + + let success = await vm.saveChanges() + #expect(success == true) + #expect(vm.isEditing == false) + #expect(driver.executedQueries.isEmpty, "no UPDATE should be issued when nothing changed") + } + + @Test("saveChanges runs UPDATE with primary keys and modified columns only") + func saveExecutesUpdate() async { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [], rowsAffected: 1, executionTime: 0)) + ] + let vm = RowDetailViewModel( + columns: makeColumns(), rows: makeRows(), initialIndex: 0, + table: TableInfo(name: "users"), session: makeSession(driver: driver), + columnDetails: makeColumns() + ) + vm.startEditing() + vm.setEditedValue("Charlie", at: 1) + + let success = await vm.saveChanges() + #expect(success == true) + #expect(driver.executedQueries.count == 1) + let query = driver.executedQueries[0].uppercased() + #expect(query.hasPrefix("UPDATE")) + #expect(query.contains("WHERE")) + } + + @Test("saveChanges fails when no primary key value present") + func saveWithoutPrimaryKey() async { + let driver = MockDatabaseDriver() + let columnsNoPK: [ColumnInfo] = [ + ColumnInfo(name: "name", typeName: "TEXT", ordinalPosition: 0) + ] + let rows = [Row(cells: [.text("Alice")])] + let vm = RowDetailViewModel( + columns: columnsNoPK, rows: rows, initialIndex: 0, + table: TableInfo(name: "users"), session: makeSession(driver: driver), + columnDetails: columnsNoPK + ) + vm.startEditing() + vm.setEditedValue("Charlie", at: 0) + + let success = await vm.saveChanges() + #expect(success == false) + #expect(vm.operationError != nil) + } + + @Test("loadFullValue populates override and clears loadingCell") + func lazyLoadPopulates() async { + let provider: (CellRef) async throws -> String? = { _ in "the full blob value" } + let vm = RowDetailViewModel( + columns: makeColumns(), rows: makeRows(), initialIndex: 0, + table: TableInfo(name: "users"), + loadFullValue: provider + ) + let ref = CellRef(table: "users", column: "name", primaryKey: [.init(column: "id", value: "1")]) + + await vm.loadFullValue(ref: ref, cellIndex: 1) + #expect(vm.loadingCell == nil) + #expect(vm.hasOverride(forRow: 0, cellIndex: 1) == true) + } +}