diff --git a/Sources/ForgejoKit/Models/Attachment.swift b/Sources/ForgejoKit/Models/Attachment.swift new file mode 100644 index 0000000..c92ac28 --- /dev/null +++ b/Sources/ForgejoKit/Models/Attachment.swift @@ -0,0 +1,41 @@ +import Foundation + +public struct Attachment: Codable, Identifiable, Sendable { + public let id: Int + public let uuid: String + public let name: String + public let size: Int + public let browserDownloadUrl: String + public let type: String? + public let created: Date + + public init( + id: Int, uuid: String, name: String, size: Int, + browserDownloadUrl: String, + type: String?, created: Date, + ) { + self.id = id + self.uuid = uuid + self.name = name + self.size = size + self.browserDownloadUrl = browserDownloadUrl + self.type = type + self.created = created + } + + public var isImage: Bool { + if let type, type.hasPrefix("image/") { return true } + let ext = (name as NSString).pathExtension.lowercased() + return ["png", "jpg", "jpeg", "gif", "webp", "heic", "heif", "svg"].contains(ext) + } + + enum CodingKeys: String, CodingKey { + case id + case uuid + case name + case size + case browserDownloadUrl = "browser_download_url" + case type + case created = "created_at" + } +} diff --git a/Sources/ForgejoKit/Models/Issue.swift b/Sources/ForgejoKit/Models/Issue.swift index add6f29..566962e 100644 --- a/Sources/ForgejoKit/Models/Issue.swift +++ b/Sources/ForgejoKit/Models/Issue.swift @@ -17,6 +17,7 @@ public struct Issue: Codable, Identifiable, Sendable { public let pullRequest: IssuePullRequest? public let comments: Int public let repository: Repository? + public let assets: [Attachment]? public init( id: Int, number: Int, title: String, @@ -28,6 +29,7 @@ public struct Issue: Codable, Identifiable, Sendable { closedAt: Date? = nil, dueDate: Date? = nil, pullRequest: IssuePullRequest? = nil, comments: Int = 0, repository: Repository? = nil, + assets: [Attachment]? = nil, ) { self.id = id self.number = number @@ -45,6 +47,7 @@ public struct Issue: Codable, Identifiable, Sendable { self.pullRequest = pullRequest self.comments = comments self.repository = repository + self.assets = assets } enum CodingKeys: String, CodingKey { @@ -64,6 +67,7 @@ public struct Issue: Codable, Identifiable, Sendable { case pullRequest = "pull_request" case comments case repository + case assets } } diff --git a/Sources/ForgejoKit/Models/IssueComment.swift b/Sources/ForgejoKit/Models/IssueComment.swift index 835b9f1..d3ba8e7 100644 --- a/Sources/ForgejoKit/Models/IssueComment.swift +++ b/Sources/ForgejoKit/Models/IssueComment.swift @@ -6,13 +6,19 @@ public struct IssueComment: Codable, Identifiable, Sendable { public let user: User public let createdAt: Date public let updatedAt: Date + public let assets: [Attachment]? - public init(id: Int, body: String, user: User, createdAt: Date, updatedAt: Date) { + public init( + id: Int, body: String, user: User, + createdAt: Date, updatedAt: Date, + assets: [Attachment]? = nil, + ) { self.id = id self.body = body self.user = user self.createdAt = createdAt self.updatedAt = updatedAt + self.assets = assets } enum CodingKeys: String, CodingKey { @@ -21,5 +27,6 @@ public struct IssueComment: Codable, Identifiable, Sendable { case user case createdAt = "created_at" case updatedAt = "updated_at" + case assets } } diff --git a/Sources/ForgejoKit/Models/PullRequest.swift b/Sources/ForgejoKit/Models/PullRequest.swift index d975d96..873c2e4 100644 --- a/Sources/ForgejoKit/Models/PullRequest.swift +++ b/Sources/ForgejoKit/Models/PullRequest.swift @@ -23,6 +23,7 @@ public struct PullRequest: Codable, Identifiable, Sendable { public let closedAt: Date? public let mergedAt: Date? public let htmlUrl: String? + public let assets: [Attachment]? public init( id: Int, number: Int, title: String, @@ -38,6 +39,7 @@ public struct PullRequest: Codable, Identifiable, Sendable { createdAt: Date, updatedAt: Date, closedAt: Date? = nil, mergedAt: Date? = nil, htmlUrl: String? = nil, + assets: [Attachment]? = nil, ) { self.id = id self.number = number @@ -61,6 +63,7 @@ public struct PullRequest: Codable, Identifiable, Sendable { self.closedAt = closedAt self.mergedAt = mergedAt self.htmlUrl = htmlUrl + self.assets = assets } enum CodingKeys: String, CodingKey { @@ -86,6 +89,7 @@ public struct PullRequest: Codable, Identifiable, Sendable { case closedAt = "closed_at" case mergedAt = "merged_at" case htmlUrl = "html_url" + case assets } } diff --git a/Sources/ForgejoKit/Services/ForgejoClient.swift b/Sources/ForgejoKit/Services/ForgejoClient.swift index f4a3064..a7399d8 100644 --- a/Sources/ForgejoKit/Services/ForgejoClient.swift +++ b/Sources/ForgejoKit/Services/ForgejoClient.swift @@ -4,7 +4,7 @@ public final class ForgejoClient: Sendable { public let serverURL: String public let username: String - private enum AuthCredential: Sendable { + private enum AuthCredential { case basic(base64: String) case token(String) case bearer(String) @@ -372,6 +372,53 @@ extension ForgejoClient { try JSONEncoder().encode(body) } + /// Builds a multipart/form-data body with a single file part named "attachment". + static func buildMultipartBody( + fileData: Data, + fileName: String, + mimeType: String, + boundary: String, + ) -> Data { + var body = Data() + body.append(contentsOf: "--\(boundary)\r\n".utf8) + let disposition = "Content-Disposition: form-data; name=\"attachment\"; filename=\"\(fileName)\"\r\n" + body.append(contentsOf: disposition.utf8) + body.append(contentsOf: "Content-Type: \(mimeType)\r\n\r\n".utf8) + body.append(fileData) + body.append(contentsOf: "\r\n--\(boundary)--\r\n".utf8) + return body + } + + func performMultipartRequest( + url: URL, + fileData: Data, + fileName: String, + mimeType: String, + boundary: String = UUID().uuidString, + responseType: T.Type, + ) async throws -> T { + let body = Self.buildMultipartBody( + fileData: fileData, fileName: fileName, mimeType: mimeType, boundary: boundary, + ) + var request = authenticatedRequest(url: url, method: "POST") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + let (data, response) = try await urlSessionManager.session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw ServiceError.invalidResponse + } + try validateStatus(httpResponse, data: data) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = forgejoDateDecodingStrategy + do { + return try decoder.decode(responseType, from: data) + } catch let error as DecodingError { + throw ServiceError.decodingFailed(detail: describeDecodingError(error)) + } + } + public static func normalizeServerURL(_ url: String) -> String { var normalized = url.trimmingCharacters(in: .whitespacesAndNewlines) while normalized.hasSuffix("/") { diff --git a/Sources/ForgejoKit/Services/IssueService.swift b/Sources/ForgejoKit/Services/IssueService.swift index 7686484..9677830 100644 --- a/Sources/ForgejoKit/Services/IssueService.swift +++ b/Sources/ForgejoKit/Services/IssueService.swift @@ -184,4 +184,37 @@ public final class IssueService: Sendable { responseType: IssueComment.self, ) } + + // swiftlint:disable:next function_parameter_count + public func uploadIssueAttachment( + owner: String, repo: String, index: Int, + fileData: Data, fileName: String, mimeType: String, + ) async throws -> Attachment { + let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/issues/\(index)/assets") + return try await client.performMultipartRequest( + url: url, + fileData: fileData, + fileName: fileName, + mimeType: mimeType, + responseType: Attachment.self, + ) + } + + // swiftlint:disable:next function_parameter_count + public func uploadCommentAttachment( + owner: String, repo: String, commentId: Int, + fileData: Data, fileName: String, mimeType: String, + ) async throws -> Attachment { + let url = try client.makeRepoURL( + owner: owner, repo: repo, + path: "/issues/comments/\(commentId)/assets", + ) + return try await client.performMultipartRequest( + url: url, + fileData: fileData, + fileName: fileName, + mimeType: mimeType, + responseType: Attachment.self, + ) + } } diff --git a/Tests/ForgejoKitTests/AdminServiceTests.swift b/Tests/ForgejoKitTests/AdminServiceTests.swift index 5620e62..52a04a9 100644 --- a/Tests/ForgejoKitTests/AdminServiceTests.swift +++ b/Tests/ForgejoKitTests/AdminServiceTests.swift @@ -3,7 +3,7 @@ import Foundation import Testing struct AdminServiceTests { - @Test func createUserPayloadEncodesCorrectKeys() throws { + @Test func `create user payload encodes correct keys`() throws { let json = """ { "username": "testbot", @@ -27,7 +27,7 @@ struct AdminServiceTests { #expect(dict["visibility"] as? String == "public") } - @Test func createUserURLConstruction() throws { + @Test func `create user URL construction`() throws { let client = ForgejoClient(serverURL: "https://forgejo.example.com", username: "admin", password: "pass") let url = try client.makeURL(path: "/api/v1/admin/users") #expect(url.absoluteString == "https://forgejo.example.com/api/v1/admin/users") diff --git a/Tests/ForgejoKitTests/AttachmentTests.swift b/Tests/ForgejoKitTests/AttachmentTests.swift new file mode 100644 index 0000000..2331e3b --- /dev/null +++ b/Tests/ForgejoKitTests/AttachmentTests.swift @@ -0,0 +1,249 @@ +@testable import ForgejoKit +import Foundation +import Testing + +struct AttachmentTests { + private func decoder() -> JSONDecoder { + let dec = JSONDecoder() + dec.dateDecodingStrategy = forgejoDateDecodingStrategy + return dec + } + + // MARK: - Model decoding + + @Test func `decodes attachment`() throws { + let json = """ + { + "id": 1, + "uuid": "ee01801f-ffdb-4154-856b-77eab52aaad2", + "name": "screenshot.png", + "size": 222208, + "browser_download_url": "https://codeberg.org/attachments/ee01801f-ffdb-4154-856b-77eab52aaad2", + "type": "image/png", + "created_at": "2026-06-14T17:49:26Z" + } + """ + let attachment = try decoder().decode(Attachment.self, from: Data(json.utf8)) + #expect(attachment.id == 1) + #expect(attachment.uuid == "ee01801f-ffdb-4154-856b-77eab52aaad2") + #expect(attachment.name == "screenshot.png") + #expect(attachment.size == 222_208) + #expect(attachment.browserDownloadUrl == "https://codeberg.org/attachments/ee01801f-ffdb-4154-856b-77eab52aaad2") + #expect(attachment.type == "image/png") + #expect(attachment.isImage == true) + } + + @Test func `decodes attachment without type field`() throws { + // Forgejo API does not always include a type field — isImage falls back to file extension. + let json = """ + { + "id": 3, + "uuid": "no-type-uuid", + "name": "photo.jpg", + "size": 10000, + "browser_download_url": "https://codeberg.org/attachments/no-type-uuid", + "created_at": "2026-01-01T00:00:00Z" + } + """ + let attachment = try decoder().decode(Attachment.self, from: Data(json.utf8)) + #expect(attachment.type == nil) + #expect(attachment.isImage == true) + } + + @Test func `decodes non image attachment`() throws { + let json = """ + { + "id": 2, + "uuid": "abc123", + "name": "report.pdf", + "size": 50000, + "browser_download_url": "https://forgejo.example.com/attachments/abc123", + "type": "application/pdf", + "created_at": "2026-01-01T00:00:00Z" + } + """ + let attachment = try decoder().decode(Attachment.self, from: Data(json.utf8)) + #expect(attachment.name == "report.pdf") + #expect(attachment.type == "application/pdf") + #expect(attachment.isImage == false) + } + + @Test func `decodes issue with assets`() throws { + let json = """ + { + "id": 1, + "number": 42, + "title": "Bug report", + "state": "open", + "user": { "id": 1, "login": "alice" }, + "labels": [], + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "comments": 0, + "assets": [ + { + "id": 10, + "uuid": "uuid-1", + "name": "bug.png", + "size": 1024, + "browser_download_url": "https://forgejo.example.com/attachments/uuid-1", + "type": "image/png", + "created_at": "2026-01-01T00:00:00Z" + } + ] + } + """ + let issue = try decoder().decode(Issue.self, from: Data(json.utf8)) + #expect(issue.assets?.count == 1) + #expect(issue.assets?.first?.name == "bug.png") + #expect(issue.assets?.first?.isImage == true) + } + + @Test func `decodes issue with no assets`() throws { + let json = """ + { + "id": 1, + "number": 1, + "title": "No attachments", + "state": "open", + "user": { "id": 1, "login": "alice" }, + "labels": [], + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "comments": 0 + } + """ + let issue = try decoder().decode(Issue.self, from: Data(json.utf8)) + #expect(issue.assets == nil) + } + + @Test func `decodes issue comment with assets`() throws { + let json = """ + { + "id": 5, + "body": "See attached screenshot", + "user": { "id": 2, "login": "bob" }, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "assets": [ + { + "id": 20, + "uuid": "uuid-2", + "name": "comment-image.jpg", + "size": 2048, + "browser_download_url": "https://forgejo.example.com/attachments/uuid-2", + "type": "image/jpeg", + "created_at": "2026-01-01T00:00:00Z" + } + ] + } + """ + let comment = try decoder().decode(IssueComment.self, from: Data(json.utf8)) + #expect(comment.assets?.count == 1) + #expect(comment.assets?.first?.name == "comment-image.jpg") + } + + // MARK: - isImage + + @Test func `is image for various mime types`() { + let makeAttachment = { (type: String) in + Attachment( + id: 1, uuid: "u", name: "f.bin", size: 0, + browserDownloadUrl: "https://example.com/f", + type: type, created: Date(), + ) + } + #expect(makeAttachment("image/png").isImage == true) + #expect(makeAttachment("image/jpeg").isImage == true) + #expect(makeAttachment("image/gif").isImage == true) + #expect(makeAttachment("image/webp").isImage == true) + #expect(makeAttachment("application/pdf").isImage == false) + #expect(makeAttachment("text/plain").isImage == false) + #expect(makeAttachment("application/zip").isImage == false) + } + + @Test func `is image falls back to file extension when type is nil`() { + let makeAttachment = { (name: String) in + Attachment( + id: 1, uuid: "u", name: name, size: 0, + browserDownloadUrl: "https://example.com/f", + type: nil, created: Date(), + ) + } + #expect(makeAttachment("photo.png").isImage == true) + #expect(makeAttachment("photo.jpg").isImage == true) + #expect(makeAttachment("photo.jpeg").isImage == true) + #expect(makeAttachment("photo.gif").isImage == true) + #expect(makeAttachment("photo.webp").isImage == true) + #expect(makeAttachment("document.pdf").isImage == false) + #expect(makeAttachment("archive.zip").isImage == false) + #expect(makeAttachment("readme.txt").isImage == false) + } + + @Test func `is image falls back to extension when type is not a mime type`() { + // Forgejo returns "type": "attachment" for all uploads, not an actual MIME type. + // isImage must fall through to the extension check in this case. + let makeAttachment = { (name: String) in + Attachment( + id: 1, uuid: "u", name: name, size: 0, + browserDownloadUrl: "https://example.com/f", + type: "attachment", created: Date(), + ) + } + #expect(makeAttachment("photo.png").isImage == true) + #expect(makeAttachment("photo.jpg").isImage == true) + #expect(makeAttachment("photo.gif").isImage == true) + #expect(makeAttachment("document.pdf").isImage == false) + #expect(makeAttachment("archive.zip").isImage == false) + } + + // MARK: - Upload URL construction + + @Test func `upload issue attachment URL`() throws { + let client = ForgejoClient(serverURL: "https://forgejo.example.com", username: "u", password: "p") + let url = try client.makeRepoURL(owner: "alice", repo: "myrepo", path: "/issues/42/assets") + #expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/alice/myrepo/issues/42/assets") + } + + @Test func `upload comment attachment URL`() throws { + let client = ForgejoClient(serverURL: "https://forgejo.example.com", username: "u", password: "p") + let url = try client.makeRepoURL(owner: "alice", repo: "myrepo", path: "/issues/comments/99/assets") + #expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/alice/myrepo/issues/comments/99/assets") + } + + @Test func `upload URL encodes owner and repo`() throws { + let client = ForgejoClient(serverURL: "https://forgejo.example.com", username: "u", password: "p") + let url = try client.makeRepoURL(owner: "my org", repo: "my repo", path: "/issues/1/assets") + #expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/my%20org/my%20repo/issues/1/assets") + } + + // MARK: - Multipart body + + @Test func `build multipart body structure`() { + let fileData = Data("hello".utf8) + let boundary = "test-boundary-123" + let body = ForgejoClient.buildMultipartBody( + fileData: fileData, + fileName: "test.png", + mimeType: "image/png", + boundary: boundary, + ) + let bodyString = String(data: body, encoding: .utf8) ?? "" + #expect(bodyString.contains("--\(boundary)\r\n")) + #expect(bodyString.contains("Content-Disposition: form-data; name=\"attachment\"; filename=\"test.png\"")) + #expect(bodyString.contains("Content-Type: image/png")) + #expect(bodyString.contains("hello")) + #expect(bodyString.hasSuffix("--\(boundary)--\r\n")) + } + + @Test func `build multipart body contains file data`() { + let fileData = Data([0xFF, 0xD8, 0xFF, 0xE0]) // JPEG header bytes + let boundary = "b" + let body = ForgejoClient.buildMultipartBody( + fileData: fileData, fileName: "img.jpg", mimeType: "image/jpeg", boundary: boundary, + ) + let jpegHeader = Data([0xFF, 0xD8, 0xFF, 0xE0]) + let range = body.range(of: jpegHeader) + #expect(range != nil) + } +} diff --git a/Tests/ForgejoKitTests/BearerCredentialTests.swift b/Tests/ForgejoKitTests/BearerCredentialTests.swift index a612a12..e7d887a 100644 --- a/Tests/ForgejoKitTests/BearerCredentialTests.swift +++ b/Tests/ForgejoKitTests/BearerCredentialTests.swift @@ -3,21 +3,21 @@ import Foundation import Testing struct BearerCredentialTests { - @Test func bearerCredentialUsesBearerScheme() { + @Test func `bearer credential uses bearer scheme`() { let client = ForgejoClient( serverURL: "https://codeberg.org", username: "octocat", bearerToken: "abc123", ) #expect(client.authorizationHeaderValue == "Bearer abc123") } - @Test func tokenCredentialStillUsesTokenScheme() { + @Test func `token credential still uses token scheme`() { let client = ForgejoClient( serverURL: "https://codeberg.org", username: "octocat", token: "abc123", ) #expect(client.authorizationHeaderValue == "token abc123") } - @Test func basicCredentialUsesBasicScheme() { + @Test func `basic credential uses basic scheme`() { let client = ForgejoClient( serverURL: "https://codeberg.org", username: "octocat", password: "pw", ) diff --git a/Tests/ForgejoKitTests/DateDecodingTests.swift b/Tests/ForgejoKitTests/DateDecodingTests.swift index 8798900..cbc13c4 100644 --- a/Tests/ForgejoKitTests/DateDecodingTests.swift +++ b/Tests/ForgejoKitTests/DateDecodingTests.swift @@ -21,7 +21,7 @@ struct DateDecodingTests { // MARK: - ISO 8601 without fractional seconds - @Test func decodesDateWithoutFractionalSeconds() throws { + @Test func `decodes date without fractional seconds`() throws { let json = #"{"date":"2024-01-15T10:30:00Z"}"# let result = try decoder().decode(DateWrapper.self, from: Data(json.utf8)) @@ -34,7 +34,7 @@ struct DateDecodingTests { // MARK: - ISO 8601 with fractional seconds - @Test func decodesDateWithFractionalSeconds() throws { + @Test func `decodes date with fractional seconds`() throws { let json = #"{"date":"2024-06-20T14:05:30.123Z"}"# let result = try decoder().decode(DateWrapper.self, from: Data(json.utf8)) @@ -44,7 +44,7 @@ struct DateDecodingTests { #expect(utcCalendar.component(.hour, from: result.date) == 14) } - @Test func decodesDateWithTripleZeroFraction() throws { + @Test func `decodes date with triple zero fraction`() throws { let json = #"{"date":"2024-01-15T10:30:00.000Z"}"# let result = try decoder().decode(DateWrapper.self, from: Data(json.utf8)) @@ -53,14 +53,14 @@ struct DateDecodingTests { // MARK: - Invalid dates - @Test func throwsOnInvalidDateString() { + @Test func `throws on invalid date string`() { let json = #"{"date":"not-a-date"}"# #expect(throws: DecodingError.self) { _ = try decoder().decode(DateWrapper.self, from: Data(json.utf8)) } } - @Test func throwsOnEmptyDateString() { + @Test func `throws on empty date string`() { let json = #"{"date":""}"# #expect(throws: DecodingError.self) { _ = try decoder().decode(DateWrapper.self, from: Data(json.utf8)) @@ -69,7 +69,7 @@ struct DateDecodingTests { // MARK: - Both formats decode to same value - @Test func bothFormatsProduceSameDate() throws { + @Test func `both formats produce same date`() throws { let jsonWithout = #"{"date":"2024-03-10T08:00:00Z"}"# let jsonWith = #"{"date":"2024-03-10T08:00:00.000Z"}"# diff --git a/Tests/ForgejoKitTests/DiffParserTests.swift b/Tests/ForgejoKitTests/DiffParserTests.swift index 5e896e2..2864056 100644 --- a/Tests/ForgejoKitTests/DiffParserTests.swift +++ b/Tests/ForgejoKitTests/DiffParserTests.swift @@ -5,19 +5,19 @@ import Testing struct DiffParserTests { // MARK: - Empty and minimal input - @Test func parseEmptyString() { + @Test func `parse empty string`() { let result = DiffParser.parse("") #expect(result.files.isEmpty) } - @Test func parseGarbageInput() { + @Test func `parse garbage input`() { let result = DiffParser.parse("this is not a diff") #expect(result.files.isEmpty) } // MARK: - Single file diff - @Test func parseSingleFileAddition() { + @Test func `parse single file addition`() { let diff = [ "diff --git a/hello.txt b/hello.txt", "new file mode 100644", @@ -45,7 +45,7 @@ struct DiffParserTests { #expect(hunk.lines[3].type == .addition) } - @Test func parseSingleFileDeletion() { + @Test func `parse single file deletion`() { let diff = [ "diff --git a/old.txt b/old.txt", "deleted file mode 100644", @@ -64,7 +64,7 @@ struct DiffParserTests { #expect(file.newName == "/dev/null") } - @Test func parseSingleFileModification() { + @Test func `parse single file modification`() { let diff = [ "diff --git a/file.txt b/file.txt", "--- a/file.txt", @@ -107,7 +107,7 @@ struct DiffParserTests { // MARK: - Multiple files - @Test func parseMultipleFiles() { + @Test func `parse multiple files`() { let diff = [ "diff --git a/a.txt b/a.txt", "--- a/a.txt", @@ -131,7 +131,7 @@ struct DiffParserTests { // MARK: - Multiple hunks - @Test func parseMultipleHunks() { + @Test func `parse multiple hunks`() { let diff = [ "diff --git a/file.txt b/file.txt", "--- a/file.txt", @@ -159,7 +159,7 @@ struct DiffParserTests { // MARK: - Line number tracking - @Test func lineNumbersTrackCorrectlyWithMultipleAdditions() { + @Test func `line numbers track correctly with multiple additions`() { let diff = [ "diff --git a/file.txt b/file.txt", "--- a/file.txt", @@ -207,7 +207,7 @@ struct DiffParserTests { // MARK: - Hunk header parsing - @Test func hunkHeaderWithFunctionContext() { + @Test func `hunk header with function context`() { let diff = [ "diff --git a/main.swift b/main.swift", "--- a/main.swift", @@ -226,7 +226,7 @@ struct DiffParserTests { // MARK: - File name extraction - @Test func fileNameExtractedFromMinusPlus() { + @Test func `file name extracted from minus plus`() { let diff = [ "diff --git a/src/app.swift b/src/app.swift", "--- a/src/app.swift", @@ -241,7 +241,7 @@ struct DiffParserTests { #expect(result.files[0].newName == "src/app.swift") } - @Test func fileNameFallsBackToGitHeader() { + @Test func `file name falls back to git header`() { let diff = [ "diff --git a/image.png b/image.png", "Binary files differ", @@ -255,7 +255,7 @@ struct DiffParserTests { // MARK: - Commit diff (new file from Forgejo API) - @Test func parseCommitDiffNewFile() { + @Test func `parse commit diff new file`() { let diff = [ "diff --git a/src/main.py b/src/main.py", "new file mode 100644", @@ -281,7 +281,7 @@ struct DiffParserTests { #expect(hunk.lines[1].oldLineNumber == nil) } - @Test func parseCommitDiffMultipleFiles() { + @Test func `parse commit diff multiple files`() { let diff = [ "diff --git a/hello.py b/hello.py", "new file mode 100644", @@ -315,7 +315,7 @@ struct DiffParserTests { // MARK: - No newline at end of file sentinel - @Test func noNewlineAtEndOfFileSentinelIsSkipped() { + @Test func `no newline at end of file sentinel is skipped`() { let diff = [ "diff --git a/file.txt b/file.txt", "--- a/file.txt", @@ -344,7 +344,7 @@ struct DiffParserTests { // MARK: - DiffLine identity - @Test func eachLineHasUniqueId() { + @Test func `each line has unique id`() { let diff = [ "diff --git a/f.txt b/f.txt", "--- a/f.txt", diff --git a/Tests/ForgejoKitTests/ErrorMappingTests.swift b/Tests/ForgejoKitTests/ErrorMappingTests.swift index f96c3ad..0288505 100644 --- a/Tests/ForgejoKitTests/ErrorMappingTests.swift +++ b/Tests/ForgejoKitTests/ErrorMappingTests.swift @@ -2,7 +2,7 @@ import Testing struct ErrorMappingTests { - @Test func classifiesHTTPStatusCodes() { + @Test func `classifies HTTP status codes`() { #expect(HTTPErrorCategory(statusCode: 401) == .authentication) #expect(HTTPErrorCategory(statusCode: 403) == .permissionDenied) #expect(HTTPErrorCategory(statusCode: 404) == .notFound) @@ -11,7 +11,7 @@ struct ErrorMappingTests { #expect(HTTPErrorCategory(statusCode: 400) == .other) } - @Test func exposesServiceErrorStatusAndCategory() { + @Test func `exposes service error status and category`() { let forbidden = ServiceError.httpError(statusCode: 403, message: "missing scope") #expect(forbidden.httpStatusCode == 403) #expect(forbidden.httpErrorCategory == .permissionDenied) @@ -24,7 +24,7 @@ struct ErrorMappingTests { #expect(ServiceError.invalidURL.httpErrorCategory == nil) } - @Test func mapsAuthenticationStatusCodes() { + @Test func `maps authentication status codes`() { #expect(AuthenticationError.from(statusCode: 401) == .invalidCredentials) #expect(AuthenticationError.from(statusCode: 401, body: "OTP required") == .otpRequired) #expect(AuthenticationError.from(statusCode: 403) == .unknownError(statusCode: 403)) @@ -33,7 +33,7 @@ struct ErrorMappingTests { #expect(AuthenticationError.from(statusCode: 418) == .unknownError(statusCode: 418)) } - @Test func exposesAuthenticationStatusAndCategory() { + @Test func `exposes authentication status and category`() { #expect(AuthenticationError.invalidCredentials.httpStatusCode == 401) #expect(AuthenticationError.invalidCredentials.httpErrorCategory == .authentication) #expect(AuthenticationError.serverNotFound.httpStatusCode == 404) @@ -47,7 +47,7 @@ struct ErrorMappingTests { // MARK: - ServiceError descriptions - @Test func serviceErrorDescriptions() { + @Test func `service error descriptions`() { #expect(ServiceError.noActiveInstance.errorDescription == "No active Forgejo instance") #expect(ServiceError.invalidURL.errorDescription == "Invalid URL") #expect(ServiceError.invalidResponse.errorDescription == "Invalid response from server") @@ -56,7 +56,7 @@ struct ErrorMappingTests { #expect(ServiceError.decodingFailed(detail: "missing key").errorDescription == "Decoding failed: missing key") } - @Test func httpErrorDescriptionUsesForgejoMessageBody() { + @Test func `http error description uses forgejo message body`() { let error = ServiceError.httpError( statusCode: 405, message: #"{"message":"This pull request has failing status checks"}"#, @@ -65,19 +65,19 @@ struct ErrorMappingTests { #expect(error.errorDescription == "HTTP 405: This pull request has failing status checks") } - @Test func httpErrorDescriptionFallsBackToRawBody() { + @Test func `http error description falls back to raw body`() { let error = ServiceError.httpError(statusCode: 500, message: "database unavailable") #expect(error.errorDescription == "HTTP 500: database unavailable") } - @Test func httpErrorDescriptionIgnoresWhitespaceOnlyMessage() { + @Test func `http error description ignores whitespace only message`() { let error = ServiceError.httpError(statusCode: 503, message: " \n ") #expect(error.errorDescription == "HTTP error: 503") } - @Test func httpErrorDescriptionFallsBackWhenJSONMessageFieldIsEmpty() { + @Test func `http error description falls back when JSON message field is empty`() { let error = ServiceError.httpError( statusCode: 422, message: #"{"message":" "}"#, @@ -86,7 +86,7 @@ struct ErrorMappingTests { #expect(error.errorDescription == "HTTP error: 422") } - @Test func httpErrorDescriptionReturnsRawJSONWhenMessageFieldMissing() { + @Test func `http error description returns raw JSON when message field missing`() { let raw = #"{"errors":["bad request"]}"# let error = ServiceError.httpError(statusCode: 400, message: raw) @@ -95,21 +95,21 @@ struct ErrorMappingTests { // MARK: - Merge error collapsing - @Test func collapseMergeErrorMaps405WithEmptyBodyToNotMergeable() { + @Test func `collapse merge error maps 405 with empty body to not mergeable`() { let collapsed = PullRequestService.collapseMergeError( .httpError(statusCode: 405, message: ""), ) #expect(collapsed == .notMergeable) } - @Test func collapseMergeErrorMaps409WithEmptyBodyToMergeConflict() { + @Test func `collapse merge error maps 409 with empty body to merge conflict`() { let collapsed = PullRequestService.collapseMergeError( .httpError(statusCode: 409, message: " \n "), ) #expect(collapsed == .mergeConflict) } - @Test func collapseMergeErrorPreserves405WithBody() { + @Test func `collapse merge error preserves 405 with body`() { let original = ServiceError.httpError( statusCode: 405, message: #"{"message":"failing status checks"}"#, @@ -117,17 +117,17 @@ struct ErrorMappingTests { #expect(PullRequestService.collapseMergeError(original) == original) } - @Test func collapseMergeErrorPreserves409WithBody() { + @Test func `collapse merge error preserves 409 with body`() { let original = ServiceError.httpError(statusCode: 409, message: "conflict in README.md") #expect(PullRequestService.collapseMergeError(original) == original) } - @Test func collapseMergeErrorPassesOtherStatusCodesThrough() { + @Test func `collapse merge error passes other status codes through`() { let original = ServiceError.httpError(statusCode: 500, message: "") #expect(PullRequestService.collapseMergeError(original) == original) } - @Test func collapseMergeErrorPassesNonHTTPErrorsThrough() { + @Test func `collapse merge error passes non HTTP errors through`() { #expect(PullRequestService.collapseMergeError(.invalidURL) == .invalidURL) #expect(PullRequestService.collapseMergeError(.noActiveInstance) == .noActiveInstance) } diff --git a/Tests/ForgejoKitTests/FileContentTests.swift b/Tests/ForgejoKitTests/FileContentTests.swift index 317d846..ad6c406 100644 --- a/Tests/ForgejoKitTests/FileContentTests.swift +++ b/Tests/ForgejoKitTests/FileContentTests.swift @@ -21,14 +21,14 @@ struct FileContentTests { // MARK: - Base64 decoding - @Test func decodesBase64Content() { + @Test func `decodes base 64 content`() { let original = "Hello, World!" let base64 = Data(original.utf8).base64EncodedString() let file = makeFileContent(content: base64, encoding: "base64") #expect(file.decodedContent == "Hello, World!") } - @Test func decodesBase64WithNewlines() { + @Test func `decodes base 64 with newlines`() { let original = "Line one\nLine two\nLine three" let base64 = Data(original.utf8).base64EncodedString() let wrappedBase64 = String(base64.prefix(20)) + "\n" + String(base64.dropFirst(20)) @@ -36,29 +36,29 @@ struct FileContentTests { #expect(file.decodedContent == original) } - @Test func returnsNilContentWhenContentIsNil() { + @Test func `returns nil content when content is nil`() { let file = makeFileContent(content: nil, encoding: "base64") #expect(file.decodedContent == nil) } - @Test func returnsRawContentWhenEncodingIsNil() { + @Test func `returns raw content when encoding is nil`() { let file = makeFileContent(content: "raw text", encoding: nil) #expect(file.decodedContent == "raw text") } - @Test func returnsRawContentWhenEncodingIsNotBase64() { + @Test func `returns raw content when encoding is not base 64`() { let file = makeFileContent(content: "raw text", encoding: "utf-8") #expect(file.decodedContent == "raw text") } - @Test func returnsNilForInvalidBase64() { + @Test func `returns nil for invalid base 64`() { let file = makeFileContent(content: "!!!not-base64!!!", encoding: "base64") #expect(file.decodedContent == nil) } // MARK: - JSON decoding - @Test func decodesFileContentFromJSON() throws { + @Test func `decodes file content from JSON`() throws { let json = """ { "name": "readme.md", diff --git a/Tests/ForgejoKitTests/ModelDecodingTests.swift b/Tests/ForgejoKitTests/ModelDecodingTests.swift index 8724804..86acc23 100644 --- a/Tests/ForgejoKitTests/ModelDecodingTests.swift +++ b/Tests/ForgejoKitTests/ModelDecodingTests.swift @@ -11,7 +11,7 @@ struct ModelDecodingTests { // MARK: - User - @Test func decodesUser() throws { + @Test func `decodes user`() throws { let json = """ { "id": 1, @@ -31,7 +31,7 @@ struct ModelDecodingTests { #expect(user.isAdmin == true) } - @Test func decodesUserWithMinimalFields() throws { + @Test func `decodes user with minimal fields`() throws { let json = """ { "id": 2, @@ -47,7 +47,7 @@ struct ModelDecodingTests { // MARK: - IssueLabel - @Test func decodesIssueLabel() throws { + @Test func `decodes issue label`() throws { let json = """ { "id": 10, @@ -64,7 +64,7 @@ struct ModelDecodingTests { // MARK: - IssueComment - @Test func decodesIssueComment() throws { + @Test func `decodes issue comment`() throws { let json = """ { "id": 100, @@ -82,7 +82,7 @@ struct ModelDecodingTests { // MARK: - Issue - @Test func decodesIssue() throws { + @Test func `decodes issue`() throws { let json = """ { "id": 50, @@ -107,7 +107,7 @@ struct ModelDecodingTests { #expect(issue.repository == nil) } - @Test func decodesIssueWithMilestoneAndAssignees() throws { + @Test func `decodes issue with milestone and assignees`() throws { let json = """ { "id": 56, @@ -147,7 +147,7 @@ struct ModelDecodingTests { #expect(issue.assignees?[1].login == "dev2") } - @Test func decodesIssueWithNilMilestoneAndEmptyAssignees() throws { + @Test func `decodes issue with nil milestone and empty assignees`() throws { let json = """ { "id": 57, @@ -168,7 +168,7 @@ struct ModelDecodingTests { #expect(issue.assignees?.isEmpty == true) } - @Test func decodesIssueMilestone() throws { + @Test func `decodes issue milestone`() throws { let json = """ { "id": 1, @@ -188,7 +188,7 @@ struct ModelDecodingTests { #expect(milestone.closedAt == nil) } - @Test func decodesIssueMilestoneWithMinimalFields() throws { + @Test func `decodes issue milestone with minimal fields`() throws { let json = """ { "id": 2, @@ -204,7 +204,7 @@ struct ModelDecodingTests { #expect(milestone.closedAt == nil) } - @Test func decodesIssueWithMinimalRepository() throws { + @Test func `decodes issue with minimal repository`() throws { let json = """ { "id": 52, @@ -233,7 +233,7 @@ struct ModelDecodingTests { #expect(issue.repository?.defaultBranch == nil) } - @Test func decodesIssueWithRepository() throws { + @Test func `decodes issue with repository`() throws { let json = """ { "id": 51, @@ -284,7 +284,7 @@ struct ModelDecodingTests { #expect(issue.repository?.fullName == "user/my-repo") } - @Test func decodesIssueWithMergedPullRequestField() throws { + @Test func `decodes issue with merged pull request field`() throws { let json = """ { "id": 53, @@ -315,7 +315,7 @@ struct ModelDecodingTests { #expect(issue.repository?.fullName == "org/app") } - @Test func decodesIssueWithOpenPullRequestField() throws { + @Test func `decodes issue with open pull request field`() throws { let json = """ { "id": 54, @@ -346,7 +346,7 @@ struct ModelDecodingTests { #expect(issue.comments == 0) } - @Test func decodesIssueWithMinimalPullRequestField() throws { + @Test func `decodes issue with minimal pull request field`() throws { let json = """ { "id": 55, @@ -371,7 +371,7 @@ struct ModelDecodingTests { // MARK: - PullRequest - @Test func decodesPullRequest() throws { + @Test func `decodes pull request`() throws { let json = """ { "id": 200, @@ -409,7 +409,7 @@ struct ModelDecodingTests { #expect(pullRequest.draft == false) } - @Test func decodesPullRequestWithFullMetadata() throws { + @Test func `decodes pull request with full metadata`() throws { let json = """ { "id": 204, @@ -460,7 +460,7 @@ struct ModelDecodingTests { #expect(pullRequest.requestedReviewers?[0].login == "reviewer1") } - @Test func decodesPullRequestWithRequestedReviewers() throws { + @Test func `decodes pull request with requested reviewers`() throws { let json = """ { "id": 202, @@ -490,7 +490,7 @@ struct ModelDecodingTests { #expect(pullRequest.requestedReviewers?[1].fullName == "Reviewer Two") } - @Test func decodesPullRequestWithoutRequestedReviewers() throws { + @Test func `decodes pull request without requested reviewers`() throws { let json = """ { "id": 203, @@ -511,7 +511,7 @@ struct ModelDecodingTests { #expect(pullRequest.requestedReviewers == nil) } - @Test func decodesMergedPullRequest() throws { + @Test func `decodes merged pull request`() throws { let json = """ { "id": 201, @@ -538,7 +538,7 @@ struct ModelDecodingTests { // MARK: - PullRequestReview - @Test func decodesPullRequestReview() throws { + @Test func `decodes pull request review`() throws { let json = """ { "id": 300, @@ -562,7 +562,7 @@ struct ModelDecodingTests { #expect(review.official == true) } - @Test func decodesReviewWithNullableFields() throws { + @Test func `decodes review with nullable fields`() throws { let json = """ { "id": 301, @@ -578,7 +578,7 @@ struct ModelDecodingTests { // MARK: - ReviewComment - @Test func decodesReviewComment() throws { + @Test func `decodes review comment`() throws { let json = """ { "id": 400, @@ -601,7 +601,7 @@ struct ModelDecodingTests { // MARK: - CreateReviewComment encoding - @Test func encodesCreateReviewComment() throws { + @Test func `encodes create review comment`() throws { let comment = CreateReviewComment( body: "Fix this", path: "file.swift", @@ -616,7 +616,7 @@ struct ModelDecodingTests { #expect(dict["new_position"] as? Int == 7) } - @Test func encodesCreateReviewCommentWithNilPositions() throws { + @Test func `encodes create review comment with nil positions`() throws { let comment = CreateReviewComment( body: "Note", path: "readme.md", @@ -631,7 +631,7 @@ struct ModelDecodingTests { // MARK: - Repository - @Test func decodesRepository() throws { + @Test func `decodes repository`() throws { let json = """ { "id": 1, @@ -677,7 +677,7 @@ struct ModelDecodingTests { #expect(repo.updatedAt != nil) } - @Test func decodesRepositoryWithMinimalFields() throws { + @Test func `decodes repository with minimal fields`() throws { let json = """ { "id": 10, @@ -713,7 +713,7 @@ struct ModelDecodingTests { #expect(repo.template == nil) } - @Test func decodesRepositoryWithPartialFields() throws { + @Test func `decodes repository with partial fields`() throws { let json = """ { "id": 11, @@ -738,7 +738,7 @@ struct ModelDecodingTests { // MARK: - NotificationThread - @Test func decodesNotificationThread() throws { + @Test func `decodes notification thread`() throws { let json = """ { "id": 1, @@ -775,7 +775,7 @@ struct ModelDecodingTests { #expect(notification.repository.fullName == "org/app") } - @Test func decodesNotificationSubjectTypes() throws { + @Test func `decodes notification subject types`() throws { let issueJson = """ { "id": 2, @@ -832,7 +832,7 @@ struct ModelDecodingTests { #expect(commit.subject.state == nil) } - @Test func decodesNotificationCount() throws { + @Test func `decodes notification count`() throws { let json = """ {"new": 5} """ @@ -840,7 +840,7 @@ struct ModelDecodingTests { #expect(count.new == 5) } - @Test func decodesNotificationWithMinimalFields() throws { + @Test func `decodes notification with minimal fields`() throws { let json = """ { "id": 10, @@ -867,7 +867,7 @@ struct ModelDecodingTests { // MARK: - Commit - @Test func decodesCommit() throws { + @Test func `decodes commit`() throws { let json = """ { "sha": "abc123def456", @@ -913,7 +913,7 @@ struct ModelDecodingTests { #expect(commit.id == "abc123def456") } - @Test func decodesCommitWithMinimalFields() throws { + @Test func `decodes commit with minimal fields`() throws { let json = """ { "sha": "minimal123", @@ -934,7 +934,7 @@ struct ModelDecodingTests { #expect(commit.url == nil) } - @Test func decodesCommitWithNullAuthor() throws { + @Test func `decodes commit with null author`() throws { let json = """ { "sha": "noauthor456", @@ -959,7 +959,7 @@ struct ModelDecodingTests { #expect(commit.parents?.isEmpty == true) } - @Test func decodesCommitList() throws { + @Test func `decodes commit list`() throws { let json = """ [ { @@ -983,7 +983,7 @@ struct ModelDecodingTests { // MARK: - RepositoryContent - @Test func decodesRepositoryContent() throws { + @Test func `decodes repository content`() throws { let json = """ { "name": "README.md", @@ -1002,7 +1002,7 @@ struct ModelDecodingTests { #expect(content.id == "README.md") } - @Test func decodesDirectoryContent() throws { + @Test func `decodes directory content`() throws { let json = """ { "name": "src", @@ -1021,7 +1021,7 @@ struct ModelDecodingTests { // MARK: - Branch - @Test func decodesBranch() throws { + @Test func `decodes branch`() throws { let json = """ { "name": "main", @@ -1042,7 +1042,7 @@ struct ModelDecodingTests { // MARK: - Repository Equatable / Hashable - @Test func repositoriesWithSameIdAreEqual() throws { + @Test func `repositories with same id are equal`() throws { let json1 = """ {"id": 42, "name": "repo-a", "full_name": "owner/repo-a", "owner": {"id": 1, "login": "owner"}, "html_url": "https://example.com/owner/repo-a", "description": "", "empty": false, "private": false, "fork": false, "mirror": false, "archived": false, "stars_count": 0, "forks_count": 0, "open_issues_count": 0, "open_pr_counter": 0, "has_issues": true, "has_pull_requests": true, "default_branch": "main", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z"} """ @@ -1055,7 +1055,7 @@ struct ModelDecodingTests { #expect(repo1.hashValue == repo2.hashValue) } - @Test func repositoriesWithDifferentIdsAreNotEqual() throws { + @Test func `repositories with different ids are not equal`() throws { let json1 = """ {"id": 1, "name": "repo", "full_name": "owner/repo", "owner": {"id": 1, "login": "owner"}, "html_url": "https://example.com/owner/repo", "description": "", "empty": false, "private": false, "fork": false, "mirror": false, "archived": false, "stars_count": 0, "forks_count": 0, "open_issues_count": 0, "open_pr_counter": 0, "has_issues": true, "has_pull_requests": true, "default_branch": "main", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z"} """ @@ -1069,7 +1069,7 @@ struct ModelDecodingTests { // MARK: - RepositoryPermissions - @Test func decodesRepositoryWithFullPermissions() throws { + @Test func `decodes repository with full permissions`() throws { let json = """ { "id": 20, @@ -1088,7 +1088,7 @@ struct ModelDecodingTests { #expect(repo.permissions?.pull == true) } - @Test func decodesRepositoryWithReadOnlyPermissions() throws { + @Test func `decodes repository with read only permissions`() throws { let json = """ { "id": 21, @@ -1107,7 +1107,7 @@ struct ModelDecodingTests { #expect(repo.permissions?.pull == true) } - @Test func decodesRepositoryWithMissingPermissions() throws { + @Test func `decodes repository with missing permissions`() throws { let json = """ { "id": 22, @@ -1121,7 +1121,7 @@ struct ModelDecodingTests { // MARK: - Token - @Test func decodesToken() throws { + @Test func `decodes token`() throws { let json = """ { "id": 1, @@ -1137,7 +1137,7 @@ struct ModelDecodingTests { #expect(token.tokenLastEight == "56789abc") } - @Test func decodesTokenWithMinimalFields() throws { + @Test func `decodes token with minimal fields`() throws { let json = """ { "id": 2, @@ -1154,7 +1154,7 @@ struct ModelDecodingTests { // MARK: - WorkflowRun (Forgejo's ActionRun schema) - @Test func decodesWorkflowRunWithFullFields() throws { + @Test func `decodes workflow run with full fields`() throws { let json = """ { "id": 100, @@ -1194,7 +1194,7 @@ struct ModelDecodingTests { #expect(run.durationNanos == 240_000_000_000) } - @Test func decodesWorkflowRunWithMinimalFields() throws { + @Test func `decodes workflow run with minimal fields`() throws { // Minimum the Forgejo API guarantees for an in-flight run: id and status. let json = """ { @@ -1212,7 +1212,7 @@ struct ModelDecodingTests { // MARK: - WorkflowRunView (experimental web-route response) - @Test func decodesWorkflowRunView() throws { + @Test func `decodes workflow run view`() throws { let json = """ { "state": { @@ -1257,7 +1257,7 @@ struct ModelDecodingTests { #expect(view.logs.stepsLog.first?.lines.first?.message == "hello") } - @Test func decodesWorkflowRunViewWithEmptyLogs() throws { + @Test func `decodes workflow run view with empty logs`() throws { let json = """ { "state": { @@ -1284,7 +1284,7 @@ struct ModelDecodingTests { // MARK: - DecodingError description - @Test func decodingErrorAtRootShowsRoot() { + @Test func `decoding error at root shows root`() { // Decoding a String where an object is expected produces a root-level error let json = #""not-an-object""# do { diff --git a/Tests/ForgejoKitTests/NormalizeServerURLTests.swift b/Tests/ForgejoKitTests/NormalizeServerURLTests.swift index 8f2c33b..4bd0cda 100644 --- a/Tests/ForgejoKitTests/NormalizeServerURLTests.swift +++ b/Tests/ForgejoKitTests/NormalizeServerURLTests.swift @@ -3,47 +3,47 @@ import Foundation import Testing struct NormalizeServerURLTests { - @Test func stripsTrailingSlash() { + @Test func `strips trailing slash`() { let result = ForgejoClient.normalizeServerURL("https://forgejo.example.com/") #expect(result == "https://forgejo.example.com") } - @Test func addsHttpsWhenNoScheme() { + @Test func `adds https when no scheme`() { let result = ForgejoClient.normalizeServerURL("forgejo.example.com") #expect(result == "https://forgejo.example.com") } - @Test func preservesHttpScheme() { + @Test func `preserves http scheme`() { let result = ForgejoClient.normalizeServerURL("http://localhost:3000") #expect(result == "http://localhost:3000") } - @Test func preservesHttpsScheme() { + @Test func `preserves https scheme`() { let result = ForgejoClient.normalizeServerURL("https://forgejo.example.com") #expect(result == "https://forgejo.example.com") } - @Test func trimsWhitespace() { + @Test func `trims whitespace`() { let result = ForgejoClient.normalizeServerURL(" https://forgejo.example.com ") #expect(result == "https://forgejo.example.com") } - @Test func handlesTrailingSlashAndWhitespace() { + @Test func `handles trailing slash and whitespace`() { let result = ForgejoClient.normalizeServerURL(" forgejo.example.com/ ") #expect(result == "https://forgejo.example.com") } - @Test func preservesPort() { + @Test func `preserves port`() { let result = ForgejoClient.normalizeServerURL("https://forgejo.example.com:3000") #expect(result == "https://forgejo.example.com:3000") } - @Test func preservesPath() { + @Test func `preserves path`() { let result = ForgejoClient.normalizeServerURL("https://example.com/forgejo") #expect(result == "https://example.com/forgejo") } - @Test func stripsMultipleTrailingSlashes() { + @Test func `strips multiple trailing slashes`() { let result = ForgejoClient.normalizeServerURL("https://example.com///") #expect(result == "https://example.com") } diff --git a/Tests/ForgejoKitTests/NotificationTests.swift b/Tests/ForgejoKitTests/NotificationTests.swift index a3af03c..bb81cb9 100644 --- a/Tests/ForgejoKitTests/NotificationTests.swift +++ b/Tests/ForgejoKitTests/NotificationTests.swift @@ -11,61 +11,61 @@ struct NotificationTests { // MARK: - Subject number extraction - @Test func extractsIssueNumberFromSubjectUrl() { + @Test func `extracts issue number from subject url`() { let url = "https://forgejo.example.com/api/v1/repos/org/app/issues/42" #expect(notificationSubjectNumber(from: url) == 42) } - @Test func extractsPullRequestNumberFromSubjectUrl() { + @Test func `extracts pull request number from subject url`() { let url = "https://forgejo.example.com/api/v1/repos/user/repo/pulls/7" #expect(notificationSubjectNumber(from: url) == 7) } - @Test func returnsNilForNilUrl() { + @Test func `returns nil for nil url`() { #expect(notificationSubjectNumber(from: nil) == nil) } - @Test func returnsNilForNonNumericLastComponent() { + @Test func `returns nil for non numeric last component`() { let url = "https://forgejo.example.com/api/v1/repos/org/app/commits/abc123" #expect(notificationSubjectNumber(from: url) == nil) } - @Test func returnsNilForEmptyString() { + @Test func `returns nil for empty string`() { #expect(notificationSubjectNumber(from: "") == nil) } - @Test func returnsNilForInvalidUrl() { + @Test func `returns nil for invalid url`() { #expect(notificationSubjectNumber(from: "not a url at all %%%") == nil) } - @Test func extractsNumberFromUrlWithTrailingSlash() { + @Test func `extracts number from url with trailing slash`() { let url = "https://forgejo.example.com/api/v1/repos/org/app/issues/42/" #expect(notificationSubjectNumber(from: url) == 42) } - @Test func extractsLargeNumber() { + @Test func `extracts large number`() { let url = "https://forgejo.example.com/api/v1/repos/org/app/issues/99999" #expect(notificationSubjectNumber(from: url) == 99999) } - @Test func extractsNumberFromUrlWithQueryParams() { + @Test func `extracts number from url with query params`() { let url = "https://forgejo.example.com/api/v1/repos/org/app/issues/42?foo=bar&baz=1" #expect(notificationSubjectNumber(from: url) == 42) } - @Test func extractsNumberFromUrlWithFragment() { + @Test func `extracts number from url with fragment`() { let url = "https://forgejo.example.com/api/v1/repos/org/app/pulls/7#section" #expect(notificationSubjectNumber(from: url) == 7) } - @Test func extractsNumberFromUrlWithQueryAndFragment() { + @Test func `extracts number from url with query and fragment`() { let url = "https://forgejo.example.com/api/v1/repos/org/app/issues/15?ref=main#comment-1" #expect(notificationSubjectNumber(from: url) == 15) } // MARK: - Full API response decoding - @Test func decodesNotificationArray() throws { + @Test func `decodes notification array`() throws { let json = """ [ { @@ -106,19 +106,19 @@ struct NotificationTests { #expect(notifications[1].subject.type == "Pull") } - @Test func decodesEmptyNotificationArray() throws { + @Test func `decodes empty notification array`() throws { let json = "[]" let notifications = try decoder().decode([NotificationThread].self, from: Data(json.utf8)) #expect(notifications.isEmpty) } - @Test func decodesNotificationCountZero() throws { + @Test func `decodes notification count zero`() throws { let json = #"{"new": 0}"# let count = try decoder().decode(NotificationCount.self, from: Data(json.utf8)) #expect(count.new == 0) } - @Test func decodesNotificationCountLargeValue() throws { + @Test func `decodes notification count large value`() throws { let json = #"{"new": 9999}"# let count = try decoder().decode(NotificationCount.self, from: Data(json.utf8)) #expect(count.new == 9999) @@ -126,7 +126,7 @@ struct NotificationTests { // MARK: - Date handling in notifications - @Test func decodesNotificationWithFractionalSecondsDate() throws { + @Test func `decodes notification with fractional seconds date`() throws { let json = """ { "id": 5, @@ -144,7 +144,7 @@ struct NotificationTests { // MARK: - Extra/unknown fields are ignored - @Test func decodesNotificationIgnoringExtraFields() throws { + @Test func `decodes notification ignoring extra fields`() throws { let json = """ { "id": 6, @@ -167,7 +167,7 @@ struct NotificationTests { // MARK: - Required fields validation - @Test func failsDecodingNotificationMissingSubject() { + @Test func `fails decoding notification missing subject`() { let json = """ { "id": 7, @@ -182,7 +182,7 @@ struct NotificationTests { } } - @Test func failsDecodingNotificationMissingRepository() { + @Test func `fails decoding notification missing repository`() { let json = """ { "id": 8, @@ -197,7 +197,7 @@ struct NotificationTests { } } - @Test func failsDecodingNotificationMissingId() { + @Test func `fails decoding notification missing id`() { let json = """ { "unread": true, @@ -212,14 +212,14 @@ struct NotificationTests { } } - @Test func failsDecodingNotificationCountMissingNewField() { + @Test func `fails decoding notification count missing new field`() { let json = #"{}"# #expect(throws: DecodingError.self) { _ = try decoder().decode(NotificationCount.self, from: Data(json.utf8)) } } - @Test func failsDecodingSubjectMissingTitle() { + @Test func `fails decoding subject missing title`() { let json = """ { "id": 9, diff --git a/Tests/ForgejoKitTests/RepositoryServiceURLTests.swift b/Tests/ForgejoKitTests/RepositoryServiceURLTests.swift index ad0ca84..ba51610 100644 --- a/Tests/ForgejoKitTests/RepositoryServiceURLTests.swift +++ b/Tests/ForgejoKitTests/RepositoryServiceURLTests.swift @@ -10,20 +10,20 @@ struct RepositoryServiceURLTests { // MARK: - fetchContents URL construction - @Test func makeURLContentsWithoutRef() throws { + @Test func `make URL contents without ref`() throws { let client = makeClient() let url = try client.makeURL(path: "/api/v1/repos/owner/repo/contents") #expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo/contents") } - @Test func makeURLContentsWithRef() throws { + @Test func `make URL contents with ref`() throws { let client = makeClient() let queryItems = [URLQueryItem(name: "ref", value: "develop")] let url = try client.makeURL(path: "/api/v1/repos/owner/repo/contents", queryItems: queryItems) #expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo/contents?ref=develop") } - @Test func makeURLContentsWithRefAndSubpath() throws { + @Test func `make URL contents with ref and subpath`() throws { let client = makeClient() let queryItems = [URLQueryItem(name: "ref", value: "feature/login")] let url = try client.makeURL(path: "/api/v1/repos/owner/repo/contents/src/main.py", queryItems: queryItems) @@ -31,14 +31,14 @@ struct RepositoryServiceURLTests { #expect(url.path == "/api/v1/repos/owner/repo/contents/src/main.py") } - @Test func makeURLFileContentWithRef() throws { + @Test func `make URL file content with ref`() throws { let client = makeClient() let queryItems = [URLQueryItem(name: "ref", value: "v1.0")] let url = try client.makeURL(path: "/api/v1/repos/owner/repo/contents/README.md", queryItems: queryItems) #expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo/contents/README.md?ref=v1.0") } - @Test func makeURLWithEmptyQueryItems() throws { + @Test func `make URL with empty query items`() throws { let client = makeClient() let url = try client.makeURL(path: "/api/v1/repos/owner/repo/contents", queryItems: []) #expect(url.query == nil) @@ -46,13 +46,13 @@ struct RepositoryServiceURLTests { // MARK: - ref parameter nil-coalescence (same pattern used by fetchContents and fetchFileContent) - @Test func optionalRefProducesEmptyQueryItems() { + @Test func `optional ref produces empty query items`() { let ref: String? = nil let queryItems = ref.map { [URLQueryItem(name: "ref", value: $0)] } ?? [] #expect(queryItems.isEmpty) } - @Test func presentRefProducesQueryItem() { + @Test func `present ref produces query item`() { let ref: String? = "main" let queryItems = ref.map { [URLQueryItem(name: "ref", value: $0)] } ?? [] #expect(queryItems.count == 1) @@ -62,7 +62,7 @@ struct RepositoryServiceURLTests { // MARK: - createRepository URL - @Test func createRepositoryURL() throws { + @Test func `create repository URL`() throws { let client = makeClient() let url = try client.makeURL(path: "/api/v1/user/repos") #expect(url.absoluteString == "https://forgejo.example.com/api/v1/user/repos") @@ -70,7 +70,7 @@ struct RepositoryServiceURLTests { // MARK: - editRepository URL - @Test func editRepositoryURL() throws { + @Test func `edit repository URL`() throws { let client = makeClient() let url = try client.makeRepoURL(owner: "owner", repo: "repo", path: "") #expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo") @@ -78,7 +78,7 @@ struct RepositoryServiceURLTests { // MARK: - createLabel URL - @Test func createLabelURL() throws { + @Test func `create label URL`() throws { let client = makeClient() let url = try client.makeRepoURL(owner: "owner", repo: "repo", path: "/labels") #expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo/labels") @@ -86,7 +86,7 @@ struct RepositoryServiceURLTests { // MARK: - createMilestone URL - @Test func createMilestoneURL() throws { + @Test func `create milestone URL`() throws { let client = makeClient() let url = try client.makeRepoURL(owner: "owner", repo: "repo", path: "/milestones") #expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo/milestones") @@ -94,7 +94,7 @@ struct RepositoryServiceURLTests { // MARK: - addCollaborator URL - @Test func addCollaboratorURL() throws { + @Test func `add collaborator URL`() throws { let client = makeClient() let encoded = ForgejoClient.encodedPathSegment("testbot") let url = try client.makeRepoURL(owner: "owner", repo: "repo", path: "/collaborators/\(encoded)") @@ -103,7 +103,7 @@ struct RepositoryServiceURLTests { // MARK: - createFile URL - @Test func createFileURL() throws { + @Test func `create file URL`() throws { let client = makeClient() let url = try client.makeRepoURL(owner: "owner", repo: "repo", path: "/contents/hello.py") #expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo/contents/hello.py") @@ -111,7 +111,7 @@ struct RepositoryServiceURLTests { // MARK: - Payload encoding - @Test func createRepositoryPayloadEncoding() throws { + @Test func `create repository payload encoding`() throws { let payload: [String: Any] = [ "name": "test-repo", "description": "A test repo", @@ -125,7 +125,7 @@ struct RepositoryServiceURLTests { #expect(dict["private"] as? Bool == false) } - @Test func createFilePayloadEncoding() throws { + @Test func `create file payload encoding`() throws { let payload: [String: Any] = [ "content": "base64content", "message": "Add file", diff --git a/Tests/ForgejoKitTests/URLSessionManagerTests.swift b/Tests/ForgejoKitTests/URLSessionManagerTests.swift index 9106d53..c151384 100644 --- a/Tests/ForgejoKitTests/URLSessionManagerTests.swift +++ b/Tests/ForgejoKitTests/URLSessionManagerTests.swift @@ -5,22 +5,22 @@ import Testing struct URLSessionManagerTests { // MARK: - trustedHost scoping - @Test func selfSignedWithoutTrustedHostAcceptsAny() { + @Test func `self signed without trusted host accepts any`() { let manager = URLSessionManager(allowSelfSignedCertificates: true, trustedHost: nil) #expect(manager.allowSelfSignedCertificates == true) } - @Test func selfSignedWithTrustedHostIsSet() { + @Test func `self signed with trusted host is set`() { let manager = URLSessionManager(allowSelfSignedCertificates: true, trustedHost: "forgejo.example.com") #expect(manager.allowSelfSignedCertificates == true) } - @Test func selfSignedDisabledByDefault() { + @Test func `self signed disabled by default`() { let manager = URLSessionManager() #expect(manager.allowSelfSignedCertificates == false) } - @Test func sessionIsCreatedWithConfiguration() { + @Test func `session is created with configuration`() { let manager = URLSessionManager(allowSelfSignedCertificates: false) let session = manager.session #expect(session.configuration.timeoutIntervalForRequest == 30) @@ -29,31 +29,31 @@ struct URLSessionManagerTests { // MARK: - Trust disposition logic - @Test func selfSignedDisabledReturnsDefaultHandling() { + @Test func `self signed disabled returns default handling`() { let manager = URLSessionManager(allowSelfSignedCertificates: false) let disposition = manager.trustDisposition(for: "forgejo.example.com") #expect(disposition == .performDefaultHandling) } - @Test func selfSignedEnabledWithNilTrustedHostReturnsDefaultHandling() { + @Test func `self signed enabled with nil trusted host returns default handling`() { let manager = URLSessionManager(allowSelfSignedCertificates: true, trustedHost: nil) let disposition = manager.trustDisposition(for: "forgejo.example.com") #expect(disposition == .performDefaultHandling) } - @Test func selfSignedEnabledWithMatchingHostReturnsUseCredential() { + @Test func `self signed enabled with matching host returns use credential`() { let manager = URLSessionManager(allowSelfSignedCertificates: true, trustedHost: "forgejo.example.com") let disposition = manager.trustDisposition(for: "forgejo.example.com") #expect(disposition == .useCredential) } - @Test func selfSignedEnabledWithMismatchedHostReturnsDefaultHandling() { + @Test func `self signed enabled with mismatched host returns default handling`() { let manager = URLSessionManager(allowSelfSignedCertificates: true, trustedHost: "forgejo.example.com") let disposition = manager.trustDisposition(for: "evil.example.com") #expect(disposition == .performDefaultHandling) } - @Test func selfSignedTrustMatchesCaseInsensitive() { + @Test func `self signed trust matches case insensitive`() { let manager = URLSessionManager(allowSelfSignedCertificates: true, trustedHost: "Forgejo.Example.COM") let disposition = manager.trustDisposition(for: "forgejo.example.com") #expect(disposition == .useCredential) @@ -63,7 +63,7 @@ struct URLSessionManagerTests { struct ForgejoClientHostMatchTests { // MARK: - Auth header host matching - @Test func authenticatedRequestIncludesAuthForMatchingHost() throws { + @Test func `authenticated request includes auth for matching host`() throws { let client = ForgejoClient( serverURL: "https://forgejo.example.com", username: "user", @@ -74,7 +74,7 @@ struct ForgejoClientHostMatchTests { #expect(request.value(forHTTPHeaderField: "Authorization") != nil) } - @Test func authenticatedRequestOmitsAuthForDifferentHost() throws { + @Test func `authenticated request omits auth for different host`() throws { let client = ForgejoClient( serverURL: "https://forgejo.example.com", username: "user", @@ -85,7 +85,7 @@ struct ForgejoClientHostMatchTests { #expect(request.value(forHTTPHeaderField: "Authorization") == nil) } - @Test func authenticatedRequestMatchesCaseInsensitiveHost() throws { + @Test func `authenticated request matches case insensitive host`() throws { let client = ForgejoClient( serverURL: "https://Forgejo.Example.COM", username: "user", @@ -96,7 +96,7 @@ struct ForgejoClientHostMatchTests { #expect(request.value(forHTTPHeaderField: "Authorization") != nil) } - @Test func authenticatedRequestSetsMethodAndBody() throws { + @Test func `authenticated request sets method and body`() throws { let client = ForgejoClient( serverURL: "https://forgejo.example.com", username: "user", @@ -110,7 +110,7 @@ struct ForgejoClientHostMatchTests { #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") } - @Test func makeURLConstructsValidURL() throws { + @Test func `make URL constructs valid URL`() throws { let client = ForgejoClient( serverURL: "https://forgejo.example.com", username: "user", @@ -120,7 +120,7 @@ struct ForgejoClientHostMatchTests { #expect(url.absoluteString == "https://forgejo.example.com/api/v1/user") } - @Test func makeURLWithQueryItems() throws { + @Test func `make URL with query items`() throws { let client = ForgejoClient( serverURL: "https://forgejo.example.com", username: "user", @@ -134,7 +134,7 @@ struct ForgejoClientHostMatchTests { #expect(url.absoluteString.contains("page=2")) } - @Test func clientExtractsHostForTrustedCert() { + @Test func `client extracts host for trusted cert`() { // Verify that the client passes the right host to URLSessionManager let client = ForgejoClient( serverURL: "https://my-forgejo.local:3000", diff --git a/Tests/ForgejoKitTests/UserServiceTests.swift b/Tests/ForgejoKitTests/UserServiceTests.swift index 74c340a..518706a 100644 --- a/Tests/ForgejoKitTests/UserServiceTests.swift +++ b/Tests/ForgejoKitTests/UserServiceTests.swift @@ -3,7 +3,7 @@ import Foundation import Testing struct UserServiceTests { - @Test func createTokenPayloadEncodesCorrectKeys() throws { + @Test func `create token payload encodes correct keys`() throws { let payload: [String: Any] = [ "name": "integration-test-token", "scopes": ["read:user", "write:user"], @@ -14,13 +14,13 @@ struct UserServiceTests { #expect((dict["scopes"] as? [String])?.count == 2) } - @Test func createTokenURLConstruction() throws { + @Test func `create token URL construction`() throws { let client = ForgejoClient(serverURL: "https://forgejo.example.com", username: "admin", password: "pass") let url = try client.makeURL(path: "/api/v1/users/\(ForgejoClient.encodedPathSegment("testuser"))/tokens") #expect(url.absoluteString == "https://forgejo.example.com/api/v1/users/testuser/tokens") } - @Test func createTokenURLEncodesSpecialCharacters() throws { + @Test func `create token URL encodes special characters`() throws { let client = ForgejoClient(serverURL: "https://forgejo.example.com", username: "admin", password: "pass") let encoded = ForgejoClient.encodedPathSegment("user name") let url = try client.makeURL(path: "/api/v1/users/\(encoded)/tokens") diff --git a/Tests/ForgejoKitTests/WorkflowServiceURLTests.swift b/Tests/ForgejoKitTests/WorkflowServiceURLTests.swift index 5b3c859..208f03d 100644 --- a/Tests/ForgejoKitTests/WorkflowServiceURLTests.swift +++ b/Tests/ForgejoKitTests/WorkflowServiceURLTests.swift @@ -7,7 +7,7 @@ struct WorkflowServiceURLTests { ForgejoClient(serverURL: "https://forgejo.example.com", username: "user", password: "pass") } - @Test func runsListURLNoFilters() throws { + @Test func `runs list URL no filters`() throws { let client = makeClient() let queryItems = [ URLQueryItem(name: "page", value: "1"), @@ -22,7 +22,7 @@ struct WorkflowServiceURLTests { #expect(url.query?.contains("limit=20") == true) } - @Test func runsListURLAllFilters() throws { + @Test func `runs list URL all filters`() throws { let client = makeClient() let queryItems = [ URLQueryItem(name: "page", value: "2"), @@ -46,13 +46,13 @@ struct WorkflowServiceURLTests { #expect(url.query?.contains("head_sha=abc123") == true) } - @Test func runDetailURL() throws { + @Test func `run detail URL`() throws { let client = makeClient() let url = try client.makeRepoURL(owner: "owner", repo: "repo", path: "/actions/runs/123") #expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo/actions/runs/123") } - @Test func ownerAndRepoArePercentEncoded() throws { + @Test func `owner and repo are percent encoded`() throws { let client = makeClient() let url = try client.makeRepoURL(owner: "my org", repo: "my repo", path: "/actions/runs") #expect(url.absoluteString.contains("my%20org")) @@ -61,13 +61,13 @@ struct WorkflowServiceURLTests { // MARK: - Experimental run-view URL (web routes, not /api/v1) - @Test func runViewURLIsUnderRepoWebRoute() throws { + @Test func `run view URL is under repo web route`() throws { let client = makeClient() let url = try client.makeURL(path: "/owner/repo/actions/runs/12/jobs/0") #expect(url.absoluteString == "https://forgejo.example.com/owner/repo/actions/runs/12/jobs/0") } - @Test func runViewURLEncodesOwnerAndRepo() throws { + @Test func `run view URL encodes owner and repo`() throws { let client = makeClient() let owner = ForgejoClient.encodedPathSegment("my org") let repo = ForgejoClient.encodedPathSegment("my repo")