krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
main: HutchTests/SettingsViewModelTests.swift · raw
1import Foundation
2import Testing
3@testable import Hutch
4
5private final class SettingsViewModelCapturingURLProtocol: URLProtocol, @unchecked Sendable {
6 nonisolated(unsafe) static var capturedRequests: [URLRequest] = []
7 nonisolated(unsafe) static var capturedBodies: [Data] = []
8
9 override class func canInit(with _: URLRequest) -> Bool { true }
10 override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
11
12 override func startLoading() {
13 Self.capturedRequests.append(request)
14 Self.capturedBodies.append(Self.readBody(from: request))
15
16 let response = HTTPURLResponse(
17 url: request.url!,
18 statusCode: 401,
19 httpVersion: nil,
20 headerFields: nil
21 )!
22 client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
23 client?.urlProtocol(self, didLoad: Data())
24 client?.urlProtocolDidFinishLoading(self)
25 }
26
27 override func stopLoading() {
28 // No cleanup is needed because the stub responds immediately in `startLoading()`.
29 }
30
31 /// `URLSession` moves `httpBody` onto `httpBodyStream` before handing a request
32 /// to a `URLProtocol`, so `request.httpBody` is always nil here and the body has
33 /// to be read back off the stream while it is still open.
34 private static func readBody(from request: URLRequest) -> Data {
35 if let body = request.httpBody { return body }
36 guard let stream = request.httpBodyStream else { return Data() }
37
38 stream.open()
39 defer { stream.close() }
40
41 var data = Data()
42 var buffer = [UInt8](repeating: 0, count: 4096)
43 while stream.hasBytesAvailable {
44 let read = stream.read(&buffer, maxLength: buffer.count)
45 guard read > 0 else { break }
46 data.append(buffer, count: read)
47 }
48 return data
49 }
50
51 static func makeSession() -> URLSession {
52 let config = URLSessionConfiguration.ephemeral
53 config.protocolClasses = [Self.self]
54 return URLSession(configuration: config)
55 }
56}
57
58private struct DeletePGPKeyEnvelope: Decodable {
59 let deletePGPKey: DeleteResultPayload?
60}
61
62private struct DeleteResultPayload: Decodable {
63 let id: Int?
64}
65
66@Suite(.serialized)
67@MainActor
68struct SettingsViewModelTests {
69
70 @Test
71 @MainActor
72 func deletePGPKeyResponseDecodesNullPayloadWithGraphQLErrors() throws {
73 let json = """
74 {
75 "errors": [
76 {
77 "message": "PGP key ID 13629 is set as the user's preferred PGP key - it must be unset before removing the key"
78 }
79 ],
80 "data": {
81 "deletePGPKey": null
82 }
83 }
84 """
85
86 let decoded = try JSONDecoder().decode(
87 GraphQLResponse<DeletePGPKeyEnvelope>.self,
88 from: Data(json.utf8)
89 )
90
91 #expect(decoded.data?.deletePGPKey == nil)
92 #expect(decoded.errors?.first?.message.contains("preferred PGP key") == true)
93 }
94
95 @Test
96 @MainActor
97 func loadProfileDoesNotRequestSSHKeyFingerprintField() async throws {
98 SettingsViewModelCapturingURLProtocol.capturedRequests = []
99 SettingsViewModelCapturingURLProtocol.capturedBodies = []
100
101 let client = SRHTClient(
102 session: SettingsViewModelCapturingURLProtocol.makeSession(),
103 token: "test-token"
104 )
105 let viewModel = SettingsViewModel(client: client)
106
107 await viewModel.loadProfile()
108
109 let body = try #require(SettingsViewModelCapturingURLProtocol.capturedBodies.first)
110 let jsonObject = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
111 let query = try #require(jsonObject["query"] as? String)
112
113 #expect(query.contains("sshKeys"))
114 #expect(!query.contains("results { id fingerprint comment created lastUsed }"))
115 #expect(!query.contains("fingerprint comment created lastUsed"))
116 }
117}