@@ -0,0 +1,466 @@
1+import XCTest
2+@testable import ParseableViewer
3+
4+final class ParseableClientTests: XCTestCase {
5+ // MARK: - Query response parsing
6+
7+ func testQueryResponseWrappedFormat() throws {
8+ let json = """
9+ {"records": [{"message": "hello"}], "fields": ["message"]}
10+ """.data(using: .utf8)!
11+
12+ let wrapped = try JSONDecoder().decode(QueryResponse.self, from: json)
13+ XCTAssertEqual(wrapped.records.count, 1)
14+ XCTAssertEqual(wrapped.records[0]["message"], .string("hello"))
15+ XCTAssertEqual(wrapped.fields, ["message"])
16+ }
17+
18+ func testQueryResponseDirectArrayFormat() throws {
19+ let json = """
20+ [{"level": "info", "msg": "test"}]
21+ """.data(using: .utf8)!
22+
23+ let records = try JSONDecoder().decode([LogRecord].self, from: json)
24+ XCTAssertEqual(records.count, 1)
25+ XCTAssertEqual(records[0]["level"], .string("info"))
26+ XCTAssertEqual(records[0]["msg"], .string("test"))
27+ }
28+
29+ func testQueryResponseEmptyArray() throws {
30+ let json = "[]".data(using: .utf8)!
31+ let records = try JSONDecoder().decode([LogRecord].self, from: json)
32+ XCTAssertTrue(records.isEmpty)
33+ }
34+
35+ func testQueryResponseWrappedEmptyRecords() throws {
36+ let json = """
37+ {"records": [], "fields": ["a", "b"]}
38+ """.data(using: .utf8)!
39+
40+ let wrapped = try JSONDecoder().decode(QueryResponse.self, from: json)
41+ XCTAssertTrue(wrapped.records.isEmpty)
42+ XCTAssertEqual(wrapped.fields, ["a", "b"])
43+ }
44+
45+ // MARK: - Alert config decoding
46+
47+ func testAlertConfigKeyedFormat() throws {
48+ let json = """
49+ {"alerts": [{"name": "high-cpu", "message": "CPU > 90%"}], "version": "v1"}
50+ """.data(using: .utf8)!
51+
52+ let config = try JSONDecoder().decode(AlertConfig.self, from: json)
53+ XCTAssertEqual(config.alerts?.count, 1)
54+ XCTAssertEqual(config.alerts?[0].name, "high-cpu")
55+ XCTAssertEqual(config.version, "v1")
56+ }
57+
58+ func testAlertConfigDirectArrayFormat() throws {
59+ let json = """
60+ [{"name": "disk-full", "message": "Disk usage > 95%"}]
61+ """.data(using: .utf8)!
62+
63+ let config = try JSONDecoder().decode(AlertConfig.self, from: json)
64+ XCTAssertEqual(config.alerts?.count, 1)
65+ XCTAssertEqual(config.alerts?[0].name, "disk-full")
66+ XCTAssertNil(config.version)
67+ }
68+
69+ func testAlertConfigMemberwiseInit() {
70+ let config = AlertConfig(alerts: [], version: nil)
71+ XCTAssertEqual(config.alerts?.count, 0)
72+ XCTAssertNil(config.version)
73+ }
74+
75+ // MARK: - Retention config decoding
76+
77+ func testRetentionConfigArray() throws {
78+ let json = """
79+ [{"description": "Keep 30 days", "duration": "30d", "action": "delete"}]
80+ """.data(using: .utf8)!
81+
82+ let configs = try JSONDecoder().decode([RetentionConfig].self, from: json)
83+ XCTAssertEqual(configs.count, 1)
84+ XCTAssertEqual(configs[0].duration, "30d")
85+ }
86+
87+ func testRetentionConfigSingleObject() throws {
88+ let json = """
89+ {"description": "Keep 7 days", "duration": "7d", "action": "delete"}
90+ """.data(using: .utf8)!
91+
92+ let config = try JSONDecoder().decode(RetentionConfig.self, from: json)
93+ XCTAssertEqual(config.duration, "7d")
94+ XCTAssertEqual(config.action, "delete")
95+ }
96+
97+ // MARK: - Stream schema decoding
98+
99+ func testStreamSchemaKeyedFormat() throws {
100+ let json = """
101+ {"fields": [{"name": "level", "data_type": "Utf8"}]}
102+ """.data(using: .utf8)!
103+
104+ let schema = try JSONDecoder().decode(StreamSchema.self, from: json)
105+ XCTAssertEqual(schema.fields.count, 1)
106+ XCTAssertEqual(schema.fields[0].name, "level")
107+ XCTAssertEqual(schema.fields[0].dataType, "Utf8")
108+ }
109+
110+ func testStreamSchemaDirectArrayFormat() throws {
111+ let json = """
112+ [{"name": "count", "data_type": "Int64"}]
113+ """.data(using: .utf8)!
114+
115+ let schema = try JSONDecoder().decode(StreamSchema.self, from: json)
116+ XCTAssertEqual(schema.fields.count, 1)
117+ XCTAssertEqual(schema.fields[0].dataType, "Int64")
118+ }
119+
120+ func testSchemaFieldObjectDataType() throws {
121+ let json = """
122+ {"name": "tags", "data_type": {"List": "Utf8"}}
123+ """.data(using: .utf8)!
124+
125+ let field = try JSONDecoder().decode(SchemaField.self, from: json)
126+ XCTAssertEqual(field.name, "tags")
127+ XCTAssertFalse(field.dataType.isEmpty)
128+ }
129+
130+ // MARK: - User info decoding
131+
132+ func testUserInfoKeyedFormat() throws {
133+ let json = """
134+ {"id": "admin", "method": "native"}
135+ """.data(using: .utf8)!
136+
137+ let user = try JSONDecoder().decode(UserInfo.self, from: json)
138+ XCTAssertEqual(user.id, "admin")
139+ XCTAssertEqual(user.method, "native")
140+ }
141+
142+ func testUserInfoPlainString() throws {
143+ let json = """
144+ "readonly-user"
145+ """.data(using: .utf8)!
146+
147+ let user = try JSONDecoder().decode(UserInfo.self, from: json)
148+ XCTAssertEqual(user.id, "readonly-user")
149+ XCTAssertNil(user.method)
150+ }
151+
152+ // MARK: - LogStream decoding
153+
154+ func testLogStreamKeyedFormat() throws {
155+ let json = """
156+ {"name": "backend-logs"}
157+ """.data(using: .utf8)!
158+
159+ let stream = try JSONDecoder().decode(LogStream.self, from: json)
160+ XCTAssertEqual(stream.name, "backend-logs")
161+ }
162+
163+ func testLogStreamPlainString() throws {
164+ let json = """
165+ "frontend-logs"
166+ """.data(using: .utf8)!
167+
168+ let stream = try JSONDecoder().decode(LogStream.self, from: json)
169+ XCTAssertEqual(stream.name, "frontend-logs")
170+ }
171+
172+ // MARK: - ServerAbout decoding
173+
174+ func testServerAboutPartialFields() throws {
175+ let json = """
176+ {"version": "1.0.0", "mode": "standalone"}
177+ """.data(using: .utf8)!
178+
179+ let about = try JSONDecoder().decode(ServerAbout.self, from: json)
180+ XCTAssertEqual(about.version, "1.0.0")
181+ XCTAssertEqual(about.mode, "standalone")
182+ XCTAssertNil(about.commit)
183+ XCTAssertNil(about.grpcPort)
184+ }
185+
186+ func testServerAboutEmptyObject() throws {
187+ let json = "{}".data(using: .utf8)!
188+ let about = try JSONDecoder().decode(ServerAbout.self, from: json)
189+ XCTAssertNil(about.version)
190+ XCTAssertNil(about.mode)
191+ }
192+
193+ // MARK: - Auth header
194+
195+ func testClientInitFromConnection() throws {
196+ let connection = ServerConnection(
197+ name: "test",
198+ url: "https://example.com",
199+ username: "admin",
200+ password: "secret"
201+ )
202+ let client = try ParseableClient(connection: connection)
203+ XCTAssertEqual(client.username, "admin")
204+ XCTAssertEqual(client.baseURL.absoluteString, "https://example.com")
205+ }
206+
207+ func testClientInitInvalidURL() {
208+ let connection = ServerConnection(
209+ name: "test",
210+ url: "",
211+ username: "admin",
212+ password: "secret"
213+ )
214+ XCTAssertThrowsError(try ParseableClient(connection: connection))
215+ }
216+
217+ // MARK: - ParseableError
218+
219+ func testServerErrorDescription() {
220+ let error = ParseableError.serverError(500, "Internal Server Error")
221+ XCTAssertTrue(error.errorDescription?.contains("500") ?? false)
222+ }
223+
224+ func testUnauthorizedDescription() {
225+ let error = ParseableError.unauthorized
226+ XCTAssertTrue(error.errorDescription?.contains("Authentication") ?? false)
227+ }
228+
229+ func testNotConnectedDescription() {
230+ let error = ParseableError.notConnected
231+ XCTAssertTrue(error.errorDescription?.contains("Not connected") ?? false)
232+ }
233+
234+ // MARK: - User-friendly error messages
235+
236+ func testUserFriendlyMessageForUnauthorized() {
237+ let msg = ParseableError.userFriendlyMessage(for: ParseableError.unauthorized)
238+ XCTAssertTrue(msg.contains("username"))
239+ XCTAssertTrue(msg.contains("password"))
240+ }
241+
242+ func testUserFriendlyMessageForServerError() {
243+ let msg = ParseableError.userFriendlyMessage(for: ParseableError.serverError(500, "fail"))
244+ XCTAssertTrue(msg.contains("500"))
245+ XCTAssertTrue(msg.contains("Parseable"))
246+ }
247+
248+ func testUserFriendlyMessageForInvalidURL() {
249+ let msg = ParseableError.userFriendlyMessage(for: ParseableError.invalidURL)
250+ XCTAssertTrue(msg.lowercased().contains("url"))
251+ }
252+
253+ func testUserFriendlyMessageForNetworkTimeout() {
254+ let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut, userInfo: nil)
255+ let msg = ParseableError.userFriendlyMessage(for: error)
256+ XCTAssertTrue(msg.lowercased().contains("timed out"))
257+ }
258+
259+ func testUserFriendlyMessageForNoInternet() {
260+ let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet, userInfo: nil)
261+ let msg = ParseableError.userFriendlyMessage(for: error)
262+ XCTAssertTrue(msg.lowercased().contains("internet") || msg.lowercased().contains("network"))
263+ }
264+
265+ func testUserFriendlyMessageForCannotFindHost() {
266+ let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotFindHost, userInfo: nil)
267+ let msg = ParseableError.userFriendlyMessage(for: error)
268+ XCTAssertTrue(msg.lowercased().contains("server") || msg.lowercased().contains("find"))
269+ }
270+
271+ func testUserFriendlyMessageForCannotConnect() {
272+ let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotConnectToHost, userInfo: nil)
273+ let msg = ParseableError.userFriendlyMessage(for: error)
274+ XCTAssertTrue(msg.lowercased().contains("connect"))
275+ }
276+
277+ func testUserFriendlyMessageForSSLError() {
278+ let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorSecureConnectionFailed, userInfo: nil)
279+ let msg = ParseableError.userFriendlyMessage(for: error)
280+ XCTAssertTrue(msg.lowercased().contains("ssl") || msg.lowercased().contains("tls"))
281+ }
282+
283+ func testUserFriendlyMessageForGenericError() {
284+ let error = NSError(domain: "custom", code: 42, userInfo: [NSLocalizedDescriptionKey: "Something broke"])
285+ let msg = ParseableError.userFriendlyMessage(for: error)
286+ XCTAssertEqual(msg, "Something broke")
287+ }
288+
289+ // MARK: - Stream name validation
290+
291+ func testValidStreamName() {
292+ XCTAssertNil(SidebarView.validateStreamName("my-stream_v2.0"))
293+ }
294+
295+ func testStreamNameWithSpaces() {
296+ let error = SidebarView.validateStreamName("my stream")
297+ XCTAssertNotNil(error)
298+ XCTAssertTrue(error?.contains("letters") ?? false)
299+ }
300+
301+ func testStreamNameTooLong() {
302+ let name = String(repeating: "a", count: 256)
303+ let error = SidebarView.validateStreamName(name)
304+ XCTAssertNotNil(error)
305+ XCTAssertTrue(error?.contains("255") ?? false)
306+ }
307+
308+ func testStreamNameStartsWithDot() {
309+ let error = SidebarView.validateStreamName(".hidden")
310+ XCTAssertNotNil(error)
311+ XCTAssertTrue(error?.contains("start") ?? false)
312+ }
313+
314+ func testStreamNameStartsWithHyphen() {
315+ let error = SidebarView.validateStreamName("-bad")
316+ XCTAssertNotNil(error)
317+ XCTAssertTrue(error?.contains("start") ?? false)
318+ }
319+
320+ func testStreamNameWithSpecialChars() {
321+ XCTAssertNotNil(SidebarView.validateStreamName("stream@1"))
322+ XCTAssertNotNil(SidebarView.validateStreamName("stream/path"))
323+ XCTAssertNotNil(SidebarView.validateStreamName("stream name"))
324+ }
325+
326+ func testStreamNameMaxLength() {
327+ let name = String(repeating: "a", count: 255)
328+ XCTAssertNil(SidebarView.validateStreamName(name))
329+ }
330+
331+ // MARK: - SQL identifier escaping
332+
333+ func testEscapeSQLIdentifierSimple() {
334+ XCTAssertEqual(QueryViewModel.escapeSQLIdentifier("logs"), "\"logs\"")
335+ }
336+
337+ func testEscapeSQLIdentifierWithQuotes() {
338+ XCTAssertEqual(QueryViewModel.escapeSQLIdentifier("my\"stream"), "\"my\"\"stream\"")
339+ }
340+
341+ func testEscapeSQLIdentifierWithSpaces() {
342+ XCTAssertEqual(QueryViewModel.escapeSQLIdentifier("my stream"), "\"my stream\"")
343+ }
344+
345+ // MARK: - CSV export
346+
347+ func testBuildCSVEmpty() {
348+ XCTAssertEqual(QueryViewModel.buildCSV(records: [], columns: []), "")
349+ }
350+
351+ func testBuildCSVWithData() {
352+ let records: [LogRecord] = [
353+ ["level": .string("info"), "msg": .string("hello")]
354+ ]
355+ let csv = QueryViewModel.buildCSV(records: records, columns: ["level", "msg"])
356+ XCTAssertTrue(csv.hasPrefix("level,msg\n"))
357+ XCTAssertTrue(csv.contains("info"))
358+ XCTAssertTrue(csv.contains("hello"))
359+ }
360+
361+ func testBuildCSVEscapesCommas() {
362+ let records: [LogRecord] = [
363+ ["msg": .string("hello, world")]
364+ ]
365+ let csv = QueryViewModel.buildCSV(records: records, columns: ["msg"])
366+ XCTAssertTrue(csv.contains("\"hello, world\""))
367+ }
368+
369+ func testBuildCSVEscapesQuotes() {
370+ let records: [LogRecord] = [
371+ ["msg": .string("say \"hi\"")]
372+ ]
373+ let csv = QueryViewModel.buildCSV(records: records, columns: ["msg"])
374+ XCTAssertTrue(csv.contains("\"say \"\"hi\"\"\""))
375+ }
376+
377+ // MARK: - Malformed response decoding
378+
379+ func testQueryResponseMalformedThrows() throws {
380+ // Malformed data that is neither QueryResponse nor [LogRecord]
381+ let data = "not json at all".data(using: .utf8)!
382+
383+ // Neither format should decode
384+ XCTAssertNil(try? JSONDecoder().decode(QueryResponse.self, from: data))
385+ XCTAssertNil(try? JSONDecoder().decode([LogRecord].self, from: data))
386+ }
387+
388+ func testRetentionConfigEmptyDataReturnsEmpty() throws {
389+ // Empty data should be treated as no retention (tested via the Data.isEmpty check)
390+ let emptyData = Data()
391+ // JSONDecoder would fail on empty data
392+ XCTAssertThrowsError(try JSONDecoder().decode([RetentionConfig].self, from: emptyData))
393+ }
394+
395+ func testAlertConfigMalformedThrows() {
396+ // Completely invalid JSON should fail AlertConfig decoding
397+ let data = "<<<not json>>>".data(using: .utf8)!
398+ XCTAssertThrowsError(try JSONDecoder().decode(AlertConfig.self, from: data))
399+ }
400+
401+ func testRetentionConfigMalformedThrows() {
402+ // A plain string is not valid as RetentionConfig or [RetentionConfig]
403+ let data = "\"just a string\"".data(using: .utf8)!
404+ XCTAssertNil(try? JSONDecoder().decode([RetentionConfig].self, from: data))
405+ XCTAssertNil(try? JSONDecoder().decode(RetentionConfig.self, from: data))
406+ }
407+
408+ // MARK: - Stream name validation (empty)
409+
410+ func testStreamNameEmpty() {
411+ let error = SidebarView.validateStreamName("")
412+ XCTAssertNotNil(error)
413+ XCTAssertTrue(error?.lowercased().contains("empty") ?? false)
414+ }
415+
416+ func testStreamNameWhitespaceOnly() {
417+ let error = SidebarView.validateStreamName(" ")
418+ XCTAssertNotNil(error)
419+ XCTAssertTrue(error?.lowercased().contains("empty") ?? false)
420+ }
421+
422+ // MARK: - URL scheme validation
423+
424+ func testValidateURLAcceptsHTTPS() {
425+ // ConnectionSheet.validateURL is private, so we test via ServerConnection.baseURL
426+ let conn = ServerConnection(name: "t", url: "https://example.com", username: "u", password: "p")
427+ XCTAssertNotNil(conn.baseURL)
428+ XCTAssertEqual(conn.baseURL?.scheme, "https")
429+ }
430+
431+ func testValidateURLAcceptsHTTP() {
432+ let conn = ServerConnection(name: "t", url: "http://example.com", username: "u", password: "p")
433+ XCTAssertNotNil(conn.baseURL)
434+ XCTAssertEqual(conn.baseURL?.scheme, "http")
435+ }
436+
437+ // MARK: - CSV export edge cases
438+
439+ func testBuildCSVEscapesNewlines() {
440+ let records: [LogRecord] = [
441+ ["msg": .string("line1\nline2")]
442+ ]
443+ let csv = QueryViewModel.buildCSV(records: records, columns: ["msg"])
444+ // Newlines in values should be wrapped in quotes
445+ XCTAssertTrue(csv.contains("\"line1\nline2\""))
446+ }
447+
448+ func testBuildCSVMissingColumn() {
449+ let records: [LogRecord] = [
450+ ["level": .string("info")]
451+ ]
452+ let csv = QueryViewModel.buildCSV(records: records, columns: ["level", "msg"])
453+ // Missing column value should produce empty field
454+ let lines = csv.components(separatedBy: "\n")
455+ XCTAssertTrue(lines[1].hasSuffix(",") || lines[1].components(separatedBy: ",").count == 2)
456+ }
457+
458+ func testBuildCSVEscapesCarriageReturn() {
459+ let records: [LogRecord] = [
460+ ["msg": .string("line1\r\nline2")]
461+ ]
462+ let csv = QueryViewModel.buildCSV(records: records, columns: ["msg"])
463+ // Carriage returns in values should be wrapped in quotes per RFC 4180
464+ XCTAssertTrue(csv.contains("\"line1\r\nline2\""))
465+ }
466+}