From cb1ea5ea4f163d87053285d3fb7999b12a3558b9 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sun, 12 Apr 2026 00:15:13 -0500 Subject: harden system status and app reliability --- HutchTests/AppStateTests.swift | 11 ++ HutchTests/SystemStatusRepositoryTests.swift | 147 +++++++++++++++++++++++++++ HutchTests/SystemStatusServiceTests.swift | 63 ++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 HutchTests/SystemStatusRepositoryTests.swift (limited to 'HutchTests') diff --git a/HutchTests/AppStateTests.swift b/HutchTests/AppStateTests.swift index ca34df2..9cf4e13 100644 --- a/HutchTests/AppStateTests.swift +++ b/HutchTests/AppStateTests.swift @@ -22,4 +22,15 @@ struct AppStateTests { #expect(appState.deepLinkError == "The ticket could not be found or is inaccessible.") } + + @Test + @MainActor + func openSystemStatusSelectsMoreTabAndQueuesNavigation() { + let appState = AppState() + + appState.openSystemStatus() + + #expect(appState.selectedTab == .more) + #expect(appState.pendingTabNavigation == .systemStatus) + } } diff --git a/HutchTests/SystemStatusRepositoryTests.swift b/HutchTests/SystemStatusRepositoryTests.swift new file mode 100644 index 0000000..fb08732 --- /dev/null +++ b/HutchTests/SystemStatusRepositoryTests.swift @@ -0,0 +1,147 @@ +import Foundation +import Testing +@testable import Hutch + +struct SystemStatusRepositoryTests { + + @Test + func fallsBackToPersistedSnapshotWhenRefreshFails() async throws { + let defaultsName = "SystemStatusRepositoryTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: defaultsName)) + defaults.removePersistentDomain(forName: defaultsName) + defer { defaults.removePersistentDomain(forName: defaultsName) } + + let cachedSnapshot = SystemStatusSnapshot( + services: [ + StatusServiceState(id: "git.sr.ht", name: "git.sr.ht", slug: "git.sr.ht", status: .degraded, description: nil) + ], + activeIncidents: [], + lastUpdated: Date(timeIntervalSince1970: 120) + ) + let cacheStore = SystemStatusCacheStore(defaults: defaults) + let initialRepository = SystemStatusRepository( + service: TestSystemStatusService( + snapshotHTMLHandler: { Self.cachedSnapshotHTML }, + incidentsDataHandler: { Data(Self.emptyRSS.utf8) } + ), + cacheStore: cacheStore, + now: { Date(timeIntervalSince1970: 120) } + ) + + _ = try await initialRepository.snapshotResult(forceRefresh: true) + + let fallbackRepository = SystemStatusRepository( + service: TestSystemStatusService( + snapshotHTMLHandler: { throw SRHTError.httpError(503) }, + incidentsDataHandler: { Data(Self.emptyRSS.utf8) } + ), + ttl: 0, + cacheStore: cacheStore, + now: { Date(timeIntervalSince1970: 180) } + ) + + let result = try await fallbackRepository.snapshotResult(forceRefresh: true) + + #expect(result.value == cachedSnapshot) + #expect(result.isStale) + #expect(result.lastSuccessfulAt == Date(timeIntervalSince1970: 120)) + #expect(result.refreshErrorMessage != nil) + } + + @Test + func fallsBackToPersistedIncidentsWhenRefreshFails() async throws { + let defaultsName = "SystemStatusRepositoryTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: defaultsName)) + defaults.removePersistentDomain(forName: defaultsName) + defer { defaults.removePersistentDomain(forName: defaultsName) } + + let cachedIncidents = [ + StatusIncident( + id: "incident-1", + title: "builds.sr.ht outage", + summary: "Builds are failing.", + url: nil, + publishedAt: Date(timeIntervalSince1970: 200), + updatedAt: nil, + isActive: true + ) + ] + let cacheStore = SystemStatusCacheStore(defaults: defaults) + let initialRepository = SystemStatusRepository( + service: TestSystemStatusService( + snapshotHTMLHandler: { Self.cachedOperationalHTML }, + incidentsDataHandler: { Data(Self.cachedIncidentRSS.utf8) } + ), + cacheStore: cacheStore, + now: { Date(timeIntervalSince1970: 220) } + ) + + _ = try await initialRepository.recentIncidentsResult(forceRefresh: true) + + let fallbackRepository = SystemStatusRepository( + service: TestSystemStatusService( + snapshotHTMLHandler: { Self.cachedOperationalHTML }, + incidentsDataHandler: { throw SRHTError.httpError(504) } + ), + ttl: 0, + cacheStore: cacheStore, + now: { Date(timeIntervalSince1970: 260) } + ) + + let result = try await fallbackRepository.recentIncidentsResult(forceRefresh: true) + + #expect(result.value == cachedIncidents) + #expect(result.isStale) + #expect(result.lastSuccessfulAt == Date(timeIntervalSince1970: 220)) + #expect(result.refreshErrorMessage != nil) + } +} + +private struct TestSystemStatusService: SystemStatusServing { + let snapshotHTMLHandler: @Sendable () async throws -> String + let incidentsDataHandler: @Sendable () async throws -> Data + + func fetchSnapshotHTML() async throws -> String { + try await snapshotHTMLHandler() + } + + func fetchIncidentFeedData() async throws -> Data { + try await incidentsDataHandler() + } +} + +private extension SystemStatusRepositoryTests { + static let cachedSnapshotHTML = #""" +
+ git.sr.ht + Disrupted +
+ """# + + static let cachedOperationalHTML = #""" +
+ meta.sr.ht + Operational +
+ """# + + static let cachedIncidentRSS = #""" + + + + builds.sr.ht outage + https://status.sr.ht/issues/1/ + Thu, 01 Jan 1970 00:03:20 +0000 + incident-1 + <p>Builds are failing.</p> + + + + """# + + static let emptyRSS = #""" + + + + """# +} diff --git a/HutchTests/SystemStatusServiceTests.swift b/HutchTests/SystemStatusServiceTests.swift index 58ace33..d1577b6 100644 --- a/HutchTests/SystemStatusServiceTests.swift +++ b/HutchTests/SystemStatusServiceTests.swift @@ -19,6 +19,18 @@ struct SystemStatusServiceTests { #expect(snapshot.activeIncidents[0].url?.absoluteString == "https://status.sr.ht/issues/2026-04-06-ddos-attack/") } + @Test + func parsesStatusHTMLWithClassOrderChangesAndTimeElements() throws { + let snapshot = try SystemStatusService.parseSnapshotHTML(Self.variantHTML, fetchedAt: .now) + + #expect(snapshot.services.count == 2) + #expect(snapshot.services[0].status == .operational) + #expect(snapshot.services[1].status == .majorOutage) + #expect(snapshot.activeIncidents.count == 1) + #expect(snapshot.activeIncidents[0].title == "builds.sr.ht outage") + #expect(snapshot.activeIncidents[0].publishedAt == ISO8601DateFormatter().date(from: "2026-04-07T12:00:00Z")) + } + @Test func parsesIncidentFeedRSS() async throws { let incidents = try await SystemStatusService.parseIncidentFeedXML(Data(Self.sampleRSS.utf8)) @@ -32,6 +44,15 @@ struct SystemStatusServiceTests { #expect(incidents[1].updatedAt != nil) } + @Test + func parsesIncidentFeedWithISO8601Dates() async throws { + let incidents = try await SystemStatusService.parseIncidentFeedXML(Data(Self.variantRSS.utf8)) + + #expect(incidents.count == 1) + #expect(incidents[0].title == "Status feed moved") + #expect(incidents[0].publishedAt == ISO8601DateFormatter().date(from: "2026-04-07T15:30:00Z")) + } + @Test func bannerSummaryPrefersSpecificServiceThenCount() { let operational = SystemStatusSnapshot( @@ -102,6 +123,32 @@ struct SystemStatusServiceTests { """# + private static let variantHTML = #""" + + +
+ meta.sr.ht + All systems operational +
+
+ builds.sr.ht +
Outage
+
+
+
+

builds.sr.ht outage

+

Build jobs are currently failing.

+
+
+ + +

builds.sr.ht outage

+ Investigating elevated failures +
+ + + """# + private static let sampleRSS = #""" @@ -126,4 +173,20 @@ struct SystemStatusServiceTests { """# + + private static let variantRSS = #""" + + + + sr.ht status + + Status feed moved + https://status.sr.ht/issues/2026-04-07-feed-moved/ + 2026-04-07T15:30:00Z + https://status.sr.ht/issues/2026-04-07-feed-moved/ + <p>Use the new feed endpoint.</p> + + + + """# } -- cgit v1.2.3