Skip to content

Commit e4fe564

Browse files
committed
test(ios): unit tests for the three extracted iOS view models
1 parent 39e3b66 commit e4fe564

7 files changed

Lines changed: 593 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020

2121
### Changed
2222

23+
- 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
2324
- Internal: extract `RowItemLabel` shared row component for the connection list and table list, dropping the inline HStack scaffolding from both
2425
- 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
2526
- 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

TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ final class ConnectionFormViewModel {
105105

106106
// MARK: - Credential Hydration
107107

108-
func loadStoredCredentials(secureStore: KeychainSecureStore) async {
108+
func loadStoredCredentials(secureStore: any SecureStore) async {
109109
guard let conn = existingConnection else { return }
110110
let connKey = "com.TablePro.password.\(conn.id.uuidString)"
111111
if let stored = try? secureStore.retrieve(forKey: connKey), !stored.isEmpty {
@@ -203,7 +203,7 @@ final class ConnectionFormViewModel {
203203

204204
// MARK: - Test Connection
205205

206-
func testConnection(appState: AppState, secureStore: KeychainSecureStore) async {
206+
func testConnection(appState: AppState, secureStore: any SecureStore) async {
207207
isTesting = true
208208
testResult = nil
209209
defer { isTesting = false }
@@ -256,7 +256,7 @@ final class ConnectionFormViewModel {
256256

257257
// MARK: - Save
258258

259-
func save(appState: AppState, secureStore: KeychainSecureStore) -> DatabaseConnection? {
259+
func save(appState: AppState, secureStore: any SecureStore) -> DatabaseConnection? {
260260
let connection = buildConnection()
261261
var storageFailed = false
262262

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import Foundation
2+
import Testing
3+
import TableProDatabase
4+
import TableProModels
5+
@testable import TableProMobile
6+
7+
@MainActor
8+
@Suite("ConnectionFormViewModel")
9+
struct ConnectionFormViewModelTests {
10+
11+
private func makeStoredConnection() -> DatabaseConnection {
12+
var conn = DatabaseConnection(
13+
id: UUID(),
14+
name: "Local",
15+
type: .postgresql,
16+
host: "10.0.0.1",
17+
port: 5432,
18+
username: "alice",
19+
database: "appdb",
20+
sshEnabled: false,
21+
sslEnabled: true,
22+
groupId: nil,
23+
tagId: nil
24+
)
25+
conn.safeModeLevel = .readOnly
26+
return conn
27+
}
28+
29+
@Test("init without editing leaves defaults and reads default safe mode")
30+
func newConnectionDefaults() {
31+
UserDefaults.standard.set(SafeModeLevel.confirmWrites.rawValue, forKey: AppPreferences.defaultSafeModeKey)
32+
defer { UserDefaults.standard.removeObject(forKey: AppPreferences.defaultSafeModeKey) }
33+
34+
let vm = ConnectionFormViewModel()
35+
36+
#expect(vm.isEditing == false)
37+
#expect(vm.type == .mysql)
38+
#expect(vm.host == "127.0.0.1")
39+
#expect(vm.port == "3306")
40+
#expect(vm.safeModeLevel == .confirmWrites)
41+
}
42+
43+
@Test("init editing hydrates fields from connection")
44+
func hydration() {
45+
let conn = makeStoredConnection()
46+
let vm = ConnectionFormViewModel(editing: conn)
47+
48+
#expect(vm.isEditing == true)
49+
#expect(vm.name == "Local")
50+
#expect(vm.type == .postgresql)
51+
#expect(vm.host == "10.0.0.1")
52+
#expect(vm.port == "5432")
53+
#expect(vm.username == "alice")
54+
#expect(vm.database == "appdb")
55+
#expect(vm.sslEnabled == true)
56+
#expect(vm.safeModeLevel == .readOnly)
57+
}
58+
59+
@Test("changing type updates default port")
60+
func typeChangeUpdatesPort() {
61+
let vm = ConnectionFormViewModel()
62+
#expect(vm.port == "3306")
63+
64+
vm.type = .postgresql
65+
#expect(vm.port == "5432")
66+
67+
vm.type = .redis
68+
#expect(vm.port == "6379")
69+
70+
vm.type = .sqlite
71+
#expect(vm.port == "")
72+
}
73+
74+
@Test("canSave requires database for SQLite, host for server types")
75+
func canSaveValidation() {
76+
let vm = ConnectionFormViewModel()
77+
vm.type = .mysql
78+
vm.host = ""
79+
#expect(vm.canSave == false)
80+
81+
vm.host = "localhost"
82+
#expect(vm.canSave == true)
83+
84+
vm.type = .sqlite
85+
vm.database = ""
86+
#expect(vm.canSave == false)
87+
88+
vm.database = "/tmp/test.db"
89+
#expect(vm.canSave == true)
90+
}
91+
92+
@Test("loadStoredCredentials hydrates password from secure store")
93+
func credentialHydration() async {
94+
let conn = makeStoredConnection()
95+
let store = MockSecureStore()
96+
store.seed("com.TablePro.password.\(conn.id.uuidString)", "secret")
97+
store.seed("com.TablePro.sshpassword.\(conn.id.uuidString)", "ssh-secret")
98+
99+
let vm = ConnectionFormViewModel(editing: conn)
100+
await vm.loadStoredCredentials(secureStore: store)
101+
102+
#expect(vm.password == "secret")
103+
#expect(vm.sshPassword == "ssh-secret")
104+
}
105+
106+
@Test("clearSelectedFile resets URL and database")
107+
func clearFile() {
108+
let vm = ConnectionFormViewModel()
109+
vm.type = .sqlite
110+
vm.database = "/some/path.db"
111+
vm.selectedFileURL = URL(fileURLWithPath: "/some/path.db")
112+
113+
vm.clearSelectedFile()
114+
#expect(vm.selectedFileURL == nil)
115+
#expect(vm.database == "")
116+
}
117+
118+
@Test("createNewDatabase creates a .db URL in Documents")
119+
func createDatabase() {
120+
let vm = ConnectionFormViewModel()
121+
vm.type = .sqlite
122+
vm.newDatabaseName = "scratch"
123+
124+
vm.createNewDatabase()
125+
126+
#expect(vm.selectedFileURL?.lastPathComponent == "scratch.db")
127+
#expect(vm.database.hasSuffix("/scratch.db"))
128+
#expect(vm.name == "scratch")
129+
#expect(vm.newDatabaseName == "")
130+
}
131+
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import Foundation
2+
import Testing
3+
import TableProDatabase
4+
import TableProModels
5+
import TableProQuery
6+
@testable import TableProMobile
7+
8+
@MainActor
9+
@Suite("DataBrowserViewModel")
10+
struct DataBrowserViewModelTests {
11+
12+
private func makeSession(driver: MockDatabaseDriver) -> ConnectionSession {
13+
ConnectionSession(
14+
connectionId: UUID(),
15+
driver: driver,
16+
activeDatabase: "test",
17+
tables: []
18+
)
19+
}
20+
21+
private func makeColumns() -> [ColumnInfo] {
22+
[
23+
ColumnInfo(name: "id", typeName: "INT", isPrimaryKey: true, isNullable: false, ordinalPosition: 0),
24+
ColumnInfo(name: "name", typeName: "VARCHAR(64)", ordinalPosition: 1)
25+
]
26+
}
27+
28+
@Test("load without session sets loadError")
29+
func loadWithoutSessionSetsError() async {
30+
let vm = DataBrowserViewModel()
31+
vm.attach(session: nil, table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost")
32+
33+
await vm.load(isInitial: true)
34+
35+
#expect(vm.loadError != nil)
36+
#expect(vm.isLoading == false)
37+
}
38+
39+
@Test("load with session populates columns and rows")
40+
func loadPopulates() async {
41+
let driver = MockDatabaseDriver()
42+
driver.scriptedColumns = makeColumns()
43+
driver.scriptedExecuteResults = [
44+
.success(QueryResult(
45+
columns: makeColumns(),
46+
rows: [["1", "Alice"], ["2", "Bob"]],
47+
rowsAffected: 0,
48+
executionTime: 0.01
49+
)),
50+
.success(QueryResult(columns: [], rows: [["2"]], rowsAffected: 0, executionTime: 0))
51+
]
52+
53+
let vm = DataBrowserViewModel()
54+
vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost")
55+
await vm.load(isInitial: true)
56+
57+
#expect(vm.legacyRows.count == 2)
58+
#expect(vm.columnDetails.count == 2)
59+
#expect(vm.hasPrimaryKeys == true)
60+
#expect(vm.loadError == nil)
61+
#expect(vm.isLoading == false)
62+
}
63+
64+
@Test("hasActiveSearch reflects activeSearchText")
65+
func searchFlagsTrack() async {
66+
let driver = MockDatabaseDriver()
67+
driver.scriptedColumns = makeColumns()
68+
driver.scriptedExecuteResults = [
69+
.success(QueryResult(columns: makeColumns(), rows: [], rowsAffected: 0, executionTime: 0)),
70+
.success(QueryResult(columns: [], rows: [["0"]], rowsAffected: 0, executionTime: 0))
71+
]
72+
let vm = DataBrowserViewModel()
73+
vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost")
74+
await vm.load(isInitial: true)
75+
76+
#expect(vm.hasActiveSearch == false)
77+
78+
driver.scriptedExecuteResults = [
79+
.success(QueryResult(columns: makeColumns(), rows: [], rowsAffected: 0, executionTime: 0)),
80+
.success(QueryResult(columns: [], rows: [["0"]], rowsAffected: 0, executionTime: 0))
81+
]
82+
await vm.applySearch("alice")
83+
#expect(vm.hasActiveSearch == true)
84+
#expect(vm.activeSearchText == "alice")
85+
86+
driver.scriptedExecuteResults = [
87+
.success(QueryResult(columns: makeColumns(), rows: [], rowsAffected: 0, executionTime: 0)),
88+
.success(QueryResult(columns: [], rows: [["0"]], rowsAffected: 0, executionTime: 0))
89+
]
90+
await vm.clearSearch()
91+
#expect(vm.hasActiveSearch == false)
92+
#expect(vm.activeSearchText == "")
93+
}
94+
95+
@Test("pagination prev/next clamps at boundaries")
96+
func paginationClamps() async {
97+
let driver = MockDatabaseDriver()
98+
let vm = DataBrowserViewModel()
99+
vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost")
100+
101+
#expect(vm.pagination.currentPage == 0)
102+
await vm.goToPreviousPage()
103+
#expect(vm.pagination.currentPage == 0, "previous on page 0 should not underflow")
104+
}
105+
106+
@Test("primaryKeyValues returns only PK columns from row")
107+
func primaryKeyExtraction() async {
108+
let driver = MockDatabaseDriver()
109+
driver.scriptedColumns = makeColumns()
110+
driver.scriptedExecuteResults = [
111+
.success(QueryResult(columns: makeColumns(), rows: [["42", "Alice"]], rowsAffected: 0, executionTime: 0)),
112+
.success(QueryResult(columns: [], rows: [["1"]], rowsAffected: 0, executionTime: 0))
113+
]
114+
let vm = DataBrowserViewModel()
115+
vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost")
116+
await vm.load(isInitial: true)
117+
118+
let pks = vm.primaryKeyValues(for: ["42", "Alice"])
119+
#expect(pks.count == 1)
120+
#expect(pks.first?.column == "id")
121+
#expect(pks.first?.value == "42")
122+
}
123+
124+
@Test("deleteRow returns true on success and runs DELETE SQL")
125+
func deleteSuccess() async {
126+
let driver = MockDatabaseDriver()
127+
driver.scriptedColumns = makeColumns()
128+
driver.scriptedExecuteResults = [
129+
.success(QueryResult(columns: makeColumns(), rows: [["1", "Alice"]], rowsAffected: 0, executionTime: 0)),
130+
.success(QueryResult(columns: [], rows: [["1"]], rowsAffected: 0, executionTime: 0))
131+
]
132+
let vm = DataBrowserViewModel()
133+
vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost")
134+
await vm.load(isInitial: true)
135+
136+
driver.scriptedExecuteResults = [
137+
.success(QueryResult(columns: [], rows: [], rowsAffected: 1, executionTime: 0)),
138+
.success(QueryResult(columns: makeColumns(), rows: [], rowsAffected: 0, executionTime: 0)),
139+
.success(QueryResult(columns: [], rows: [["0"]], rowsAffected: 0, executionTime: 0))
140+
]
141+
142+
let success = await vm.deleteRow(pkValues: [(column: "id", value: "1")])
143+
#expect(success == true)
144+
#expect(vm.operationError == nil)
145+
#expect(driver.executedQueries.contains(where: { $0.uppercased().hasPrefix("DELETE") }))
146+
}
147+
148+
@Test("deleteRow returns false and sets operationError on driver failure")
149+
func deleteFailure() async {
150+
let driver = MockDatabaseDriver()
151+
driver.scriptedColumns = makeColumns()
152+
driver.scriptedExecuteResults = [
153+
.success(QueryResult(columns: makeColumns(), rows: [["1", "Alice"]], rowsAffected: 0, executionTime: 0)),
154+
.success(QueryResult(columns: [], rows: [["1"]], rowsAffected: 0, executionTime: 0))
155+
]
156+
let vm = DataBrowserViewModel()
157+
vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost")
158+
await vm.load(isInitial: true)
159+
160+
driver.scriptedExecuteResults = [.failure(MockDatabaseDriver.MockError.scripted)]
161+
162+
let success = await vm.deleteRow(pkValues: [(column: "id", value: "1")])
163+
#expect(success == false)
164+
#expect(vm.operationError != nil)
165+
}
166+
167+
@Test("changePageSize resets currentPage and totalRows")
168+
func changePageSizeResets() async {
169+
let driver = MockDatabaseDriver()
170+
driver.scriptedColumns = makeColumns()
171+
driver.scriptedExecuteResults = [
172+
.success(QueryResult(columns: makeColumns(), rows: [], rowsAffected: 0, executionTime: 0)),
173+
.success(QueryResult(columns: [], rows: [["0"]], rowsAffected: 0, executionTime: 0)),
174+
.success(QueryResult(columns: makeColumns(), rows: [], rowsAffected: 0, executionTime: 0)),
175+
.success(QueryResult(columns: [], rows: [["0"]], rowsAffected: 0, executionTime: 0))
176+
]
177+
let vm = DataBrowserViewModel()
178+
vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost")
179+
await vm.load(isInitial: true)
180+
181+
await vm.changePageSize(50)
182+
#expect(vm.pagination.pageSize == 50)
183+
#expect(vm.pagination.currentPage == 0)
184+
}
185+
}

0 commit comments

Comments
 (0)