Commit aa49c4e3da

aa49c4e3da7e696a203b6ee9bff8a1a3a35023f4

parent: e1f56d98a0

Unsigned

cmc <hello@cleberg.net> · 2026-04-23 22:39 UTC

fix: profile loading error and user-timeline prefs update

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -517,7 +517,7 @@
517 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 517 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
518 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 518 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
519 CODE_SIGN_STYLE = Automatic; 519 CODE_SIGN_STYLE = Automatic;
520 CURRENT_PROJECT_VERSION = 80; 520 CURRENT_PROJECT_VERSION = 81;
521 DEVELOPMENT_TEAM = ZCNAX3VL9D; 521 DEVELOPMENT_TEAM = ZCNAX3VL9D;
522 ENABLE_PREVIEWS = YES; 522 ENABLE_PREVIEWS = YES;
523 GENERATE_INFOPLIST_FILE = YES; 523 GENERATE_INFOPLIST_FILE = YES;
@@ -534,7 +534,7 @@
534 "$(inherited)", 534 "$(inherited)",
535 "@executable_path/Frameworks", 535 "@executable_path/Frameworks",
536 ); 536 );
537 MARKETING_VERSION = 3.2.0; 537 MARKETING_VERSION = 3.2.1;
538 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 538 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
539 PRODUCT_NAME = "$(TARGET_NAME)"; 539 PRODUCT_NAME = "$(TARGET_NAME)";
540 STRING_CATALOG_GENERATE_SYMBOLS = YES; 540 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -554,7 +554,7 @@
554 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 554 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
555 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 555 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
556 CODE_SIGN_STYLE = Automatic; 556 CODE_SIGN_STYLE = Automatic;
557 CURRENT_PROJECT_VERSION = 80; 557 CURRENT_PROJECT_VERSION = 81;
558 DEVELOPMENT_TEAM = ZCNAX3VL9D; 558 DEVELOPMENT_TEAM = ZCNAX3VL9D;
559 ENABLE_PREVIEWS = YES; 559 ENABLE_PREVIEWS = YES;
560 GENERATE_INFOPLIST_FILE = YES; 560 GENERATE_INFOPLIST_FILE = YES;
@@ -571,7 +571,7 @@
571 "$(inherited)", 571 "$(inherited)",
572 "@executable_path/Frameworks", 572 "@executable_path/Frameworks",
573 ); 573 );
574 MARKETING_VERSION = 3.2.0; 574 MARKETING_VERSION = 3.2.1;
575 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 575 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
576 PRODUCT_NAME = "$(TARGET_NAME)"; 576 PRODUCT_NAME = "$(TARGET_NAME)";
577 STRING_CATALOG_GENERATE_SYMBOLS = YES; 577 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -634,7 +634,7 @@
634 APPLICATION_EXTENSION_API_ONLY = YES; 634 APPLICATION_EXTENSION_API_ONLY = YES;
635 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 635 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
636 CODE_SIGN_STYLE = Automatic; 636 CODE_SIGN_STYLE = Automatic;
637 CURRENT_PROJECT_VERSION = 80; 637 CURRENT_PROJECT_VERSION = 81;
638 DEVELOPMENT_TEAM = ZCNAX3VL9D; 638 DEVELOPMENT_TEAM = ZCNAX3VL9D;
639 GENERATE_INFOPLIST_FILE = NO; 639 GENERATE_INFOPLIST_FILE = NO;
640 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 640 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -644,7 +644,7 @@
644 "@executable_path/Frameworks", 644 "@executable_path/Frameworks",
645 "@executable_path/../../Frameworks", 645 "@executable_path/../../Frameworks",
646 ); 646 );
647 MARKETING_VERSION = 3.2.0; 647 MARKETING_VERSION = 3.2.1;
648 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 648 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
649 PRODUCT_NAME = "$(TARGET_NAME)"; 649 PRODUCT_NAME = "$(TARGET_NAME)";
650 SKIP_INSTALL = YES; 650 SKIP_INSTALL = YES;
@@ -663,7 +663,7 @@
663 APPLICATION_EXTENSION_API_ONLY = YES; 663 APPLICATION_EXTENSION_API_ONLY = YES;
664 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 664 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
665 CODE_SIGN_STYLE = Automatic; 665 CODE_SIGN_STYLE = Automatic;
666 CURRENT_PROJECT_VERSION = 80; 666 CURRENT_PROJECT_VERSION = 81;
667 DEVELOPMENT_TEAM = ZCNAX3VL9D; 667 DEVELOPMENT_TEAM = ZCNAX3VL9D;
668 GENERATE_INFOPLIST_FILE = NO; 668 GENERATE_INFOPLIST_FILE = NO;
669 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 669 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -673,7 +673,7 @@
673 "@executable_path/Frameworks", 673 "@executable_path/Frameworks",
674 "@executable_path/../../Frameworks", 674 "@executable_path/../../Frameworks",
675 ); 675 );
676 MARKETING_VERSION = 3.2.0; 676 MARKETING_VERSION = 3.2.1;
677 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 677 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
678 PRODUCT_NAME = "$(TARGET_NAME)"; 678 PRODUCT_NAME = "$(TARGET_NAME)";
679 SKIP_INSTALL = YES; 679 SKIP_INSTALL = YES;
Hutch/App/AppStorageKeys.swift +1
@@ -18,6 +18,7 @@ enum AppStorageKeys {
18 static let homeBuildsExpanded = "homeBuildsExpanded" 18 static let homeBuildsExpanded = "homeBuildsExpanded"
19 static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval" 19 static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval"
20 static let buildsRepoFilter = "buildsRepoFilter" 20 static let buildsRepoFilter = "buildsRepoFilter"
21 static let buildsLookbackDays = "buildsLookbackDays"
21 static let ticketFilterState = "ticketFilterState" 22 static let ticketFilterState = "ticketFilterState"
22 static let ticketSavedFilters = "ticketSavedFilters" 23 static let ticketSavedFilters = "ticketSavedFilters"
23 static let appTheme = "appTheme" 24 static let appTheme = "appTheme"
Hutch/Models/Meta.swift +7 −1
@@ -22,10 +22,16 @@ struct UserProfile: Codable, Sendable {
22 22
23struct SSHKey: Codable, Sendable, Identifiable { 23struct SSHKey: Codable, Sendable, Identifiable {
24 let id: Int 24 let id: Int
25 let fingerprint: String
26 let comment: String? 25 let comment: String?
27 let created: Date 26 let created: Date
28 let lastUsed: Date? 27 let lastUsed: Date?
28
29 var displayLabel: String {
30 if let comment, !comment.isEmpty {
31 return comment
32 }
33 return "SSH key #\(id)"
34 }
29} 35}
30 36
31struct SSHKeyPage: Codable, Sendable { 37struct SSHKeyPage: Codable, Sendable {
Hutch/Views/Builds/BuildListView.swift +26
@@ -4,6 +4,8 @@ struct BuildListView: View {
4 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true 4 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
5 @AppStorage(AppStorageKeys.buildsAutoRefreshInterval) private var autoRefreshRawValue = 0 5 @AppStorage(AppStorageKeys.buildsAutoRefreshInterval) private var autoRefreshRawValue = 0
6 @AppStorage(AppStorageKeys.buildsRepoFilter) private var savedRepoFilter = "" 6 @AppStorage(AppStorageKeys.buildsRepoFilter) private var savedRepoFilter = ""
7 @AppStorage(AppStorageKeys.buildsLookbackDays, store: .standard)
8 private var lookbackDays = BuildListViewModel.defaultLookbackDays
7 @Environment(AppState.self) private var appState 9 @Environment(AppState.self) private var appState
8 @Environment(\.isAMOLEDTheme) private var isAMOLED 10 @Environment(\.isAMOLEDTheme) private var isAMOLED
9 @State private var viewModel: BuildListViewModel? 11 @State private var viewModel: BuildListViewModel?
@@ -65,6 +67,20 @@ struct BuildListView: View {
65 } 67 }
66 } 68 }
67 } 69 }
70 Section("Timeframe") {
71 ForEach(HomeViewModel.allowedFailedBuildLookbackDays, id: \.self) { days in
72 Button {
73 lookbackDays = days
74 viewModel.lookbackDays = days
75 } label: {
76 if lookbackDays == days {
77 Label(HomeViewModel.failedBuildLookbackLabel(days: days), systemImage: "checkmark")
78 } else {
79 Text(HomeViewModel.failedBuildLookbackLabel(days: days))
80 }
81 }
82 }
83 }
68 } label: { 84 } label: {
69 Image(systemName: "line.3.horizontal.decrease.circle") 85 Image(systemName: "line.3.horizontal.decrease.circle")
70 } 86 }
@@ -107,6 +123,7 @@ struct BuildListView: View {
107 if viewModel == nil { 123 if viewModel == nil {
108 let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults) 124 let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults)
109 vm.repoFilter = savedRepoFilter 125 vm.repoFilter = savedRepoFilter
126 vm.lookbackDays = lookbackDays
110 viewModel = vm 127 viewModel = vm
111 await vm.loadJobs() 128 await vm.loadJobs()
112 } 129 }
@@ -114,6 +131,9 @@ struct BuildListView: View {
114 // onDisappear stops it when navigating away. 131 // onDisappear stops it when navigating away.
115 viewModel?.startAutoRefresh(interval: autoRefreshInterval) 132 viewModel?.startAutoRefresh(interval: autoRefreshInterval)
116 } 133 }
134 .onChange(of: lookbackDays) { _, newValue in
135 viewModel?.lookbackDays = newValue
136 }
117 .onDisappear { 137 .onDisappear {
118 viewModel?.stopAutoRefresh() 138 viewModel?.stopAutoRefresh()
119 } 139 }
@@ -236,6 +256,12 @@ struct BuildListView: View {
236 systemImage: "magnifyingglass", 256 systemImage: "magnifyingglass",
237 description: Text("No builds matched “\(viewModel.searchText)”.") 257 description: Text("No builds matched “\(viewModel.searchText)”.")
238 ) 258 )
259 } else if !viewModel.jobs.isEmpty, viewModel.filteredJobs.isEmpty {
260 ContentUnavailableView(
261 "No Builds In Timeframe",
262 systemImage: "calendar.badge.clock",
263 description: Text("No builds were updated \(HomeViewModel.failedBuildLookbackLabel(days: lookbackDays)).")
264 )
239 } else if viewModel.jobs.isEmpty, viewModel.error == nil { 265 } else if viewModel.jobs.isEmpty, viewModel.error == nil {
240 ContentUnavailableView( 266 ContentUnavailableView(
241 "No Builds", 267 "No Builds",
Hutch/Views/Builds/BuildListViewModel.swift +25 −3
@@ -45,6 +45,7 @@ enum AutoRefreshInterval: Int, CaseIterable, Sendable {
45@MainActor 45@MainActor
46final class BuildListViewModel { 46final class BuildListViewModel {
47 private static let searchHistoryScopeID = "builds" 47 private static let searchHistoryScopeID = "builds"
48 nonisolated static let defaultLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays
48 49
49 private(set) var jobs: [JobSummary] = [] { 50 private(set) var jobs: [JobSummary] = [] {
50 didSet { updateFilteredJobs() } 51 didSet { updateFilteredJobs() }
@@ -68,6 +69,9 @@ final class BuildListViewModel {
68 updateFilteredJobs() 69 updateFilteredJobs()
69 } 70 }
70 } 71 }
72 var lookbackDays = defaultLookbackDays {
73 didSet { updateFilteredJobs() }
74 }
71 // Cached filtered result. Updated whenever jobs, filter, searchText, or 75 // Cached filtered result. Updated whenever jobs, filter, searchText, or
72 // repoFilter changes. Only notifies observers when the content actually 76 // repoFilter changes. Only notifies observers when the content actually
73 // differs, which prevents the list from re-rendering on auto-refresh when 77 // differs, which prevents the list from re-rendering on auto-refresh when
@@ -99,7 +103,7 @@ final class BuildListViewModel {
99 } 103 }
100 104
101 private func updateFilteredJobs() { 105 private func updateFilteredJobs() {
102 var result = Self.filterJobs(jobs, filter: filter) 106 var result = Self.filterJobs(jobs, filter: filter, lookbackDays: lookbackDays)
103 if !repoFilter.isEmpty { 107 if !repoFilter.isEmpty {
104 result = result.filter { $0.tags.contains(repoFilter) } 108 result = result.filter { $0.tags.contains(repoFilter) }
105 } 109 }
@@ -382,8 +386,14 @@ final class BuildListViewModel {
382 let cancel: CancelResult 386 let cancel: CancelResult
383 } 387 }
384 388
385 nonisolated static func filterJobs(_ jobs: [JobSummary], filter: BuildListFilter) -> [JobSummary] { 389 nonisolated static func filterJobs(
386 jobs.filter { job in 390 _ jobs: [JobSummary],
391 filter: BuildListFilter,
392 lookbackDays: Int,
393 now: Date = .now,
394 calendar: Calendar = .current
395 ) -> [JobSummary] {
396 let filteredByStatus = jobs.filter { job in
387 switch filter { 397 switch filter {
388 case .attention: 398 case .attention:
389 switch job.status { 399 switch job.status {
@@ -403,6 +413,18 @@ final class BuildListViewModel {
403 return true 413 return true
404 } 414 }
405 } 415 }
416
417 let normalizedLookbackDays = HomeViewModel.allowedFailedBuildLookbackDays.contains(lookbackDays)
418 ? lookbackDays
419 : defaultLookbackDays
420 let startOfToday = calendar.startOfDay(for: now)
421 let windowStart = calendar.date(
422 byAdding: .day,
423 value: -(normalizedLookbackDays - 1),
424 to: startOfToday
425 ) ?? startOfToday
426
427 return filteredByStatus.filter { $0.updated >= windowStart }
406 } 428 }
407 429
408 nonisolated static func searchJobs(_ jobs: [JobSummary], matching query: String) -> [JobSummary] { 430 nonisolated static func searchJobs(_ jobs: [JobSummary], matching query: String) -> [JobSummary] {
Hutch/Views/Home/HomeView.swift +3
@@ -56,6 +56,9 @@ struct HomeView: View {
56 loadRecentActivity() 56 loadRecentActivity()
57 } 57 }
58 } 58 }
59 .onChange(of: failedBuildLookbackDays) { _, _ in
60 viewModel?.refreshNeedsAttentionSnapshot()
61 }
59 } 62 }
60 63
61 @ViewBuilder 64 @ViewBuilder
Hutch/Views/Home/HomeViewModel.swift +4
@@ -670,6 +670,10 @@ final class HomeViewModel {
670 persistNeedsAttentionSnapshot() 670 persistNeedsAttentionSnapshot()
671 } 671 }
672 672
673 func refreshNeedsAttentionSnapshot() {
674 persistNeedsAttentionSnapshot()
675 }
676
673 private func loadProjects() async -> Result<[Project], Error> { 677 private func loadProjects() async -> Result<[Project], Error> {
674 do { 678 do {
675 return .success(try await projectService.fetchProjects()) 679 return .success(try await projectService.fetchProjects())
Hutch/Views/More/ProfileView.swift +2 −2
@@ -243,7 +243,7 @@ struct ProfileView: View {
243 Section { 243 Section {
244 ForEach(viewModel.sshKeys) { key in 244 ForEach(viewModel.sshKeys) { key in
245 VStack(alignment: .leading, spacing: 2) { 245 VStack(alignment: .leading, spacing: 2) {
246 Text(key.fingerprint) 246 Text(key.displayLabel)
247 .font(.caption.monospaced()) 247 .font(.caption.monospaced())
248 .lineLimit(1) 248 .lineLimit(1)
249 .truncationMode(.middle) 249 .truncationMode(.middle)
@@ -677,7 +677,7 @@ private enum ProfileDestructiveAction {
677 var message: String { 677 var message: String {
678 switch self { 678 switch self {
679 case .deleteSSHKey(let key): 679 case .deleteSSHKey(let key):
680 "Remove SSH key \(key.fingerprint) from your account?" 680 "Remove \(key.displayLabel) from your account?"
681 case .deletePGPKey(let key): 681 case .deletePGPKey(let key):
682 "Remove PGP key \(key.fingerprint) from your account?" 682 "Remove PGP key \(key.fingerprint) from your account?"
683 } 683 }
Hutch/Views/Settings/SettingsViewModel.swift +2 −2
@@ -90,7 +90,7 @@ final class SettingsViewModel {
90 avatar 90 avatar
91 userType 91 userType
92 sshKeys { 92 sshKeys {
93 results { id fingerprint comment created lastUsed } 93 results { id comment created lastUsed }
94 cursor 94 cursor
95 } 95 }
96 pgpKeys { 96 pgpKeys {
@@ -114,7 +114,7 @@ final class SettingsViewModel {
114 private static let createSSHKeyMutation = """ 114 private static let createSSHKeyMutation = """
115 mutation createSSHKey($key: String!) { 115 mutation createSSHKey($key: String!) {
116 createSSHKey(key: $key) { 116 createSSHKey(key: $key) {
117 id fingerprint comment created lastUsed 117 id comment created lastUsed
118 } 118 }
119 } 119 }
120 """ 120 """
HutchTests/BuildListViewModelTests.swift +53 −5
@@ -49,22 +49,70 @@ struct BuildListViewModelTests {
49 makeJob(id: 4, status: .cancelled, tags: []) 49 makeJob(id: 4, status: .cancelled, tags: [])
50 ] 50 ]
51 51
52 let attention = BuildListViewModel.filterJobs(jobs, filter: .attention) 52 let attention = BuildListViewModel.filterJobs(
53 let active = BuildListViewModel.filterJobs(jobs, filter: .active) 53 jobs,
54 filter: .attention,
55 lookbackDays: BuildListViewModel.defaultLookbackDays
56 )
57 let active = BuildListViewModel.filterJobs(
58 jobs,
59 filter: .active,
60 lookbackDays: BuildListViewModel.defaultLookbackDays
61 )
54 62
55 #expect(attention.map(\.id) == [2, 3]) 63 #expect(attention.map(\.id) == [2, 3])
56 #expect(active.map(\.id) == [3]) 64 #expect(active.map(\.id) == [3])
57 } 65 }
58 66
67 @Test
68 func buildFilterRestrictsJobsToSelectedLookbackWindow() {
69 let now = Date(timeIntervalSince1970: 60 * 60 * 24 * 20)
70 let jobs = [
71 makeJob(
72 id: 1,
73 status: .failed,
74 tags: [],
75 updated: now.addingTimeInterval(-(60 * 60 * 24))
76 ),
77 makeJob(
78 id: 2,
79 status: .running,
80 tags: [],
81 updated: now.addingTimeInterval(-(60 * 60 * 24 * 8))
82 ),
83 makeJob(
84 id: 3,
85 status: .success,
86 tags: [],
87 updated: now.addingTimeInterval(-(60 * 60 * 24 * 2))
88 ),
89 ]
90
91 let filtered = BuildListViewModel.filterJobs(
92 jobs,
93 filter: .all,
94 lookbackDays: 3,
95 now: now,
96 calendar: Calendar(identifier: .gregorian)
97 )
98
99 #expect(filtered.map(\.id) == [1, 3])
100 }
101
59 private func filterJobs(_ jobs: [JobSummary], query: String) -> [JobSummary] { 102 private func filterJobs(_ jobs: [JobSummary], query: String) -> [JobSummary] {
60 BuildListViewModel.searchJobs(jobs, matching: query) 103 BuildListViewModel.searchJobs(jobs, matching: query)
61 } 104 }
62 105
63 private func makeJob(id: Int, status: JobStatus = .success, tags: [String]) -> JobSummary { 106 private func makeJob(
107 id: Int,
108 status: JobStatus = .success,
109 tags: [String],
110 updated: Date = Date()
111 ) -> JobSummary {
64 JobSummary( 112 JobSummary(
65 id: id, 113 id: id,
66 created: Date(), 114 created: updated,
67 updated: Date(), 115 updated: updated,
68 status: status, 116 status: status,
69 note: nil, 117 note: nil,
70 tags: tags, 118 tags: tags,
HutchTests/SettingsViewModelTests.swift +55
@@ -2,6 +2,37 @@ import Foundation
2import Testing 2import Testing
3@testable import Hutch 3@testable import Hutch
4 4
5private final class SettingsViewModelCapturingURLProtocol: URLProtocol, @unchecked Sendable {
6 nonisolated(unsafe) static var capturedRequests: [URLRequest] = []
7
8 override class func canInit(with _: URLRequest) -> Bool { true }
9 override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
10
11 override func startLoading() {
12 Self.capturedRequests.append(request)
13
14 let response = HTTPURLResponse(
15 url: request.url!,
16 statusCode: 401,
17 httpVersion: nil,
18 headerFields: nil
19 )!
20 client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
21 client?.urlProtocol(self, didLoad: Data())
22 client?.urlProtocolDidFinishLoading(self)
23 }
24
25 override func stopLoading() {
26 // No cleanup is needed because the stub responds immediately in `startLoading()`.
27 }
28
29 static func makeSession() -> URLSession {
30 let config = URLSessionConfiguration.ephemeral
31 config.protocolClasses = [Self.self]
32 return URLSession(configuration: config)
33 }
34}
35
5private struct DeletePGPKeyEnvelope: Decodable { 36private struct DeletePGPKeyEnvelope: Decodable {
6 let deletePGPKey: DeleteResultPayload? 37 let deletePGPKey: DeleteResultPayload?
7} 38}
@@ -10,6 +41,7 @@ private struct DeleteResultPayload: Decodable {
10 let id: Int? 41 let id: Int?
11} 42}
12 43
44@Suite(.serialized)
13struct SettingsViewModelTests { 45struct SettingsViewModelTests {
14 46
15 @Test 47 @Test
@@ -36,4 +68,27 @@ struct SettingsViewModelTests {
36 #expect(decoded.data?.deletePGPKey == nil) 68 #expect(decoded.data?.deletePGPKey == nil)
37 #expect(decoded.errors?.first?.message.contains("preferred PGP key") == true) 69 #expect(decoded.errors?.first?.message.contains("preferred PGP key") == true)
38 } 70 }
71
72 @Test
73 @MainActor
74 func loadProfileDoesNotRequestSSHKeyFingerprintField() async throws {
75 SettingsViewModelCapturingURLProtocol.capturedRequests = []
76
77 let client = SRHTClient(
78 session: SettingsViewModelCapturingURLProtocol.makeSession(),
79 token: "test-token"
80 )
81 let viewModel = SettingsViewModel(client: client)
82
83 await viewModel.loadProfile()
84
85 let request = try #require(SettingsViewModelCapturingURLProtocol.capturedRequests.first)
86 let body = try #require(request.httpBody)
87 let jsonObject = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
88 let query = try #require(jsonObject["query"] as? String)
89
90 #expect(query.contains("sshKeys"))
91 #expect(!query.contains("results { id fingerprint comment created lastUsed }"))
92 #expect(!query.contains("fingerprint comment created lastUsed"))
93 }
39} 94}