diff options
Diffstat (limited to 'HutchTests')
| -rw-r--r-- | HutchTests/BuildTaskTests.swift | 27 | ||||
| -rw-r--r-- | HutchTests/GitObjectTests.swift | 40 | ||||
| -rw-r--r-- | HutchTests/HutchTests.swift | 16 | ||||
| -rw-r--r-- | HutchTests/ReadmeViewTests.swift | 29 | ||||
| -rw-r--r-- | HutchTests/RepositoryListViewModelTests.swift | 50 | ||||
| -rw-r--r-- | HutchTests/RepositorySettingsViewModelTests.swift | 104 | ||||
| -rw-r--r-- | HutchTests/SRHTClientTests.swift | 27 | ||||
| -rw-r--r-- | HutchTests/SettingsViewModelTests.swift | 39 | ||||
| -rw-r--r-- | HutchTests/SettingsViewTests.swift | 22 | ||||
| -rw-r--r-- | HutchTests/TicketDetailViewModelTests.swift | 30 | ||||
| -rw-r--r-- | HutchTests/TrackerListViewModelTests.swift | 16 |
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") + } +} |
