diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 000000000..14d86ad62 --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 000000000..935429726 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,67 @@ +# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby) +# * For C, use cpp +# * For JavaScript, use typescript +# Special requirements: +# * csharp: Requires the presence of a .sln file in the project folder. +language: swift + +# whether to use the project's gitignore file to ignore files +# Added on 2025-04-07 +ignore_all_files_in_gitignore: true +# list of additional paths to ignore +# same syntax as gitignore, so you can use * and ** +# Was previously called `ignored_dirs`, please update your config if you are using that. +# Added (renamed) on 2025-04-07 +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +project_name: "supabase-swift" diff --git a/Tests/AuthTests/AuthErrorTests.swift b/Tests/AuthTests/AuthErrorTests.swift index e630c9535..253fc7921 100644 --- a/Tests/AuthTests/AuthErrorTests.swift +++ b/Tests/AuthTests/AuthErrorTests.swift @@ -42,4 +42,100 @@ final class AuthErrorTests: XCTestCase { XCTAssertEqual(implicitGrantRedirect.errorCode, .unknown) XCTAssertEqual(implicitGrantRedirect.message, "Implicit grant failure") } + + func testWeakPasswordWithReasons() { + let reasons = ["length", "characters", "pwned"] + let weakPassword = AuthError.weakPassword(message: "Password is weak", reasons: reasons) + + XCTAssertEqual(weakPassword.message, "Password is weak") + XCTAssertEqual(weakPassword.errorCode, .weakPassword) + XCTAssertEqual(weakPassword.errorDescription, "Password is weak") + } + + func testJWTVerificationFailed() { + let jwtError = AuthError.jwtVerificationFailed(message: "Invalid JWT signature") + + XCTAssertEqual(jwtError.message, "Invalid JWT signature") + XCTAssertEqual(jwtError.errorCode, .invalidJWT) + XCTAssertEqual(jwtError.errorDescription, "Invalid JWT signature") + } + + func testPKCEGrantCodeExchangeWithErrorAndCode() { + let pkceError = AuthError.pkceGrantCodeExchange( + message: "Exchange failed", + error: "invalid_grant", + code: "auth_code_123" + ) + + XCTAssertEqual(pkceError.message, "Exchange failed") + XCTAssertEqual(pkceError.errorCode, .unknown) + } + + func testAPIErrorWithDifferentCodes() { + let errorCodes: [ErrorCode] = [ + .badJWT, + .sessionExpired, + .userNotFound, + .invalidCredentials, + .emailExists, + .overRequestRateLimit + ] + + for code in errorCodes { + let error = AuthError.api( + message: "Test error", + errorCode: code, + underlyingData: Data(), + underlyingResponse: HTTPURLResponse( + url: URL(string: "http://localhost")!, + statusCode: 400, + httpVersion: nil, + headerFields: nil + )! + ) + + XCTAssertEqual(error.errorCode, code) + XCTAssertEqual(error.message, "Test error") + } + } + + func testErrorCodeEquality() { + XCTAssertEqual(ErrorCode.badJWT, ErrorCode("bad_jwt")) + XCTAssertEqual(ErrorCode.sessionExpired, ErrorCode("session_expired")) + XCTAssertNotEqual(ErrorCode.badJWT, ErrorCode.sessionExpired) + } + + func testErrorCodeRawValue() { + XCTAssertEqual(ErrorCode.badJWT.rawValue, "bad_jwt") + XCTAssertEqual(ErrorCode.sessionExpired.rawValue, "session_expired") + XCTAssertEqual(ErrorCode.unknown.rawValue, "unknown") + } + + func testErrorCodeInitWithString() { + let code1 = ErrorCode("custom_error") + XCTAssertEqual(code1.rawValue, "custom_error") + + let code2 = ErrorCode(rawValue: "another_error") + XCTAssertEqual(code2.rawValue, "another_error") + } + + func testErrorCodeHashable() { + let set: Set = [.badJWT, .sessionExpired, .userNotFound] + XCTAssertTrue(set.contains(.badJWT)) + XCTAssertTrue(set.contains(.sessionExpired)) + XCTAssertFalse(set.contains(.emailExists)) + } + + func testAuthErrorPatternMatching() { + let error1: Error = AuthError.sessionMissing + XCTAssertTrue(AuthError.sessionMissing ~= error1) + + let error2: Error = AuthError.weakPassword(message: "weak", reasons: []) + XCTAssertTrue(AuthError.weakPassword(message: "weak", reasons: []) ~= error2) + + // Test non-AuthError + struct OtherError: Error {} + let error3: Error = OtherError() + XCTAssertFalse(AuthError.sessionMissing ~= error3) + } } diff --git a/Tests/AuthTests/JWTCryptoTests.swift b/Tests/AuthTests/JWTCryptoTests.swift new file mode 100644 index 000000000..60f51fee4 --- /dev/null +++ b/Tests/AuthTests/JWTCryptoTests.swift @@ -0,0 +1,175 @@ +// +// JWTCryptoTests.swift +// Supabase +// +// Created by Coverage Tests +// + +import XCTest +@testable import Auth +@testable import Helpers + +#if canImport(Security) +final class JWTCryptoTests: XCTestCase { + + // MARK: - JWK+RSA Tests + + func testRSAPublishKeyGeneration() { + // Test data from a real RS256 JWT (modulus and exponent) + // This is a sample RSA256 public key + let jwk = JWK( + kty: "RSA", + keyOps: ["verify"], + alg: "RS256", + kid: "test-key-1", + n: "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + e: "AQAB", + crv: nil, + x: nil, + y: nil, + k: nil + ) + + // Test valid RSA key generation + let rsaKey = jwk.rsaPublishKey + XCTAssertNotNil(rsaKey, "RSA public key should be generated successfully") + } + + func testRSAPublishKeyInvalidAlgorithm() { + // Test with invalid algorithm + let jwk = JWK( + kty: "RSA", + keyOps: nil, + alg: "ES256", // Wrong algorithm - should be RS256 + kid: "test-key-2", + n: "test-modulus", + e: "AQAB", + crv: nil, + x: nil, + y: nil, + k: nil + ) + + let rsaKey = jwk.rsaPublishKey + XCTAssertNil(rsaKey, "RSA public key should be nil with wrong algorithm") + } + + func testRSAPublishKeyInvalidKeyType() { + // Test with invalid key type + let jwk = JWK( + kty: "EC", // Wrong type - should be RSA + keyOps: nil, + alg: "RS256", + kid: "test-key-3", + n: "test-modulus", + e: "AQAB", + crv: nil, + x: nil, + y: nil, + k: nil + ) + + let rsaKey = jwk.rsaPublishKey + XCTAssertNil(rsaKey, "RSA public key should be nil with wrong key type") + } + + func testRSAPublishKeyMissingModulus() { + // Test with missing modulus + let jwk = JWK( + kty: "RSA", + keyOps: nil, + alg: "RS256", + kid: "test-key-4", + n: nil, // Missing modulus + e: "AQAB", + crv: nil, + x: nil, + y: nil, + k: nil + ) + + let rsaKey = jwk.rsaPublishKey + XCTAssertNil(rsaKey, "RSA public key should be nil with missing modulus") + } + + func testRSAPublishKeyMissingExponent() { + // Test with missing exponent + let jwk = JWK( + kty: "RSA", + keyOps: nil, + alg: "RS256", + kid: "test-key-5", + n: "test-modulus", + e: nil, // Missing exponent + crv: nil, + x: nil, + y: nil, + k: nil + ) + + let rsaKey = jwk.rsaPublishKey + XCTAssertNil(rsaKey, "RSA public key should be nil with missing exponent") + } + + func testRSAPublishKeyInvalidBase64() { + // Test with invalid Base64URL data + let jwk = JWK( + kty: "RSA", + keyOps: nil, + alg: "RS256", + kid: "test-key-6", + n: "!!!invalid-base64!!!", + e: "AQAB", + crv: nil, + x: nil, + y: nil, + k: nil + ) + + let rsaKey = jwk.rsaPublishKey + XCTAssertNil(rsaKey, "RSA public key should be nil with invalid base64 modulus") + } + + // MARK: - JWTAlgorithm Tests + + func testRS256VerificationWithValidSignature() { + // Create a sample JWT token (this would normally come from a real auth server) + // For testing, we'll use a known-good JWT + let header = #"{"alg":"RS256","typ":"JWT"}"# + let payload = #"{"sub":"1234567890","name":"Test User","iat":1516239022}"# + + guard + let headerData = header.data(using: .utf8), + let payloadData = payload.data(using: .utf8) + else { + XCTFail("Failed to create test data") + return + } + + let headerB64 = Base64URL.encode(headerData) + let payloadB64 = Base64URL.encode(payloadData) + + // Create a mock signature (in real scenario, this would be a proper RSA signature) + let mockSignature = Data([0x00, 0x01, 0x02, 0x03]) + let signatureB64 = Base64URL.encode(mockSignature) + + let jwtString = "\(headerB64).\(payloadB64).\(signatureB64)" + + // Decode the JWT + guard let decoded = JWT.decode(jwtString) else { + XCTFail("Failed to decode JWT") + return + } + + XCTAssertEqual(decoded.raw.header, headerB64) + XCTAssertEqual(decoded.raw.payload, payloadB64) + XCTAssertEqual(decoded.signature, mockSignature) + } + + func testRS256AlgorithmType() { + let algorithm = JWTAlgorithm.rs256 + XCTAssertEqual(algorithm.rawValue, "RS256") + } + +} +#endif diff --git a/Tests/AuthTests/URLOpenerTests.swift b/Tests/AuthTests/URLOpenerTests.swift new file mode 100644 index 000000000..43389a9ec --- /dev/null +++ b/Tests/AuthTests/URLOpenerTests.swift @@ -0,0 +1,261 @@ +// +// URLOpenerTests.swift +// Supabase +// +// Created by Coverage Tests +// + +import XCTest +@testable import Auth + +final class URLOpenerTests: XCTestCase { + + // MARK: - Custom Opener Tests + + func testCustomURLOpener() async { + final class Capture: @unchecked Sendable { + var url: URL? + } + + let capture = Capture() + let customOpener = URLOpener { @Sendable url in + capture.url = url + } + + let testURL = URL(string: "https://example.com/callback")! + + await customOpener.open(testURL) + + XCTAssertEqual(capture.url, testURL) + } + + func testCustomOpenerWithMultipleURLs() async { + final class Capture: @unchecked Sendable { + var urls: [URL] = [] + } + + let capture = Capture() + let customOpener = URLOpener { @Sendable url in + capture.urls.append(url) + } + + let urls = [ + URL(string: "https://example.com/auth")!, + URL(string: "https://example.com/callback")!, + URL(string: "myapp://redirect")!, + ] + + for url in urls { + await customOpener.open(url) + } + + XCTAssertEqual(capture.urls.count, 3) + XCTAssertEqual(capture.urls, urls) + } + + func testCustomOpenerWithDifferentSchemes() async { + final class Capture: @unchecked Sendable { + var urls: [URL] = [] + } + + let capture = Capture() + let customOpener = URLOpener { @Sendable url in + capture.urls.append(url) + } + + let schemes = ["https", "http", "myapp", "supabase"] + let urls = schemes.map { URL(string: "\($0)://example.com")! } + + for url in urls { + await customOpener.open(url) + } + + XCTAssertEqual(capture.urls.count, schemes.count) + for (index, url) in capture.urls.enumerated() { + XCTAssertEqual(url.scheme, schemes[index]) + } + } + + func testCustomOpenerWithQueryParameters() async { + final class Capture: @unchecked Sendable { + var url: URL? + } + + let capture = Capture() + let customOpener = URLOpener { @Sendable url in + capture.url = url + } + + let testURL = URL(string: "https://example.com/auth?code=123&state=abc")! + + await customOpener.open(testURL) + + XCTAssertEqual(capture.url?.scheme, "https") + XCTAssertEqual(capture.url?.host, "example.com") + XCTAssertEqual(capture.url?.path, "/auth") + XCTAssertTrue(capture.url?.query?.contains("code=123") ?? false) + XCTAssertTrue(capture.url?.query?.contains("state=abc") ?? false) + } + + func testCustomOpenerWithFragment() async { + final class Capture: @unchecked Sendable { + var url: URL? + } + + let capture = Capture() + let customOpener = URLOpener { @Sendable url in + capture.url = url + } + + let testURL = URL(string: "https://example.com/page#section")! + + await customOpener.open(testURL) + + XCTAssertEqual(capture.url?.fragment, "section") + } + + func testCustomOpenerWithComplexURL() async { + final class Capture: @unchecked Sendable { + var url: URL? + } + + let capture = Capture() + let customOpener = URLOpener { @Sendable url in + capture.url = url + } + + let testURL = URL(string: "myapp://auth/callback?access_token=abc123&refresh_token=def456&expires_in=3600#success")! + + await customOpener.open(testURL) + + XCTAssertEqual(capture.url, testURL) + XCTAssertEqual(capture.url?.scheme, "myapp") + XCTAssertEqual(capture.url?.host, "auth") + XCTAssertEqual(capture.url?.path, "/callback") + XCTAssertNotNil(capture.url?.query) + XCTAssertEqual(capture.url?.fragment, "success") + } + + // MARK: - Live Opener Tests + + func testLiveOpenerExists() { + let liveOpener = URLOpener.live + XCTAssertNotNil(liveOpener) + } + + func testLiveOpenerStructure() async { + // Test that live opener can be called without crashing + // We can't really test if it opens URLs in a test environment, + // but we can verify it compiles and runs + let liveOpener = URLOpener.live + let testURL = URL(string: "https://example.com")! + + // This will attempt to open the URL on the platform + // In test environment, it might not succeed, but shouldn't crash + await liveOpener.open(testURL) + + // If we get here, the function at least executed + XCTAssertTrue(true) + } + + // MARK: - Edge Cases + + func testOpenerWithURLWithPort() async { + final class Capture: @unchecked Sendable { + var url: URL? + } + + let capture = Capture() + let customOpener = URLOpener { @Sendable url in + capture.url = url + } + + let testURL = URL(string: "https://localhost:54321/auth/callback")! + + await customOpener.open(testURL) + + XCTAssertEqual(capture.url?.port, 54321) + XCTAssertEqual(capture.url?.host, "localhost") + } + + func testOpenerWithURLWithUsername() async { + final class Capture: @unchecked Sendable { + var url: URL? + } + + let capture = Capture() + let customOpener = URLOpener { @Sendable url in + capture.url = url + } + + let testURL = URL(string: "https://user@example.com/path")! + + await customOpener.open(testURL) + + XCTAssertEqual(capture.url?.user, "user") + } + + func testOpenerWithEncodedURL() async { + final class Capture: @unchecked Sendable { + var url: URL? + } + + let capture = Capture() + let customOpener = URLOpener { @Sendable url in + capture.url = url + } + + let testURL = URL(string: "https://example.com/path?redirect=https%3A%2F%2Fother.com")! + + await customOpener.open(testURL) + + XCTAssertNotNil(capture.url) + XCTAssertTrue(capture.url?.query?.contains("redirect=") ?? false) + } + + // MARK: - Multiple Calls Tests + + func testMultipleOpenerCalls() async { + final class URLCapture: @unchecked Sendable { + var urls: [URL] = [] + private let lock = NSLock() + + func append(_ url: URL) { + lock.lock() + defer { lock.unlock() } + urls.append(url) + } + + func getURLs() -> [URL] { + lock.lock() + defer { lock.unlock() } + return urls + } + } + + let capture = URLCapture() + let customOpener = URLOpener { @Sendable url in + capture.append(url) + } + + for i in 0..<10 { + let url = URL(string: "https://example.com/\(i)")! + await customOpener.open(url) + } + + let capturedURLs = capture.getURLs() + XCTAssertEqual(capturedURLs.count, 10) + } + + // MARK: - Sendable Conformance Tests + + func testURLOpenerIsSendable() { + let opener = URLOpener { @Sendable _ in } + + // Test that it can be used in async context + Task { + let url = URL(string: "https://example.com")! + await opener.open(url) + } + } +} diff --git a/Tests/HelpersTests/DateFormatterTests.swift b/Tests/HelpersTests/DateFormatterTests.swift new file mode 100644 index 000000000..1cdb7d278 --- /dev/null +++ b/Tests/HelpersTests/DateFormatterTests.swift @@ -0,0 +1,256 @@ +// +// DateFormatterTests.swift +// Supabase +// +// Created by Coverage Tests +// + +import XCTest +@testable import Helpers + +final class DateFormatterTests: XCTestCase { + + // MARK: - Date to ISO8601 String Tests + + func testDateToISO8601String() { + // Create a specific date: 2024-01-15 10:30:45.123 UTC + var components = DateComponents() + components.year = 2024 + components.month = 1 + components.day = 15 + components.hour = 10 + components.minute = 30 + components.second = 45 + components.nanosecond = 123_000_000 // 123 milliseconds + components.timeZone = TimeZone(secondsFromGMT: 0) + + let calendar = Calendar(identifier: .iso8601) + guard let date = calendar.date(from: components) else { + XCTFail("Failed to create test date") + return + } + + let iso8601String = date.iso8601String + + // Should contain the date and time + XCTAssertTrue(iso8601String.contains("2024-01-15")) + XCTAssertTrue(iso8601String.contains("10:30:45")) + } + + func testCurrentDateToISO8601String() { + let now = Date() + let iso8601String = now.iso8601String + + // Verify it's not empty and has proper format + XCTAssertFalse(iso8601String.isEmpty) + XCTAssertTrue(iso8601String.contains("T")) // Should have date-time separator + XCTAssertTrue(iso8601String.contains("-")) // Should have date separators + XCTAssertTrue(iso8601String.contains(":")) // Should have time separators + } + + // MARK: - String to Date Parsing Tests + + func testParseISO8601StringWithFractionalSeconds() { + let dateString = "2024-01-15T10:30:45.123" + guard let parsedDate = dateString.date else { + XCTFail("Failed to parse date string") + return + } + + let calendar = Calendar(identifier: .iso8601) + let components = calendar.dateComponents( + in: TimeZone(secondsFromGMT: 0)!, + from: parsedDate + ) + + XCTAssertEqual(components.year, 2024) + XCTAssertEqual(components.month, 1) + XCTAssertEqual(components.day, 15) + XCTAssertEqual(components.hour, 10) + XCTAssertEqual(components.minute, 30) + XCTAssertEqual(components.second, 45) + } + + func testParseISO8601StringWithoutFractionalSeconds() { + let dateString = "2024-01-15T10:30:45" + guard let parsedDate = dateString.date else { + XCTFail("Failed to parse date string") + return + } + + let calendar = Calendar(identifier: .iso8601) + let components = calendar.dateComponents( + in: TimeZone(secondsFromGMT: 0)!, + from: parsedDate + ) + + XCTAssertEqual(components.year, 2024) + XCTAssertEqual(components.month, 1) + XCTAssertEqual(components.day, 15) + XCTAssertEqual(components.hour, 10) + XCTAssertEqual(components.minute, 30) + XCTAssertEqual(components.second, 45) + } + + func testParseInvalidDateString() { + let invalidStrings = [ + "not a date", + "2024-13-45", // Invalid month and day + "2024/01/15", // Wrong separator + "15-01-2024", // Wrong order + "", + "2024-01-15 10:30:45", // Space instead of T + ] + + for invalidString in invalidStrings { + XCTAssertNil( + invalidString.date, + "Should return nil for invalid date string: \(invalidString)" + ) + } + } + + func testParseEmptyString() { + let emptyString = "" + XCTAssertNil(emptyString.date) + } + + // MARK: - Round-trip Tests + + func testRoundTripConversion() { + // Create a date, convert to string, parse back to date + var components = DateComponents() + components.year = 2023 + components.month = 6 + components.day = 15 + components.hour = 14 + components.minute = 30 + components.second = 0 + components.timeZone = TimeZone(secondsFromGMT: 0) + + let calendar = Calendar(identifier: .iso8601) + guard let originalDate = calendar.date(from: components) else { + XCTFail("Failed to create original date") + return + } + + let dateString = originalDate.iso8601String + guard let parsedDate = dateString.date else { + XCTFail("Failed to parse date from string: \(dateString)") + return + } + + // Compare timestamps (allowing small tolerance for milliseconds) + let timeDifference = abs(originalDate.timeIntervalSince(parsedDate)) + XCTAssertLessThan(timeDifference, 1.0, "Dates should be within 1 second of each other") + } + + func testRoundTripWithCurrentDate() { + let now = Date() + let dateString = now.iso8601String + guard let parsedDate = dateString.date else { + XCTFail("Failed to parse current date from string: \(dateString)") + return + } + + // Compare timestamps (allowing small tolerance for milliseconds) + let timeDifference = abs(now.timeIntervalSince(parsedDate)) + XCTAssertLessThan(timeDifference, 1.0, "Dates should be within 1 second of each other") + } + + // MARK: - Edge Cases + + func testParseDateAtMidnight() { + let dateString = "2024-01-01T00:00:00" + guard let parsedDate = dateString.date else { + XCTFail("Failed to parse midnight date") + return + } + + let calendar = Calendar(identifier: .iso8601) + let components = calendar.dateComponents( + in: TimeZone(secondsFromGMT: 0)!, + from: parsedDate + ) + + XCTAssertEqual(components.hour, 0) + XCTAssertEqual(components.minute, 0) + XCTAssertEqual(components.second, 0) + } + + func testParseDateAtEndOfDay() { + let dateString = "2024-12-31T23:59:59" + guard let parsedDate = dateString.date else { + XCTFail("Failed to parse end-of-day date") + return + } + + let calendar = Calendar(identifier: .iso8601) + let components = calendar.dateComponents( + in: TimeZone(secondsFromGMT: 0)!, + from: parsedDate + ) + + XCTAssertEqual(components.month, 12) + XCTAssertEqual(components.day, 31) + XCTAssertEqual(components.hour, 23) + XCTAssertEqual(components.minute, 59) + XCTAssertEqual(components.second, 59) + } + + func testParseLeapYearDate() { + let dateString = "2024-02-29T12:00:00" // 2024 is a leap year + XCTAssertNotNil(dateString.date, "Should parse leap year date") + } + + func testParseVariousFractionalSecondFormats() { + let formats = [ + "2024-01-15T10:30:45.1", + "2024-01-15T10:30:45.12", + "2024-01-15T10:30:45.123", + "2024-01-15T10:30:45.1234", + ] + + for format in formats { + // These might not all parse depending on the formatter, but at least test them + let _ = format.date + } + } + + // MARK: - Multiple Date Conversion Tests + + func testConvertMultipleDates() { + let dates = [ + "2020-01-01T00:00:00", + "2021-06-15T12:30:45", + "2022-12-31T23:59:59", + "2023-07-04T16:20:30.500", + "2024-02-29T08:15:22", // Leap year + ] + + for dateString in dates { + XCTAssertNotNil( + dateString.date, + "Should parse date: \(dateString)" + ) + } + } + + func testConvertDatesInDifferentYears() { + for year in 2020...2025 { + let dateString = "\(year)-06-15T12:00:00" + guard let parsedDate = dateString.date else { + XCTFail("Failed to parse date for year \(year)") + continue + } + + let calendar = Calendar(identifier: .iso8601) + let components = calendar.dateComponents( + in: TimeZone(secondsFromGMT: 0)!, + from: parsedDate + ) + + XCTAssertEqual(components.year, year) + } + } +} diff --git a/Tests/HelpersTests/LoggerInterceptorTests.swift b/Tests/HelpersTests/LoggerInterceptorTests.swift new file mode 100644 index 000000000..e8279ff27 --- /dev/null +++ b/Tests/HelpersTests/LoggerInterceptorTests.swift @@ -0,0 +1,321 @@ +// +// LoggerInterceptorTests.swift +// Supabase +// +// Created by Coverage Tests +// + +import XCTest +@testable import Helpers +import HTTPTypes + +final class LoggerInterceptorTests: XCTestCase { + + typealias Method = HTTPTypes.HTTPRequest.Method + + // MARK: - Mock Logger + + final class MockLogger: SupabaseLogger, @unchecked Sendable { + var verboseLogs: [String] = [] + var errorLogs: [String] = [] + + func log(message: SupabaseLogMessage) { + switch message.level { + case .verbose: + verboseLogs.append(message.message) + case .error: + errorLogs.append(message.message) + case .debug, .warning: + break + } + } + } + + // MARK: - Helper Methods + + func createTestRequest( + url: String = "https://api.example.com/test", + method: Method = .get, + body: Data? = nil + ) -> Helpers.HTTPRequest { + Helpers.HTTPRequest( + url: URL(string: url)!, + method: method, + body: body + ) + } + + func createTestResponse(statusCode: Int = 200, data: Data = Data()) -> Helpers.HTTPResponse { + let urlResponse = HTTPURLResponse( + url: URL(string: "https://api.example.com/test")!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )! + return Helpers.HTTPResponse(data: data, response: urlResponse) + } + + // MARK: - Interceptor Tests + + func testInterceptorLogsRequest() async throws { + let logger = MockLogger() + let interceptor = LoggerInterceptor(logger: logger) + + let request = createTestRequest(url: "https://api.example.com/users", method: .get) + let expectedResponse = createTestResponse() + + let _ = try await interceptor.intercept(request) { _ in + return expectedResponse + } + + // Verify request was logged + XCTAssertEqual(logger.verboseLogs.count, 2) // Request and response + XCTAssertTrue(logger.verboseLogs[0].contains("Request:")) + XCTAssertTrue(logger.verboseLogs[0].contains("/users")) + } + + func testInterceptorLogsResponse() async throws { + let logger = MockLogger() + let interceptor = LoggerInterceptor(logger: logger) + + let request = createTestRequest() + let responseData = #"{"success": true}"#.data(using: .utf8)! + let expectedResponse = createTestResponse(statusCode: 200, data: responseData) + + let _ = try await interceptor.intercept(request) { _ in + return expectedResponse + } + + // Verify response was logged + XCTAssertEqual(logger.verboseLogs.count, 2) + XCTAssertTrue(logger.verboseLogs[1].contains("Response: Status code: 200")) + } + + func testInterceptorLogsError() async throws { + let logger = MockLogger() + let interceptor = LoggerInterceptor(logger: logger) + + let request = createTestRequest() + + struct TestError: Error {} + + do { + let _ = try await interceptor.intercept(request) { _ in + throw TestError() + } + XCTFail("Should have thrown error") + } catch { + // Expected error + } + + // Verify error was logged + XCTAssertEqual(logger.errorLogs.count, 1) + XCTAssertTrue(logger.errorLogs[0].contains("Response: Failure")) + } + + func testInterceptorWithJSONBody() async throws { + let logger = MockLogger() + let interceptor = LoggerInterceptor(logger: logger) + + let jsonBody = #"{"name": "test", "value": 123}"#.data(using: .utf8)! + let request = createTestRequest(method: .post, body: jsonBody) + let expectedResponse = createTestResponse() + + let _ = try await interceptor.intercept(request) { _ in + return expectedResponse + } + + // Verify JSON body was logged + XCTAssertTrue(logger.verboseLogs[0].contains("Body:")) + XCTAssertTrue(logger.verboseLogs[0].contains("name")) + } + + func testInterceptorWithEmptyBody() async throws { + let logger = MockLogger() + let interceptor = LoggerInterceptor(logger: logger) + + let request = createTestRequest(method: .get, body: Data?.none) + let expectedResponse = createTestResponse() + + let _ = try await interceptor.intercept(request) { _ in + return expectedResponse + } + + // Verify empty body handling + XCTAssertTrue(logger.verboseLogs[0].contains("")) + } + + func testInterceptorWithDifferentMethods() async throws { + let methods: [(Method, String)] = [ + (.get, "GET"), + (.post, "POST"), + (.put, "PUT"), + (.delete, "DELETE"), + (.patch, "PATCH"), + ] + + for (method, methodString) in methods { + let logger = MockLogger() + let interceptor = LoggerInterceptor(logger: logger) + + let request = createTestRequest(method: method) + let expectedResponse = createTestResponse() + + let _ = try await interceptor.intercept(request) { _ in + return expectedResponse + } + + XCTAssertTrue( + logger.verboseLogs[0].contains("Request:"), + "Should log \(methodString) request" + ) + } + } + + func testInterceptorWithDifferentStatusCodes() async throws { + let statusCodes = [200, 201, 400, 401, 404, 500] + + for statusCode in statusCodes { + let logger = MockLogger() + let interceptor = LoggerInterceptor(logger: logger) + + let request = createTestRequest() + let expectedResponse = createTestResponse(statusCode: statusCode) + + let _ = try await interceptor.intercept(request) { _ in + return expectedResponse + } + + XCTAssertTrue( + logger.verboseLogs[1].contains("Status code: \(statusCode)"), + "Should log status code \(statusCode)" + ) + } + } + + // MARK: - Stringify Function Tests + + func testStringfyWithNilData() { + let result = stringfy(nil) + XCTAssertEqual(result, "") + } + + func testStringfyWithJSONData() { + let jsonData = #"{"key": "value", "number": 42}"#.data(using: .utf8)! + let result = stringfy(jsonData) + + XCTAssertTrue(result.contains("key")) + XCTAssertTrue(result.contains("value")) + XCTAssertTrue(result.contains("number")) + } + + func testStringfyWithNonJSONData() { + let textData = "Plain text content".data(using: .utf8)! + let result = stringfy(textData) + + XCTAssertEqual(result, "Plain text content") + } + + func testStringfyWithInvalidUTF8Data() { + // Invalid UTF-8 sequence + let invalidData = Data([0xFF, 0xFE, 0xFD]) + let result = stringfy(invalidData) + + XCTAssertEqual(result, "") + } + + func testStringfyWithEmptyData() { + let emptyData = Data() + let result = stringfy(emptyData) + + // Empty JSON object or empty string + XCTAssertTrue(result.isEmpty) + } + + func testStringfyWithComplexJSON() { + let complexJSON = """ + { + "users": [ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bob"} + ], + "total": 2, + "nested": { + "key": "value" + } + } + """.data(using: .utf8)! + + let result = stringfy(complexJSON) + + XCTAssertTrue(result.contains("users")) + XCTAssertTrue(result.contains("Alice")) + XCTAssertTrue(result.contains("nested")) + } + + func testStringfyWithArrayJSON() { + let arrayJSON = #"[1, 2, 3, 4, 5]"#.data(using: .utf8)! + let result = stringfy(arrayJSON) + + XCTAssertTrue(result.contains("1")) + XCTAssertTrue(result.contains("5")) + } + + func testStringfyWithBooleanJSON() { + let boolJSON = #"{"active": true, "deleted": false}"#.data(using: .utf8)! + let result = stringfy(boolJSON) + + XCTAssertTrue(result.contains("active")) + XCTAssertTrue(result.contains("true") || result.contains("1")) + } + + func testStringfyWithNullJSON() { + let nullJSON = #"{"value": null}"#.data(using: .utf8)! + let result = stringfy(nullJSON) + + XCTAssertTrue(result.contains("value")) + } + + // MARK: - Integration Tests + + func testInterceptorPassesThroughResponse() async throws { + let logger = MockLogger() + let interceptor = LoggerInterceptor(logger: logger) + + let request = createTestRequest() + let testData = "Test Response".data(using: .utf8)! + let expectedResponse = createTestResponse(statusCode: 201, data: testData) + + let actualResponse = try await interceptor.intercept(request) { _ in + return expectedResponse + } + + // Verify response is passed through unchanged + XCTAssertEqual(actualResponse.statusCode, 201) + XCTAssertEqual(actualResponse.data, testData) + } + + func testInterceptorPassesThroughError() async throws { + let logger = MockLogger() + let interceptor = LoggerInterceptor(logger: logger) + + let request = createTestRequest() + + struct CustomError: Error, Equatable { + let message: String + } + + let expectedError = CustomError(message: "Test error") + + do { + let _ = try await interceptor.intercept(request) { _ in + throw expectedError + } + XCTFail("Should have thrown error") + } catch let error as CustomError { + XCTAssertEqual(error, expectedError) + } catch { + XCTFail("Wrong error type thrown") + } + } +} diff --git a/Tests/StorageTests/MultipartFormDataTests.swift b/Tests/StorageTests/MultipartFormDataTests.swift index 94d544669..bb836345e 100644 --- a/Tests/StorageTests/MultipartFormDataTests.swift +++ b/Tests/StorageTests/MultipartFormDataTests.swift @@ -31,4 +31,223 @@ final class MultipartFormDataTests: XCTestCase { XCTAssertTrue(formData.contentType.hasPrefix("multipart/form-data")) } + + func testCustomBoundary() { + let customBoundary = "test-boundary-12345" + let formData = MultipartFormData(boundary: customBoundary) + + XCTAssertEqual(formData.boundary, customBoundary) + XCTAssertTrue(formData.contentType.contains(customBoundary)) + } + + func testAppendDataWithoutFileName() { + let formData = MultipartFormData() + let testData = "Test data".data(using: .utf8)! + + formData.append(testData, withName: "field") + + XCTAssertGreaterThan(formData.contentLength, 0) + } + + func testMultipleAppends() { + let formData = MultipartFormData() + let data1 = "First".data(using: .utf8)! + let data2 = "Second".data(using: .utf8)! + + formData.append(data1, withName: "field1", fileName: "file1.txt", mimeType: "text/plain") + formData.append(data2, withName: "field2", fileName: "file2.txt", mimeType: "text/plain") + + XCTAssertEqual(formData.contentLength, UInt64(data1.count + data2.count)) + } + + func testEncodeFormData() throws { + let formData = MultipartFormData() + let testData = "Test content".data(using: .utf8)! + + formData.append(testData, withName: "file", fileName: "test.txt", mimeType: "text/plain") + + let encoded = try formData.encode() + XCTAssertGreaterThan(encoded.count, 0) + + // Verify encoded data contains boundary + let encodedString = String(data: encoded, encoding: .utf8) + XCTAssertNotNil(encodedString) + XCTAssertTrue(encodedString!.contains(formData.boundary)) + } + + func testAppendFileURL() throws { + let formData = MultipartFormData() + + // Create a temporary file + let tempDir = FileManager.default.temporaryDirectory + let fileURL = tempDir.appendingPathComponent("test-\(UUID().uuidString).txt") + let testContent = "File content".data(using: .utf8)! + + try testContent.write(to: fileURL) + defer { try? FileManager.default.removeItem(at: fileURL) } + + formData.append(fileURL, withName: "upload") + + XCTAssertGreaterThan(formData.contentLength, 0) + } + + func testAppendFileURLWithCustomMetadata() throws { + let formData = MultipartFormData() + + // Create a temporary file + let tempDir = FileManager.default.temporaryDirectory + let fileURL = tempDir.appendingPathComponent("custom-\(UUID().uuidString).json") + let testContent = #"{"key": "value"}"#.data(using: .utf8)! + + try testContent.write(to: fileURL) + defer { try? FileManager.default.removeItem(at: fileURL) } + + formData.append(fileURL, withName: "data", fileName: "custom.json", mimeType: "application/json") + + XCTAssertGreaterThan(formData.contentLength, 0) + } + + #if !os(Linux) && !os(Windows) && !os(Android) + func testAppendInvalidFileURL() { + let formData = MultipartFormData() + let invalidURL = URL(fileURLWithPath: "/nonexistent/path/file.txt") + + formData.append(invalidURL, withName: "file") + + // Should fail during encoding + XCTAssertThrowsError(try formData.encode()) + } + + func testAppendNonFileURL() { + let formData = MultipartFormData() + let httpURL = URL(string: "https://example.com/file.txt")! + + formData.append(httpURL, withName: "file", fileName: "file.txt", mimeType: "text/plain") + + // Should fail during encoding + XCTAssertThrowsError(try formData.encode()) + } + + func testAppendDirectory() throws { + let formData = MultipartFormData() + + // Use a known directory + let dirURL = FileManager.default.temporaryDirectory + + formData.append(dirURL, withName: "dir") + + // Should fail during encoding + XCTAssertThrowsError(try formData.encode()) + } + #endif + + func testAppendInputStream() { + let formData = MultipartFormData() + let testData = "Stream data".data(using: .utf8)! + let stream = InputStream(data: testData) + + formData.append( + stream, + withLength: UInt64(testData.count), + name: "stream", + fileName: "stream.txt", + mimeType: "text/plain" + ) + + XCTAssertEqual(formData.contentLength, UInt64(testData.count)) + } + + func testEmptyFormData() throws { + let formData = MultipartFormData() + + // Encoding empty form data should succeed + let encoded = try formData.encode() + XCTAssertEqual(encoded.count, 0) + } + + func testLargeData() throws { + let formData = MultipartFormData() + + // Create 1MB of data + let largeData = Data(repeating: 0xFF, count: 1024 * 1024) + formData.append(largeData, withName: "large", fileName: "large.bin", mimeType: "application/octet-stream") + + let encoded = try formData.encode() + XCTAssertGreaterThan(encoded.count, largeData.count) + } + + func testWriteEncodedDataToFile() throws { + let formData = MultipartFormData() + let testData = "Test file write".data(using: .utf8)! + + formData.append(testData, withName: "file", fileName: "test.txt", mimeType: "text/plain") + + let tempDir = FileManager.default.temporaryDirectory + let outputURL = tempDir.appendingPathComponent("output-\(UUID().uuidString).txt") + defer { try? FileManager.default.removeItem(at: outputURL) } + + try formData.writeEncodedData(to: outputURL) + + XCTAssertTrue(FileManager.default.fileExists(atPath: outputURL.path)) + + let written = try Data(contentsOf: outputURL) + XCTAssertGreaterThan(written.count, 0) + } + + func testWriteToExistingFile() throws { + let formData = MultipartFormData() + let testData = "Test".data(using: .utf8)! + + formData.append(testData, withName: "file") + + let tempDir = FileManager.default.temporaryDirectory + let outputURL = tempDir.appendingPathComponent("existing-\(UUID().uuidString).txt") + + // Create existing file + try testData.write(to: outputURL) + defer { try? FileManager.default.removeItem(at: outputURL) } + + // Should throw because file already exists + XCTAssertThrowsError(try formData.writeEncodedData(to: outputURL)) + } + + func testWriteToNonFileURL() throws { + let formData = MultipartFormData() + let testData = "Test".data(using: .utf8)! + + formData.append(testData, withName: "file") + + let httpURL = URL(string: "https://example.com/output.txt")! + + // Should throw because URL is not a file URL + XCTAssertThrowsError(try formData.writeEncodedData(to: httpURL)) + } + + func testMultipartFormDataErrorUnderlyingError() { + let nsError = NSError(domain: "test", code: 1, userInfo: nil) + let error = MultipartFormDataError.inputStreamReadFailed(error: nsError) + + XCTAssertNotNil(error.underlyingError) + XCTAssertNil(error.url) + } + + func testMultipartFormDataErrorURL() { + let url = URL(fileURLWithPath: "/test/file.txt") + let error = MultipartFormDataError.bodyPartFileNotReachable(at: url) + + XCTAssertNotNil(error.url) + XCTAssertNil(error.underlyingError) + } + + func testContentLengthCalculation() { + let formData = MultipartFormData() + let data1 = "Part 1".data(using: .utf8)! + let data2 = "Part 2".data(using: .utf8)! + + formData.append(data1, withName: "part1") + formData.append(data2, withName: "part2") + + let expectedLength = UInt64(data1.count + data2.count) + XCTAssertEqual(formData.contentLength, expectedLength) + } }