Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
126 changes: 125 additions & 1 deletion TableProMobile/TableProMobile.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -518,6 +525,7 @@
5AA313392F7EA5B4008EBA97 /* OpenSSL-SSL.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = "OpenSSL-SSL.xcframework"; path = "../Libs/ios/OpenSSL-SSL.xcframework"; sourceTree = "<group>"; };
5AA313532F7EC188008EBA97 /* LibSSH2.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = LibSSH2.xcframework; path = ../Libs/ios/LibSSH2.xcframework; sourceTree = "<group>"; };
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 */
Expand Down Expand Up @@ -563,6 +571,11 @@
path = TableProMobile;
sourceTree = "<group>";
};
5AC8A8F92FAFC99F005DE2A3 /* TableProMobileTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = TableProMobileTests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -595,6 +608,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
5AC8A8F52FAFC99F005DE2A3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */

/* Begin PBXGroup section */
Expand Down Expand Up @@ -1628,6 +1648,7 @@
5AA136322F82675600ADCD58 /* TableProWidgetExtension.entitlements */,
5AB9F3DB2F7C1C12001F3337 /* TableProMobile */,
5AA136092F82610F00ADCD58 /* TableProWidget */,
5AC8A8F92FAFC99F005DE2A3 /* TableProMobileTests */,
5AA313332F7EA5B4008EBA97 /* Frameworks */,
5AB9F3DA2F7C1C12001F3337 /* Products */,
5A72D6222F97A69500E2ADE0 /* Secrets.xcconfig */,
Expand All @@ -1639,6 +1660,7 @@
children = (
5AB9F3D92F7C1C12001F3337 /* TableProMobile.app */,
5AA136042F82610F00ADCD58 /* TableProWidgetExtension.appex */,
5AC8A8F82FAFC99F005DE2A3 /* TableProMobileTests.xctest */,
);
name = Products;
sourceTree = "<group>";
Expand Down Expand Up @@ -1698,14 +1720,37 @@
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 */
5AB9F3D12F7C1C12001F3337 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2650;
LastSwiftUpdateCheck = 2640;
LastUpgradeCheck = 2650;
TargetAttributes = {
5AA136032F82610F00ADCD58 = {
Expand All @@ -1714,6 +1759,10 @@
5AB9F3D82F7C1C12001F3337 = {
CreatedOnToolsVersion = 26.4;
};
5AC8A8F72FAFC99F005DE2A3 = {
CreatedOnToolsVersion = 26.4.1;
TestTargetID = 5AB9F3D82F7C1C12001F3337;
};
};
};
buildConfigurationList = 5AB9F3D42F7C1C12001F3337 /* Build configuration list for PBXProject "TableProMobile" */;
Expand All @@ -1735,6 +1784,7 @@
targets = (
5AB9F3D82F7C1C12001F3337 /* TableProMobile */,
5AA136032F82610F00ADCD58 /* TableProWidgetExtension */,
5AC8A8F72FAFC99F005DE2A3 /* TableProMobileTests */,
);
};
/* End PBXProject section */
Expand All @@ -1755,6 +1805,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
5AC8A8F62FAFC99F005DE2A3 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */

/* Begin PBXSourcesBuildPhase section */
Expand All @@ -1772,6 +1829,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
5AC8A8F42FAFC99F005DE2A3 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */

/* Begin PBXTargetDependency section */
Expand All @@ -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 */
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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

Expand Down
131 changes: 131 additions & 0 deletions TableProMobile/TableProMobileTests/ConnectionFormViewModelTests.swift
Original file line number Diff line number Diff line change
@@ -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 == "")
}
}
Loading
Loading