summaryrefslogtreecommitdiff
path: root/HutchTests
diff options
context:
space:
mode:
Diffstat (limited to 'HutchTests')
-rw-r--r--HutchTests/BuildTaskTests.swift27
-rw-r--r--HutchTests/GitObjectTests.swift40
-rw-r--r--HutchTests/HutchTests.swift16
-rw-r--r--HutchTests/ReadmeViewTests.swift29
-rw-r--r--HutchTests/RepositoryListViewModelTests.swift50
-rw-r--r--HutchTests/RepositorySettingsViewModelTests.swift104
-rw-r--r--HutchTests/SRHTClientTests.swift27
-rw-r--r--HutchTests/SettingsViewModelTests.swift39
-rw-r--r--HutchTests/SettingsViewTests.swift22
-rw-r--r--HutchTests/TicketDetailViewModelTests.swift30
-rw-r--r--HutchTests/TrackerListViewModelTests.swift16
11 files changed, 400 insertions, 0 deletions
diff --git a/HutchTests/BuildTaskTests.swift b/HutchTests/BuildTaskTests.swift
new file mode 100644
index 0000000..2e2a1e0
--- /dev/null
+++ b/HutchTests/BuildTaskTests.swift
@@ -0,0 +1,27 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct BuildTaskTests {
+
+ @Test
+ @MainActor
+ func duplicateTaskNamesProduceDistinctIDsAndLogCacheKeys() {
+ let firstTask = BuildTask(
+ name: "test",
+ status: .failed,
+ log: BuildLog(fullURL: "https://builds.sr.ht/job/1/task/1")
+ ).withOrdinal(0)
+
+ let secondTask = BuildTask(
+ name: "test",
+ status: .failed,
+ log: BuildLog(fullURL: "https://builds.sr.ht/job/1/task/2")
+ ).withOrdinal(1)
+
+ #expect(firstTask.id == "0:test")
+ #expect(secondTask.id == "1:test")
+ #expect(firstTask.id != secondTask.id)
+ #expect(firstTask.logCacheKey != secondTask.logCacheKey)
+ }
+}
diff --git a/HutchTests/GitObjectTests.swift b/HutchTests/GitObjectTests.swift
new file mode 100644
index 0000000..547c6f3
--- /dev/null
+++ b/HutchTests/GitObjectTests.swift
@@ -0,0 +1,40 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct GitObjectTests {
+
+ @Test
+ @MainActor
+ func decodesMetadataOnlyTextBlobUsingTypename() throws {
+ let data = Data(#"{"type":"BLOB","__typename":"TextBlob","id":"blob123","shortId":"blob123","size":42}"#.utf8)
+
+ let blob = try JSONDecoder().decode(GitObject.self, from: data)
+
+ guard case .textBlob(let textBlob) = blob else {
+ Issue.record("Expected metadata-only blob to decode as text blob.")
+ return
+ }
+
+ #expect(textBlob.id == "blob123")
+ #expect(textBlob.size == 42)
+ #expect(textBlob.text == nil)
+ }
+
+ @Test
+ @MainActor
+ func decodesMetadataOnlyBinaryBlobUsingTypename() throws {
+ let data = Data(#"{"type":"BLOB","__typename":"BinaryBlob","id":"blob456","shortId":"blob456","size":64}"#.utf8)
+
+ let blob = try JSONDecoder().decode(GitObject.self, from: data)
+
+ guard case .binaryBlob(let binaryBlob) = blob else {
+ Issue.record("Expected metadata-only blob to decode as binary blob.")
+ return
+ }
+
+ #expect(binaryBlob.id == "blob456")
+ #expect(binaryBlob.size == 64)
+ #expect(binaryBlob.content == nil)
+ }
+}
diff --git a/HutchTests/HutchTests.swift b/HutchTests/HutchTests.swift
new file mode 100644
index 0000000..c681efa
--- /dev/null
+++ b/HutchTests/HutchTests.swift
@@ -0,0 +1,16 @@
+//
+// HutchTests.swift
+// HutchTests
+//
+// Created by cmc on 2026-03-18.
+//
+
+import Testing
+
+struct HutchTests {
+
+ @Test func example() async throws {
+ // Write your test here and use APIs like `#expect(...)` to check expected conditions.
+ }
+
+}
diff --git a/HutchTests/ReadmeViewTests.swift b/HutchTests/ReadmeViewTests.swift
new file mode 100644
index 0000000..9c3c019
--- /dev/null
+++ b/HutchTests/ReadmeViewTests.swift
@@ -0,0 +1,29 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct ReadmeViewTests {
+
+ @Test
+ func sanitizedReadmeLinkURLStringRejectsUnexpectedSchemes() {
+ #expect(sanitizedReadmeLinkURLString("javascript:alert(1)") == nil)
+ #expect(sanitizedReadmeLinkURLString("file:///tmp/readme") == nil)
+ #expect(sanitizedReadmeLinkURLString("data:text/html;base64,SGVsbG8=") == nil)
+ }
+
+ @Test
+ func processInlineDropsUnsafeMarkdownLinks() {
+ let rendered = processInline("[click me](javascript:alert)")
+
+ #expect(rendered == "click me")
+ #expect(!rendered.contains("href="))
+ #expect(!rendered.contains("javascript:"))
+ }
+
+ @Test
+ func sanitizedReadmeLinkURLStringAllowsExpectedDestinations() {
+ #expect(sanitizedReadmeLinkURLString("https://example.com/docs?q=1") == "https://example.com/docs?q=1")
+ #expect(sanitizedReadmeLinkURLString("mailto:[email protected]") == "mailto:[email protected]")
+ #expect(sanitizedReadmeLinkURLString("#readme") == "#readme")
+ }
+}
diff --git a/HutchTests/RepositoryListViewModelTests.swift b/HutchTests/RepositoryListViewModelTests.swift
new file mode 100644
index 0000000..d6e76bf
--- /dev/null
+++ b/HutchTests/RepositoryListViewModelTests.swift
@@ -0,0 +1,50 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct RepositoryListViewModelTests {
+
+ @Test
+ @MainActor
+ func searchIndexRefreshRequiresMinimumQueryLength() {
+ #expect(RepositoryListViewModel.shouldRefreshSearchIndex(for: "ab") == false)
+ #expect(RepositoryListViewModel.shouldRefreshSearchIndex(for: "abc") == true)
+ #expect(RepositoryListViewModel.shouldRefreshSearchIndex(for: " abc ") == true)
+ }
+
+ @Test
+ @MainActor
+ func filterRepositoriesMatchesNameAndDescriptionLocally() {
+ let repositories = [
+ makeRepository(id: 1, service: .git, name: "Hutch", description: "SourceHut client"),
+ makeRepository(id: 2, service: .hg, name: "Mail", description: "patch queue"),
+ makeRepository(id: 3, service: .git, name: "Tree", description: nil)
+ ]
+
+ let nameMatches = RepositoryListViewModel.filterRepositories(repositories, matching: "hut")
+ let descriptionMatches = RepositoryListViewModel.filterRepositories(repositories, matching: "patch")
+
+ #expect(nameMatches.map(\.id) == [1])
+ #expect(descriptionMatches.map(\.id) == [2])
+ }
+
+ @MainActor
+ private func makeRepository(
+ id: Int,
+ service: SRHTService,
+ name: String,
+ description: String?
+ ) -> RepositorySummary {
+ RepositorySummary(
+ id: id,
+ rid: "rid-\(id)",
+ service: service,
+ name: name,
+ description: description,
+ visibility: .public,
+ updated: Date(timeIntervalSince1970: TimeInterval(id)),
+ owner: Entity(canonicalName: "~owner"),
+ head: Reference(name: "main", target: nil)
+ )
+ }
+}
diff --git a/HutchTests/RepositorySettingsViewModelTests.swift b/HutchTests/RepositorySettingsViewModelTests.swift
new file mode 100644
index 0000000..a2c88ce
--- /dev/null
+++ b/HutchTests/RepositorySettingsViewModelTests.swift
@@ -0,0 +1,104 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+private struct UpdateRepositoryInfoEnvelope: Decodable {
+ let updateRepository: UpdateRepositoryInfoPayload
+}
+
+private struct UpdateRepositoryInfoPayload: Decodable {
+ let id: Int
+}
+
+struct RepositorySettingsViewModelTests {
+
+ @Test
+ func saveInfoResponseDecodesMinimalRepositoryPayload() throws {
+ let json = """
+ {
+ "data": {
+ "updateRepository": {
+ "id": 42
+ }
+ }
+ }
+ """
+
+ let decoded = try JSONDecoder().decode(
+ GraphQLResponse<UpdateRepositoryInfoEnvelope>.self,
+ from: Data(json.utf8)
+ )
+
+ #expect(decoded.data?.updateRepository.id == 42)
+ }
+
+ @Test
+ func gitCanonicalEntityAddsMissingTilde() {
+ #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: "alice") == "~alice")
+ #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: "~alice") == "~alice")
+ #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: " alice ") == "~alice")
+ }
+
+ @Test
+ func gitHeadReferenceUsesFullBranchRef() {
+ #expect(RepositorySettingsViewModel.gitHeadReference(from: "main") == "refs/heads/main")
+ #expect(RepositorySettingsViewModel.gitHeadReference(from: " refs/heads/dev ") == "refs/heads/dev")
+ }
+
+ @Test
+ @MainActor
+ func unchangedHeadIsOmittedFromSaveInput() {
+ let viewModel = RepositorySettingsViewModel(
+ repository: makeRepository(headName: "refs/heads/main"),
+ branches: [Reference(name: "refs/heads/main", target: nil)],
+ client: SRHTClient(token: "test-token")
+ )
+
+ #expect(viewModel.selectedHeadReferenceForSave() == nil)
+ }
+
+ @Test
+ @MainActor
+ func changedHeadUsesSelectedBranchReference() {
+ let viewModel = RepositorySettingsViewModel(
+ repository: makeRepository(headName: "refs/heads/main"),
+ branches: [
+ Reference(name: "refs/heads/main", target: nil),
+ Reference(name: "refs/heads/dev", target: nil)
+ ],
+ client: SRHTClient(token: "test-token")
+ )
+ viewModel.editedHead = "dev"
+
+ #expect(viewModel.selectedHeadReferenceForSave() == "refs/heads/dev")
+ }
+
+ @Test
+ @MainActor
+ func bareRepositoryOmitsHeadWhenNoBranchExists() {
+ let viewModel = RepositorySettingsViewModel(
+ repository: makeRepository(headName: nil),
+ branches: [],
+ client: SRHTClient(token: "test-token")
+ )
+
+ #expect(viewModel.selectedHeadReferenceForSave() == nil)
+ viewModel.editedHead = "main"
+ #expect(viewModel.selectedHeadReferenceForSave() == nil)
+ }
+
+ @MainActor
+ private func makeRepository(headName: String?) -> RepositorySummary {
+ RepositorySummary(
+ id: 1,
+ rid: "rid-1",
+ service: .git,
+ name: "repo",
+ description: "desc",
+ visibility: .public,
+ updated: .now,
+ owner: Entity(canonicalName: "~owner"),
+ head: headName.map { Reference(name: $0, target: nil) }
+ )
+ }
+}
diff --git a/HutchTests/SRHTClientTests.swift b/HutchTests/SRHTClientTests.swift
new file mode 100644
index 0000000..2ce2529
--- /dev/null
+++ b/HutchTests/SRHTClientTests.swift
@@ -0,0 +1,27 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct SRHTClientTests {
+
+ @Test
+ @MainActor
+ func fetchTextRejectsUnexpectedAuthenticatedURL() async throws {
+ let client = SRHTClient(token: "test-token")
+ let url = try #require(URL(string: "https://example.com/build-log"))
+
+ do {
+ _ = try await client.fetchText(url: url)
+ Issue.record("Expected fetchText(url:) to reject non-sr.ht URLs.")
+ } catch let error as SRHTError {
+ guard case .invalidAuthenticatedURL(let rejectedURL) = error else {
+ Issue.record("Expected invalidAuthenticatedURL error, got \(error).")
+ return
+ }
+
+ #expect(rejectedURL == url)
+ } catch {
+ Issue.record("Expected SRHTError.invalidAuthenticatedURL, got \(error).")
+ }
+ }
+}
diff --git a/HutchTests/SettingsViewModelTests.swift b/HutchTests/SettingsViewModelTests.swift
new file mode 100644
index 0000000..fc18162
--- /dev/null
+++ b/HutchTests/SettingsViewModelTests.swift
@@ -0,0 +1,39 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+private struct DeletePGPKeyEnvelope: Decodable {
+ let deletePGPKey: DeleteResultPayload?
+}
+
+private struct DeleteResultPayload: Decodable {
+ let id: Int?
+}
+
+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<DeletePGPKeyEnvelope>.self,
+ from: Data(json.utf8)
+ )
+
+ #expect(decoded.data?.deletePGPKey == nil)
+ #expect(decoded.errors?.first?.message.contains("preferred PGP key") == true)
+ }
+}
diff --git a/HutchTests/SettingsViewTests.swift b/HutchTests/SettingsViewTests.swift
new file mode 100644
index 0000000..950c3dc
--- /dev/null
+++ b/HutchTests/SettingsViewTests.swift
@@ -0,0 +1,22 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct SettingsViewTests {
+
+ @Test
+ @MainActor
+ func settingsBioAttributedStringPreservesInlineMarkdown() {
+ let attributed = settingsBioAttributedString("Hello **world** and [link](https://example.com)")
+
+ #expect(String(attributed.characters).contains("Hello world and link"))
+ }
+
+ @Test
+ @MainActor
+ func settingsBioAttributedStringFallsBackForInvalidMarkdown() {
+ let attributed = settingsBioAttributedString("[broken")
+
+ #expect(String(attributed.characters) == "[broken")
+ }
+}
diff --git a/HutchTests/TicketDetailViewModelTests.swift b/HutchTests/TicketDetailViewModelTests.swift
new file mode 100644
index 0000000..bf0bc60
--- /dev/null
+++ b/HutchTests/TicketDetailViewModelTests.swift
@@ -0,0 +1,30 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct TicketDetailViewModelTests {
+
+ @Test
+ @MainActor
+ func reopenStatusInputOmitsResolution() {
+ let input = TicketDetailViewModel.statusUpdateInput(
+ status: .reported,
+ resolution: .unresolved
+ )
+
+ #expect(input["status"] as? String == TicketStatus.reported.rawValue)
+ #expect(input["resolution"] == nil)
+ }
+
+ @Test
+ @MainActor
+ func resolveStatusInputIncludesResolution() {
+ let input = TicketDetailViewModel.statusUpdateInput(
+ status: .resolved,
+ resolution: .fixed
+ )
+
+ #expect(input["status"] as? String == TicketStatus.resolved.rawValue)
+ #expect(input["resolution"] as? String == TicketResolution.fixed.rawValue)
+ }
+}
diff --git a/HutchTests/TrackerListViewModelTests.swift b/HutchTests/TrackerListViewModelTests.swift
new file mode 100644
index 0000000..1caedea
--- /dev/null
+++ b/HutchTests/TrackerListViewModelTests.swift
@@ -0,0 +1,16 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct TrackerListViewModelTests {
+
+ @Test
+ @MainActor
+ func graphQLErrorDescriptionIsPreservedForTrackerCreationFailures() {
+ let error = SRHTError.graphQLErrors([
+ GraphQLError(message: "A tracker named bugs already exists", locations: nil)
+ ])
+
+ #expect(error.localizedDescription == "GraphQL error: A tracker named bugs already exists")
+ }
+}