vibetunnel/ios/VibeTunnelTests/APIErrorTests.swift
2025-06-21 14:39:44 +02:00

377 lines
13 KiB
Swift

import Foundation
import Testing
@Suite("API Error Handling Tests", .tags(.critical, .networking))
struct APIErrorTests {
// MARK: - Network Error Scenarios
@Test("Network timeout error handling")
func networkTimeout() {
enum APIError: Error, Equatable {
case networkError(URLError)
case noServerConfigured
var localizedDescription: String {
switch self {
case .networkError(let urlError):
switch urlError.code {
case .timedOut:
"Connection timed out"
case .notConnectedToInternet:
"No internet connection"
case .cannotFindHost:
"Cannot find server - check the address"
case .cannotConnectToHost:
"Cannot connect to server - is it running?"
case .networkConnectionLost:
"Network connection lost"
default:
urlError.localizedDescription
}
case .noServerConfigured:
"No server configured"
}
}
}
let timeoutError = APIError.networkError(URLError(.timedOut))
#expect(timeoutError.localizedDescription == "Connection timed out")
let noInternetError = APIError.networkError(URLError(.notConnectedToInternet))
#expect(noInternetError.localizedDescription == "No internet connection")
let hostNotFoundError = APIError.networkError(URLError(.cannotFindHost))
#expect(hostNotFoundError.localizedDescription == "Cannot find server - check the address")
}
@Test("HTTP status code error mapping")
func hTTPStatusErrors() {
struct ServerError {
let code: Int
let message: String?
var description: String {
if let message {
return message
}
switch code {
case 400: return "Bad request - check your input"
case 401: return "Unauthorized - authentication required"
case 403: return "Forbidden - access denied"
case 404: return "Not found - endpoint doesn't exist"
case 409: return "Conflict - resource already exists"
case 422: return "Unprocessable entity - validation failed"
case 429: return "Too many requests - rate limit exceeded"
case 500: return "Server error - internal server error"
case 502: return "Bad gateway - server is down"
case 503: return "Service unavailable"
default: return "Server error: \(code)"
}
}
}
// Test common HTTP errors
#expect(ServerError(code: 400, message: nil).description == "Bad request - check your input")
#expect(ServerError(code: 401, message: nil).description == "Unauthorized - authentication required")
#expect(ServerError(code: 404, message: nil).description == "Not found - endpoint doesn't exist")
#expect(ServerError(code: 429, message: nil).description == "Too many requests - rate limit exceeded")
#expect(ServerError(code: 500, message: nil).description == "Server error - internal server error")
// Test custom error message takes precedence
#expect(ServerError(code: 404, message: "Session not found").description == "Session not found")
// Test unknown status code
#expect(ServerError(code: 418, message: nil).description == "Server error: 418")
}
@Test("Error response body parsing")
func errorResponseParsing() throws {
// Standard error format
struct ErrorResponse: Codable {
let error: String?
let message: String?
let details: String?
let code: String?
}
// Test various error response formats
let errorFormats = [
// Format 1: Simple error field
"""
{"error": "Invalid session ID"}
""",
// Format 2: Message field
"""
{"message": "Authentication failed", "code": "AUTH_FAILED"}
""",
// Format 3: Detailed error
"""
{"error": "Validation error", "details": "Field 'command' is required"}
""",
// Format 4: All fields
"""
{"error": "Request failed", "message": "Invalid input", "details": "Missing required fields", "code": "VALIDATION_ERROR"}
"""
]
for json in errorFormats {
let data = json.data(using: .utf8)!
let response = try JSONDecoder().decode(ErrorResponse.self, from: data)
// Verify at least one error field is present
let hasError = response.error != nil || response.message != nil || response.details != nil
#expect(hasError == true)
}
}
// MARK: - Decoding Error Scenarios
@Test("Invalid JSON response handling")
func invalidJSONResponse() {
let invalidResponses = [
"", // Empty response
"not json", // Plain text
"{invalid json}", // Malformed JSON
"null", // Null response
"undefined", // JavaScript undefined
"<html>404 Not Found</html>" // HTML error page
]
for response in invalidResponses {
let data = response.data(using: .utf8) ?? Data()
// Attempt to decode as array of sessions
struct Session: Codable {
let id: String
let command: String
}
do {
_ = try JSONDecoder().decode([Session].self, from: data)
Issue.record("Should have thrown decoding error for: \(response)")
} catch {
// Expected to fail
#expect(error is DecodingError)
}
}
}
@Test("Partial JSON response handling")
func partialJSONResponse() throws {
// Session with missing required fields
let partialSession = """
{
"id": "test-123"
}
"""
struct Session: Codable {
let id: String
let command: String
let workingDir: String
let status: String
let startedAt: String
}
let data = partialSession.data(using: .utf8)!
#expect(throws: DecodingError.self) {
try JSONDecoder().decode(Session.self, from: data)
}
}
// MARK: - Request Validation
@Test("Invalid request parameters")
func invalidRequestParameters() {
// Test session creation with invalid data
struct SessionCreateRequest {
let command: [String]
let workingDir: String
let cols: Int?
let rows: Int?
func validate() -> String? {
if command.isEmpty {
return "Command cannot be empty"
}
if command.first?.isEmpty == true {
return "Command cannot be empty string"
}
if workingDir.isEmpty {
return "Working directory cannot be empty"
}
if let cols, cols <= 0 {
return "Terminal width must be positive"
}
if let rows, rows <= 0 {
return "Terminal height must be positive"
}
return nil
}
}
// Test various invalid inputs
let invalidRequests = [
SessionCreateRequest(command: [], workingDir: "/tmp", cols: 80, rows: 24),
SessionCreateRequest(command: [""], workingDir: "/tmp", cols: 80, rows: 24),
SessionCreateRequest(command: ["bash"], workingDir: "", cols: 80, rows: 24),
SessionCreateRequest(command: ["bash"], workingDir: "/tmp", cols: 0, rows: 24),
SessionCreateRequest(command: ["bash"], workingDir: "/tmp", cols: 80, rows: -1)
]
for request in invalidRequests {
#expect(request.validate() != nil)
}
// Valid request should pass
let validRequest = SessionCreateRequest(command: ["bash"], workingDir: "/tmp", cols: 80, rows: 24)
#expect(validRequest.validate() == nil)
}
// MARK: - Connection State Errors
@Test("No server configured error")
func noServerConfiguredError() {
enum APIError: Error {
case noServerConfigured
case invalidURL
var localizedDescription: String {
switch self {
case .noServerConfigured:
"No server configured. Please connect to a server first."
case .invalidURL:
"Invalid server URL"
}
}
}
let error = APIError.noServerConfigured
#expect(error.localizedDescription.contains("No server configured"))
}
@Test("Empty response handling")
func emptyResponseHandling() throws {
// Some endpoints return 204 No Content
let emptyData = Data()
// For endpoints that should return data
struct SessionListResponse: Codable {
let sessions: [Session]
struct Session: Codable {
let id: String
}
}
// Empty data should throw when expecting content
#expect(throws: DecodingError.self) {
try JSONDecoder().decode(SessionListResponse.self, from: emptyData)
}
// But empty array is valid
let emptyArrayData = "[]".data(using: .utf8)!
let sessions = try JSONDecoder().decode([SessionListResponse.Session].self, from: emptyArrayData)
#expect(sessions.isEmpty)
}
// MARK: - Retry Logic
@Test("Retry behavior for transient errors")
func retryLogic() {
struct RetryPolicy {
let maxAttempts: Int
let retryableErrors: Set<Int>
func shouldRetry(attempt: Int, statusCode: Int) -> Bool {
attempt < maxAttempts && retryableErrors.contains(statusCode)
}
func delayForAttempt(_ attempt: Int) -> TimeInterval {
// Exponential backoff: 1s, 2s, 4s, 8s...
pow(2.0, Double(attempt - 1))
}
}
let policy = RetryPolicy(
maxAttempts: 3,
retryableErrors: [408, 429, 502, 503, 504] // Timeout, rate limit, gateway errors
)
// Should retry on retryable errors
#expect(policy.shouldRetry(attempt: 1, statusCode: 503) == true)
#expect(policy.shouldRetry(attempt: 2, statusCode: 429) == true)
// Should not retry on non-retryable errors
#expect(policy.shouldRetry(attempt: 1, statusCode: 404) == false)
#expect(policy.shouldRetry(attempt: 1, statusCode: 401) == false)
// Should stop after max attempts
#expect(policy.shouldRetry(attempt: 3, statusCode: 503) == false)
// Test backoff delays
#expect(policy.delayForAttempt(1) == 1.0)
#expect(policy.delayForAttempt(2) == 2.0)
#expect(policy.delayForAttempt(3) == 4.0)
}
// MARK: - Edge Cases
@Test("Unicode and special characters in errors")
func unicodeErrorMessages() throws {
let errorMessages = [
"Error: File not found 文件未找到",
"❌ Operation failed",
"Error: Path contains invalid characters: /tmp/test-file",
"Session 'test—session' not found", // em dash
"Invalid input: 🚫"
]
struct ErrorResponse: Codable {
let error: String
}
for message in errorMessages {
let json = """
{"error": "\(message.replacingOccurrences(of: "\"", with: "\\\""))"}
"""
let data = json.data(using: .utf8)!
let response = try JSONDecoder().decode(ErrorResponse.self, from: data)
#expect(response.error == message)
}
}
@Test("Concurrent error handling")
func concurrentErrors() async {
// Simulate multiple concurrent API calls failing
actor ErrorCollector {
private var errors: [String] = []
func addError(_ error: String) {
errors.append(error)
}
func getErrors() -> [String] {
errors
}
}
let collector = ErrorCollector()
// Simulate concurrent operations
await withTaskGroup(of: Void.self) { group in
for i in 1...5 {
group.addTask {
// Simulate API call and error
let error = "Error from request \(i)"
await collector.addError(error)
}
}
}
let errors = await collector.getErrors()
#expect(errors.count == 5)
}
}