import Foundation import Testing @testable import Hutch private final class SettingsViewModelCapturingURLProtocol: URLProtocol, @unchecked Sendable { nonisolated(unsafe) static var capturedRequests: [URLRequest] = [] nonisolated(unsafe) static var capturedBodies: [Data] = [] override class func canInit(with _: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { Self.capturedRequests.append(request) Self.capturedBodies.append(Self.readBody(from: request)) let response = HTTPURLResponse( url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil )! client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: Data()) client?.urlProtocolDidFinishLoading(self) } override func stopLoading() { // No cleanup is needed because the stub responds immediately in `startLoading()`. } /// `URLSession` moves `httpBody` onto `httpBodyStream` before handing a request /// to a `URLProtocol`, so `request.httpBody` is always nil here and the body has /// to be read back off the stream while it is still open. private static func readBody(from request: URLRequest) -> Data { if let body = request.httpBody { return body } guard let stream = request.httpBodyStream else { return Data() } stream.open() defer { stream.close() } var data = Data() var buffer = [UInt8](repeating: 0, count: 4096) while stream.hasBytesAvailable { let read = stream.read(&buffer, maxLength: buffer.count) guard read > 0 else { break } data.append(buffer, count: read) } return data } static func makeSession() -> URLSession { let config = URLSessionConfiguration.ephemeral config.protocolClasses = [Self.self] return URLSession(configuration: config) } } private struct DeletePGPKeyEnvelope: Decodable { let deletePGPKey: DeleteResultPayload? } private struct DeleteResultPayload: Decodable { let id: Int? } @Suite(.serialized) struct SettingsViewModelTests { @Test @MainActor func deletePGPKeyResponseDecodesNullPayloadWithGraphQLErrors() throws { let json = """ { "errors": [ { "message": "PGP key ID 13629 is set as the user's preferred PGP key - it must be unset before removing the key" } ], "data": { "deletePGPKey": null } } """ let decoded = try JSONDecoder().decode( GraphQLResponse.self, from: Data(json.utf8) ) #expect(decoded.data?.deletePGPKey == nil) #expect(decoded.errors?.first?.message.contains("preferred PGP key") == true) } @Test @MainActor func loadProfileDoesNotRequestSSHKeyFingerprintField() async throws { SettingsViewModelCapturingURLProtocol.capturedRequests = [] SettingsViewModelCapturingURLProtocol.capturedBodies = [] let client = SRHTClient( session: SettingsViewModelCapturingURLProtocol.makeSession(), token: "test-token" ) let viewModel = SettingsViewModel(client: client) await viewModel.loadProfile() let body = try #require(SettingsViewModelCapturingURLProtocol.capturedBodies.first) let jsonObject = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) let query = try #require(jsonObject["query"] as? String) #expect(query.contains("sshKeys")) #expect(!query.contains("results { id fingerprint comment created lastUsed }")) #expect(!query.contains("fingerprint comment created lastUsed")) } }