Compare commits

..

13 commits
0.5.0 ... main

Author SHA1 Message Date
Stefan Hausotte
7675312f5c release 0.8.1 2026-06-15 18:44:16 +02:00
Stefan Hausotte
1ad2ee2607 fix: report security-key-blocked Basic auth instead of invalid credentials
Forgejo rejects username/password (Basic auth) for accounts with a
security key / passkey (WebAuthn) enrolled, responding 401 with
"Basic authorization is not allowed while having security keys enrolled".
This was misclassified as .invalidCredentials, showing "Invalid username
or password" and sending users in circles. Add a dedicated
.basicAuthBlockedBySecurityKey case that tells the user to use a token.

Also extract AuthenticationError into its own file.
2026-06-15 18:43:55 +02:00
Stefan Hausotte
485c147e7f release 0.8.0 2026-06-15 12:06:48 +02:00
Stefan Hausotte
81c74f6dc3 chore: formatting and function names 2026-06-15 12:06:27 +02:00
Stefan Hausotte
b78c57ffd9 feat: add Attachment model and image upload support
- Add Attachment model with isImage computed property
- Add assets field to Issue, IssueComment, and PullRequest
- Add multipart/form-data upload support to ForgejoClient
- Add uploadIssueAttachment and uploadCommentAttachment to IssueService
2026-06-15 09:48:47 +02:00
Stefan Hausotte
4d1dfc1305 release 0.7.0 2026-06-04 11:29:39 +02:00
Stefan Hausotte
167d61bc06 chore: improved formatting and linting 2026-06-04 11:29:23 +02:00
systemblue
551e1d01ea feat: add .bearer credential for OAuth access tokens (#4) 2026-06-04 11:15:25 +02:00
Stefan Hausotte
7352bc4b1f release 0.6.1 2026-05-17 21:26:30 +02:00
Stefan Hausotte
03829084c8 refactor: small code refactoring 2026-05-17 21:26:19 +02:00
pdurlej
81d8bc5b4a fix: preserve merge error context (#3)
Problem
- Merge failures currently collapse 405 and 409 responses to generic ServiceError cases, even when Forgejo returns a useful response body.

Change
- Keeps the existing notMergeable and mergeConflict fallbacks for empty responses.
- Preserves non-empty Forgejo response bodies as ServiceError.httpError for merge failures.
- Extracts the common JSON message field for clearer LocalizedError descriptions.

Tests
- DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test

Co-authored-by: Piotr Durlej <pdurlej@users.noreply.github.com>
Reviewed-on: https://codeberg.org/secana/ForgejoKit/pulls/3
2026-05-17 21:19:39 +02:00
Stefan Hausotte
2ab5808c34 release 0.6.0 2026-05-12 17:42:44 +02:00
pdurlej
8a5e513c50 feat: classify auth and service errors (#2)
Problem
- Callers currently need to inspect raw status codes to distinguish authentication, permission, not-found, and server failures.

Change
- Adds a small HTTPErrorCategory for 401, 403, 404, and 5xx responses.
- Exposes httpStatusCode and httpErrorCategory on ServiceError and AuthenticationError.
- Keeps the existing AuthenticationError cases intact and centralizes status-to-auth-error mapping.

Tests
- DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test

Co-authored-by: Piotr Durlej <pdurlej@users.noreply.github.com>
Reviewed-on: https://codeberg.org/secana/ForgejoKit/pulls/2
Reviewed-by: secana <secana@noreply.codeberg.org>
2026-05-12 17:41:24 +02:00
32 changed files with 865 additions and 172 deletions

View file

@ -1,2 +1,3 @@
--swiftversion 6.2
--disable redundantMemberwiseInit
--disable swiftTestingTestCaseNames

View file

@ -3,3 +3,13 @@
disabled_rules:
- trailing_comma
- opening_brace
# Service types nest their request/response payload structs one level deeper.
nesting:
type_level: 2
# ForgejoClient intentionally aggregates every authentication flow in one type.
file_length:
warning: 600
type_body_length:
warning: 300

View file

@ -4,7 +4,7 @@ A native Swift library for the [Forgejo](https://forgejo.org) API. ForgejoKit pr
## Features
- Authentication via username/password (with optional OTP) or API tokens
- Authentication via username/password (with optional OTP), API tokens, or OAuth2 bearer tokens
- Repositories: search, list, create, fork, star, file contents, commits, branches, tags, releases
- Issues: create, edit, list, comment, manage labels and milestones
- Pull requests: create, edit, merge, review, diff
@ -17,7 +17,7 @@ Add ForgejoKit as a dependency in your `Package.swift`:
```swift
dependencies: [
.package(url: "https://codeberg.org/secana/ForgejoKit.git", from: "0.5.0"),
.package(url: "https://codeberg.org/secana/ForgejoKit.git", from: "0.8.1"),
],
targets: [
.target(
@ -35,6 +35,9 @@ import ForgejoKit
// Authenticate with a token
let client = ForgejoClient(serverURL: "https://codeberg.org", username: "user", token: "your-token")
// Or authenticate with an OAuth2 access token (sent as `Authorization: Bearer`)
let client = ForgejoClient(serverURL: "https://codeberg.org", username: "user", bearerToken: "oauth2-access-token")
// Or log in with username/password to create an API token
let result = try await ForgejoClient.login(serverURL: "https://codeberg.org", username: "user", password: "pass")
let client = result.client

View file

@ -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"
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -3,7 +3,7 @@ import Foundation
/// Represents a single Forgejo Actions run.
///
/// Mirrors the Forgejo `ActionRun` schema. Note: Forgejo merges status and
/// conclusion into a single `status` field values are
/// conclusion into a single `status` field, values are
/// `success`, `failure`, `cancelled`, `skipped`, `running`, `waiting`,
/// `blocked`, `unknown`.
public struct WorkflowRun: Codable, Identifiable, Sendable {

View file

@ -3,7 +3,7 @@ import Foundation
/// Response from Forgejo's internal web route
/// `POST /{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}`.
///
/// **Experimental.** This endpoint is the web UI's own JSON contract it is
/// **Experimental.** This endpoint is the web UI's own JSON contract, it is
/// not part of `/api/v1` and may change between Forgejo releases without
/// notice. Use only for surfaces where graceful degradation is acceptable.
public struct WorkflowRunView: Codable, Sendable {

View file

@ -3,7 +3,7 @@ import Foundation
public class URLSessionManager: NSObject, @unchecked Sendable {
public let allowSelfSignedCertificates: Bool
private let trustedHost: String?
// Initialized eagerly in init safe for concurrent access as it's only written once.
// Initialized eagerly in init, safe for concurrent access as it's only written once.
private var _session: URLSession!
public var session: URLSession {
_session

View file

@ -0,0 +1,73 @@
import Foundation
public enum AuthenticationError: LocalizedError, Sendable, Equatable {
case invalidURL
case invalidCredentials
case otpRequired
case basicAuthBlockedBySecurityKey
case serverNotFound
case invalidResponse
case certificateError
case unknownError(statusCode: Int)
static func from(statusCode: Int, body: String = "") -> AuthenticationError {
switch statusCode {
case 401 where body.localizedCaseInsensitiveContains("security key"):
.basicAuthBlockedBySecurityKey
case 401:
body.localizedCaseInsensitiveContains("OTP") ? .otpRequired : .invalidCredentials
case 404:
.serverNotFound
default:
.unknownError(statusCode: statusCode)
}
}
public var httpStatusCode: Int? {
switch self {
case .invalidCredentials, .otpRequired, .basicAuthBlockedBySecurityKey:
401
case .serverNotFound:
404
case let .unknownError(statusCode):
statusCode
case .invalidURL, .invalidResponse, .certificateError:
nil
}
}
public var httpErrorCategory: HTTPErrorCategory? {
guard let httpStatusCode else {
return nil
}
return HTTPErrorCategory(statusCode: httpStatusCode)
}
public var errorDescription: String? {
switch self {
case .invalidURL:
"Invalid server URL"
case .invalidCredentials:
"Invalid username or password"
case .otpRequired:
"Two-factor authentication code required"
case .basicAuthBlockedBySecurityKey:
"An enrolled security key blocks Basic auth. Create a personal access token and use 'Login with Token'."
case .serverNotFound:
"Server not found. Please check the URL."
case .invalidResponse:
"Invalid response from server"
case .certificateError:
"SSL certificate error. Try enabling 'Accept Self-Signed Certificates' if your server uses one."
case let .unknownError(statusCode):
switch HTTPErrorCategory(statusCode: statusCode) {
case .permissionDenied:
"Permission denied (Status: \(statusCode))"
case .server:
"Server error occurred (Status: \(statusCode))"
case .authentication, .notFound, .other:
"Unknown error occurred (Status: \(statusCode))"
}
}
}
}

View file

@ -4,9 +4,10 @@ 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)
}
private let credential: AuthCredential
@ -48,6 +49,21 @@ public final class ForgejoClient: Sendable {
)
}
/// Creates a client authenticated with an OAuth2 access token.
///
/// OAuth2 access tokens are sent with the `Authorization: Bearer` scheme,
/// unlike personal access tokens which use `Authorization: token`.
public convenience init(
serverURL: String, username: String, bearerToken: String,
allowSelfSignedCertificates: Bool = false,
) {
self.init(
serverURL: serverURL, username: username,
credential: .bearer(bearerToken),
allowSelfSignedCertificates: allowSelfSignedCertificates,
)
}
public static func login(
serverURL: String, username: String, password: String,
allowSelfSignedCertificates: Bool = false,
@ -115,13 +131,9 @@ public final class ForgejoClient: Sendable {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = forgejoDateDecodingStrategy
return try decoder.decode(User.self, from: data)
case 401:
let body = String(data: data, encoding: .utf8) ?? ""
throw body.contains("OTP") ? AuthenticationError.otpRequired : .invalidCredentials
case 404:
throw AuthenticationError.serverNotFound
default:
throw AuthenticationError.unknownError(statusCode: httpResponse.statusCode)
let body = String(data: data, encoding: .utf8) ?? ""
throw AuthenticationError.from(statusCode: httpResponse.statusCode, body: body)
}
} catch let error as URLError where Self.certificateErrorCodes.contains(error.code) {
throw AuthenticationError.certificateError
@ -129,11 +141,19 @@ public final class ForgejoClient: Sendable {
}
private func applyAuthHeader(to request: inout URLRequest) {
request.setValue(authorizationHeaderValue, forHTTPHeaderField: "Authorization")
}
/// The `Authorization` header value for this client's credential.
/// Exposed internally so tests can assert the scheme without issuing a request.
var authorizationHeaderValue: String {
switch credential {
case let .basic(base64):
request.setValue("Basic \(base64)", forHTTPHeaderField: "Authorization")
"Basic \(base64)"
case let .token(token):
request.setValue("token \(token)", forHTTPHeaderField: "Authorization")
"token \(token)"
case let .bearer(token):
"Bearer \(token)"
}
}
@ -184,20 +204,21 @@ public final class ForgejoClient: Sendable {
else { throw AuthenticationError.invalidResponse }
return sha1
case 400, 422:
// Token name already taken Forgejo returns 400, some versions use 422
// Token name already taken, Forgejo returns 400, some versions use 422
let body = String(data: data, encoding: .utf8) ?? ""
guard body.contains("name") else {
throw AuthenticationError.unknownError(statusCode: httpResponse.statusCode)
throw AuthenticationError.from(statusCode: httpResponse.statusCode, body: body)
}
guard retryCount < 3 else {
throw AuthenticationError.unknownError(statusCode: httpResponse.statusCode)
throw AuthenticationError.from(statusCode: httpResponse.statusCode, body: body)
}
let retryName = "\(name)-\(Int(Date().timeIntervalSince1970))"
return try await postTokenRequest(
url: url, name: retryName, otp: otp, retryCount: retryCount + 1,
)
default:
throw AuthenticationError.unknownError(statusCode: httpResponse.statusCode)
let body = String(data: data, encoding: .utf8) ?? ""
throw AuthenticationError.from(statusCode: httpResponse.statusCode, body: body)
}
}
@ -351,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<T: Decodable>(
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("/") {
@ -369,36 +437,30 @@ public struct LoginResult: Sendable {
public let token: String
}
public enum AuthenticationError: LocalizedError, Sendable {
case invalidURL
case invalidCredentials
case otpRequired
case serverNotFound
case invalidResponse
case certificateError
case unknownError(statusCode: Int)
public enum HTTPErrorCategory: Equatable, Sendable {
case authentication
case permissionDenied
case notFound
case server
case other
public var errorDescription: String? {
switch self {
case .invalidURL:
"Invalid server URL"
case .invalidCredentials:
"Invalid username or password"
case .otpRequired:
"Two-factor authentication code required"
case .serverNotFound:
"Server not found. Please check the URL."
case .invalidResponse:
"Invalid response from server"
case .certificateError:
"SSL certificate error. Try enabling 'Accept Self-Signed Certificates' if your server uses one."
case let .unknownError(statusCode):
"Unknown error occurred (Status: \(statusCode))"
public init(statusCode: Int) {
switch statusCode {
case 401:
self = .authentication
case 403:
self = .permissionDenied
case 404:
self = .notFound
case 500 ... 599:
self = .server
default:
self = .other
}
}
}
public enum ServiceError: LocalizedError, Sendable {
public enum ServiceError: LocalizedError, Sendable, Equatable {
case noActiveInstance
case invalidURL
case invalidResponse
@ -407,6 +469,20 @@ public enum ServiceError: LocalizedError, Sendable {
case mergeConflict
case decodingFailed(detail: String)
public var httpStatusCode: Int? {
guard case let .httpError(statusCode, _) = self else {
return nil
}
return statusCode
}
public var httpErrorCategory: HTTPErrorCategory? {
guard let httpStatusCode else {
return nil
}
return HTTPErrorCategory(statusCode: httpStatusCode)
}
public var errorDescription: String? {
switch self {
case .noActiveInstance:
@ -416,7 +492,11 @@ public enum ServiceError: LocalizedError, Sendable {
case .invalidResponse:
"Invalid response from server"
case let .httpError(statusCode, message):
message.isEmpty ? "HTTP error: \(statusCode)" : "HTTP \(statusCode): \(message)"
if let message = Self.normalizedHTTPMessage(message) {
"HTTP \(statusCode): \(message)"
} else {
"HTTP error: \(statusCode)"
}
case .notMergeable:
"This pull request cannot be merged"
case .mergeConflict:
@ -425,4 +505,26 @@ public enum ServiceError: LocalizedError, Sendable {
"Decoding failed: \(detail)"
}
}
private struct ForgejoErrorBody: Decodable {
let message: String?
}
static func normalizedHTTPMessage(_ message: String) -> String? {
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return nil
}
guard
let data = trimmed.data(using: .utf8),
let body = try? JSONDecoder().decode(ForgejoErrorBody.self, from: data)
else {
return trimmed
}
guard let jsonMessage = body.message else {
return trimmed
}
let trimmedJSONMessage = jsonMessage.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedJSONMessage.isEmpty ? nil : trimmedJSONMessage
}
}

View file

@ -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,
)
}
}

View file

@ -37,7 +37,6 @@ public final class PullRequestService: Sendable {
let mergeTitleField: String?
let mergeMessageField: String?
// swiftlint:disable:next nesting
enum CodingKeys: String, CodingKey {
case method = "Do"
case deleteBranchAfterMerge = "delete_branch_after_merge"
@ -58,7 +57,6 @@ public final class PullRequestService: Sendable {
let oldPosition: Int?
let newPosition: Int?
// swiftlint:disable:next nesting
enum CodingKeys: String, CodingKey {
case body
case path
@ -156,7 +154,6 @@ public final class PullRequestService: Sendable {
_ = try await client.performRequestNoContent(url: url, method: "DELETE", body: jsonData, validateStatus: true)
}
// swiftlint:disable:next function_parameter_count
public func mergePullRequest(
owner: String, repo: String, index: Int,
method: String, title: String? = nil, message: String? = nil,
@ -173,17 +170,24 @@ public final class PullRequestService: Sendable {
do {
_ = try await client.performRequestNoContent(url: url, method: "POST", body: jsonData, validateStatus: true)
} catch let error as ServiceError {
if case let .httpError(statusCode, _) = error {
switch statusCode {
case 405:
throw ServiceError.notMergeable
case 409:
throw ServiceError.mergeConflict
default:
throw error
}
}
throw error
throw Self.collapseMergeError(error)
}
}
static func collapseMergeError(_ error: ServiceError) -> ServiceError {
guard case let .httpError(statusCode, message) = error else {
return error
}
guard message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return error
}
switch statusCode {
case 405:
return .notMergeable
case 409:
return .mergeConflict
default:
return error
}
}

View file

@ -1,7 +1,5 @@
import Foundation
// swiftlint:disable file_length
private struct CreateRepositoryPayload: Codable {
let name: String
let description: String?

View file

@ -1,7 +1,7 @@
import Foundation
/// Forgejo Actions API. Forgejo's Actions API is much smaller than Gitea's
/// or GitHub's there is no public endpoint to list workflow definitions,
/// or GitHub's, there is no public endpoint to list workflow definitions,
/// fetch jobs of a run, or stream job logs. Only run listing and run detail
/// are exposed.
public final class WorkflowService: Sendable {
@ -114,9 +114,9 @@ public final class WorkflowService: Sendable {
)
// Forgejo's POST handler at this base path uses `attempt=0` which fails
// for any run whose tasks are stored with the (1-based) attempt number.
// The matching GET endpoint 307-redirects to the latest-attempt URL
// The matching GET endpoint 307-redirects to the latest-attempt URL,
// resolve that first, then POST to the resolved attempt-suffixed URL.
let postURL = (try? await client.discoverRedirectLocation(url: baseURL)) ?? baseURL
let postURL = await (try? client.discoverRedirectLocation(url: baseURL)) ?? baseURL
let body = try client.encodeRequestBody(ViewRequestBody(logCursors: logCursors))
return try await client.performRequest(
url: postURL, method: "POST", body: body,

18
Tests/.swiftlint.yml Normal file
View file

@ -0,0 +1,18 @@
# Test fixtures embed large, verbatim JSON payloads and exhaustive decoding
# cases, so length limits are relaxed here. Quality/safety rules stay enabled.
disabled_rules:
- trailing_comma
- opening_brace
file_length:
warning: 1500
error: 2000
type_body_length:
warning: 1300
error: 1500
function_body_length:
warning: 60
error: 100
line_length:
warning: 600
error: 700

View file

@ -1,9 +1,8 @@
@testable import ForgejoKit
import Foundation
import Testing
@testable import ForgejoKit
struct AdminServiceTests {
@Test func createUserPayloadEncodesCorrectKeys() throws {
let json = """
{
@ -19,7 +18,7 @@ struct AdminServiceTests {
}
"""
let data = Data(json.utf8)
let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any]
let dict = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect(dict["username"] as? String == "testbot")
#expect(dict["login_name"] as? String == "testbot")
#expect(dict["must_change_password"] as? Bool == false)

View file

@ -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 decodesAttachment() 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 decodesAttachmentWithoutTypeField() 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 decodesNonImageAttachment() 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 decodesIssueWithAssets() 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 decodesIssueWithNoAssets() 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 decodesIssueCommentWithAssets() 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 isImageForVariousMimeTypes() {
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 isImageFallsBackToFileExtensionWhenTypeIsNil() {
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 isImageFallsBackToExtensionWhenTypeIsNotAMimeType() {
// 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 uploadIssueAttachmentURL() 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 uploadCommentAttachmentURL() 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 uploadURLEncodesOwnerAndRepo() 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 buildMultipartBodyStructure() {
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 buildMultipartBodyContainsFileData() {
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)
}
}

View file

@ -0,0 +1,27 @@
@testable import ForgejoKit
import Foundation
import Testing
struct BearerCredentialTests {
@Test func bearerCredentialUsesBearerScheme() {
let client = ForgejoClient(
serverURL: "https://codeberg.org", username: "octocat", bearerToken: "abc123",
)
#expect(client.authorizationHeaderValue == "Bearer abc123")
}
@Test func tokenCredentialStillUsesTokenScheme() {
let client = ForgejoClient(
serverURL: "https://codeberg.org", username: "octocat", token: "abc123",
)
#expect(client.authorizationHeaderValue == "token abc123")
}
@Test func basicCredentialUsesBasicScheme() {
let client = ForgejoClient(
serverURL: "https://codeberg.org", username: "octocat", password: "pw",
)
let expected = "Basic " + Data("octocat:pw".utf8).base64EncodedString()
#expect(client.authorizationHeaderValue == expected)
}
}

View file

@ -1,13 +1,12 @@
@testable import ForgejoKit
import Foundation
import Testing
@testable import ForgejoKit
struct DateDecodingTests {
private func decoder() -> JSONDecoder {
let d = JSONDecoder()
d.dateDecodingStrategy = forgejoDateDecodingStrategy
return d
let jsonDecoder = JSONDecoder()
jsonDecoder.dateDecodingStrategy = forgejoDateDecodingStrategy
return jsonDecoder
}
private struct DateWrapper: Codable {
@ -74,9 +73,9 @@ struct DateDecodingTests {
let jsonWithout = #"{"date":"2024-03-10T08:00:00Z"}"#
let jsonWith = #"{"date":"2024-03-10T08:00:00.000Z"}"#
let d = decoder()
let resultWithout = try d.decode(DateWrapper.self, from: Data(jsonWithout.utf8))
let resultWith = try d.decode(DateWrapper.self, from: Data(jsonWith.utf8))
let jsonDecoder = decoder()
let resultWithout = try jsonDecoder.decode(DateWrapper.self, from: Data(jsonWithout.utf8))
let resultWith = try jsonDecoder.decode(DateWrapper.self, from: Data(jsonWith.utf8))
#expect(resultWithout.date == resultWith.date)
}

View file

@ -1,9 +1,8 @@
@testable import ForgejoKit
import Foundation
import Testing
@testable import ForgejoKit
struct DiffParserTests {
// MARK: - Empty and minimal input
@Test func parseEmptyString() {
@ -305,13 +304,13 @@ struct DiffParserTests {
#expect(result.files[0].newName == "hello.py")
#expect(result.files[0].oldName == "/dev/null")
#expect(result.files[0].hunks[0].lines.filter { $0.type == .addition }.count == 2)
#expect(result.files[0].hunks[0].lines.count(where: { $0.type == .addition }) == 2)
#expect(result.files[1].newName == "README.md")
#expect(result.files[1].oldName == "README.md")
let readmeHunk = result.files[1].hunks[0]
#expect(readmeHunk.lines.filter { $0.type == .context }.count == 1)
#expect(readmeHunk.lines.filter { $0.type == .addition }.count == 2)
#expect(readmeHunk.lines.count(where: { $0.type == .context }) == 1)
#expect(readmeHunk.lines.count(where: { $0.type == .addition }) == 2)
}
// MARK: - No newline at end of file sentinel

View file

@ -0,0 +1,142 @@
@testable import ForgejoKit
import Testing
struct ErrorMappingTests {
@Test func classifiesHTTPStatusCodes() {
#expect(HTTPErrorCategory(statusCode: 401) == .authentication)
#expect(HTTPErrorCategory(statusCode: 403) == .permissionDenied)
#expect(HTTPErrorCategory(statusCode: 404) == .notFound)
#expect(HTTPErrorCategory(statusCode: 500) == .server)
#expect(HTTPErrorCategory(statusCode: 503) == .server)
#expect(HTTPErrorCategory(statusCode: 400) == .other)
}
@Test func exposesServiceErrorStatusAndCategory() {
let forbidden = ServiceError.httpError(statusCode: 403, message: "missing scope")
#expect(forbidden.httpStatusCode == 403)
#expect(forbidden.httpErrorCategory == .permissionDenied)
let serverError = ServiceError.httpError(statusCode: 502)
#expect(serverError.httpStatusCode == 502)
#expect(serverError.httpErrorCategory == .server)
#expect(ServiceError.invalidURL.httpStatusCode == nil)
#expect(ServiceError.invalidURL.httpErrorCategory == nil)
}
@Test func mapsAuthenticationStatusCodes() {
#expect(AuthenticationError.from(statusCode: 401) == .invalidCredentials)
#expect(AuthenticationError.from(statusCode: 401, body: "OTP required") == .otpRequired)
#expect(AuthenticationError.from(statusCode: 403) == .unknownError(statusCode: 403))
#expect(AuthenticationError.from(statusCode: 404) == .serverNotFound)
#expect(AuthenticationError.from(statusCode: 500) == .unknownError(statusCode: 500))
#expect(AuthenticationError.from(statusCode: 418) == .unknownError(statusCode: 418))
}
@Test func mapsSecurityKeyBlockedBasicAuth() {
// Forgejo's exact 401 body when a passkey/security key is enrolled on the account.
let body = "Basic authorization is not allowed while having security keys enrolled"
#expect(AuthenticationError.from(statusCode: 401, body: body) == .basicAuthBlockedBySecurityKey)
#expect(AuthenticationError.basicAuthBlockedBySecurityKey.httpStatusCode == 401)
#expect(AuthenticationError.basicAuthBlockedBySecurityKey.httpErrorCategory == .authentication)
}
@Test func exposesAuthenticationStatusAndCategory() {
#expect(AuthenticationError.invalidCredentials.httpStatusCode == 401)
#expect(AuthenticationError.invalidCredentials.httpErrorCategory == .authentication)
#expect(AuthenticationError.serverNotFound.httpStatusCode == 404)
#expect(AuthenticationError.serverNotFound.httpErrorCategory == .notFound)
#expect(AuthenticationError.unknownError(statusCode: 403).httpStatusCode == 403)
#expect(AuthenticationError.unknownError(statusCode: 403).httpErrorCategory == .permissionDenied)
#expect(AuthenticationError.unknownError(statusCode: 502).httpErrorCategory == .server)
#expect(AuthenticationError.certificateError.httpStatusCode == nil)
#expect(AuthenticationError.certificateError.httpErrorCategory == nil)
}
// MARK: - ServiceError descriptions
@Test func serviceErrorDescriptions() {
#expect(ServiceError.noActiveInstance.errorDescription == "No active Forgejo instance")
#expect(ServiceError.invalidURL.errorDescription == "Invalid URL")
#expect(ServiceError.invalidResponse.errorDescription == "Invalid response from server")
#expect(ServiceError.httpError(statusCode: 404).errorDescription == "HTTP error: 404")
#expect(ServiceError.httpError(statusCode: 500).errorDescription == "HTTP error: 500")
#expect(ServiceError.decodingFailed(detail: "missing key").errorDescription == "Decoding failed: missing key")
}
@Test func httpErrorDescriptionUsesForgejoMessageBody() {
let error = ServiceError.httpError(
statusCode: 405,
message: #"{"message":"This pull request has failing status checks"}"#,
)
#expect(error.errorDescription == "HTTP 405: This pull request has failing status checks")
}
@Test func httpErrorDescriptionFallsBackToRawBody() {
let error = ServiceError.httpError(statusCode: 500, message: "database unavailable")
#expect(error.errorDescription == "HTTP 500: database unavailable")
}
@Test func httpErrorDescriptionIgnoresWhitespaceOnlyMessage() {
let error = ServiceError.httpError(statusCode: 503, message: " \n ")
#expect(error.errorDescription == "HTTP error: 503")
}
@Test func httpErrorDescriptionFallsBackWhenJSONMessageFieldIsEmpty() {
let error = ServiceError.httpError(
statusCode: 422,
message: #"{"message":" "}"#,
)
#expect(error.errorDescription == "HTTP error: 422")
}
@Test func httpErrorDescriptionReturnsRawJSONWhenMessageFieldMissing() {
let raw = #"{"errors":["bad request"]}"#
let error = ServiceError.httpError(statusCode: 400, message: raw)
#expect(error.errorDescription == "HTTP 400: \(raw)")
}
// MARK: - Merge error collapsing
@Test func collapseMergeErrorMaps405WithEmptyBodyToNotMergeable() {
let collapsed = PullRequestService.collapseMergeError(
.httpError(statusCode: 405, message: ""),
)
#expect(collapsed == .notMergeable)
}
@Test func collapseMergeErrorMaps409WithEmptyBodyToMergeConflict() {
let collapsed = PullRequestService.collapseMergeError(
.httpError(statusCode: 409, message: " \n "),
)
#expect(collapsed == .mergeConflict)
}
@Test func collapseMergeErrorPreserves405WithBody() {
let original = ServiceError.httpError(
statusCode: 405,
message: #"{"message":"failing status checks"}"#,
)
#expect(PullRequestService.collapseMergeError(original) == original)
}
@Test func collapseMergeErrorPreserves409WithBody() {
let original = ServiceError.httpError(statusCode: 409, message: "conflict in README.md")
#expect(PullRequestService.collapseMergeError(original) == original)
}
@Test func collapseMergeErrorPassesOtherStatusCodesThrough() {
let original = ServiceError.httpError(statusCode: 500, message: "")
#expect(PullRequestService.collapseMergeError(original) == original)
}
@Test func collapseMergeErrorPassesNonHTTPErrorsThrough() {
#expect(PullRequestService.collapseMergeError(.invalidURL) == .invalidURL)
#expect(PullRequestService.collapseMergeError(.noActiveInstance) == .noActiveInstance)
}
}

View file

@ -1,9 +1,8 @@
@testable import ForgejoKit
import Foundation
import Testing
@testable import ForgejoKit
struct FileContentTests {
private func makeFileContent(content: String?, encoding: String?) -> FileContent {
FileContent(
name: "test.txt",
@ -16,7 +15,7 @@ struct FileContentTests {
downloadUrl: nil,
type: "file",
content: content,
encoding: encoding
encoding: encoding,
)
}

View file

@ -1,13 +1,12 @@
@testable import ForgejoKit
import Foundation
import Testing
@testable import ForgejoKit
struct ModelDecodingTests {
private func decoder() -> JSONDecoder {
let d = JSONDecoder()
d.dateDecodingStrategy = forgejoDateDecodingStrategy
return d
let jsonDecoder = JSONDecoder()
jsonDecoder.dateDecodingStrategy = forgejoDateDecodingStrategy
return jsonDecoder
}
// MARK: - User
@ -400,14 +399,14 @@ struct ModelDecodingTests {
"updated_at": "2024-03-02T09:00:00Z"
}
"""
let pr = try decoder().decode(PullRequest.self, from: Data(json.utf8))
#expect(pr.number == 42)
#expect(pr.title == "Add feature X")
#expect(pr.head.ref == "feature-x")
#expect(pr.base.ref == "main")
#expect(pr.mergeable == true)
#expect(pr.merged == false)
#expect(pr.draft == false)
let pullRequest = try decoder().decode(PullRequest.self, from: Data(json.utf8))
#expect(pullRequest.number == 42)
#expect(pullRequest.title == "Add feature X")
#expect(pullRequest.head.ref == "feature-x")
#expect(pullRequest.base.ref == "main")
#expect(pullRequest.mergeable == true)
#expect(pullRequest.merged == false)
#expect(pullRequest.draft == false)
}
@Test func decodesPullRequestWithFullMetadata() throws {
@ -447,18 +446,18 @@ struct ModelDecodingTests {
"updated_at": "2024-03-02T09:00:00Z"
}
"""
let pr = try decoder().decode(PullRequest.self, from: Data(json.utf8))
#expect(pr.number == 46)
#expect(pr.labels.count == 2)
#expect(pr.labels[0].name == "enhancement")
#expect(pr.milestone?.id == 3)
#expect(pr.milestone?.title == "v1.0")
#expect(pr.milestone?.dueOn != nil)
#expect(pr.assignees?.count == 2)
#expect(pr.assignees?[0].login == "assignee1")
#expect(pr.assignees?[0].fullName == "Assignee One")
#expect(pr.requestedReviewers?.count == 1)
#expect(pr.requestedReviewers?[0].login == "reviewer1")
let pullRequest = try decoder().decode(PullRequest.self, from: Data(json.utf8))
#expect(pullRequest.number == 46)
#expect(pullRequest.labels.count == 2)
#expect(pullRequest.labels[0].name == "enhancement")
#expect(pullRequest.milestone?.id == 3)
#expect(pullRequest.milestone?.title == "v1.0")
#expect(pullRequest.milestone?.dueOn != nil)
#expect(pullRequest.assignees?.count == 2)
#expect(pullRequest.assignees?[0].login == "assignee1")
#expect(pullRequest.assignees?[0].fullName == "Assignee One")
#expect(pullRequest.requestedReviewers?.count == 1)
#expect(pullRequest.requestedReviewers?[0].login == "reviewer1")
}
@Test func decodesPullRequestWithRequestedReviewers() throws {
@ -484,11 +483,11 @@ struct ModelDecodingTests {
]
}
"""
let pr = try decoder().decode(PullRequest.self, from: Data(json.utf8))
#expect(pr.number == 44)
#expect(pr.requestedReviewers?.count == 2)
#expect(pr.requestedReviewers?[0].login == "reviewer1")
#expect(pr.requestedReviewers?[1].fullName == "Reviewer Two")
let pullRequest = try decoder().decode(PullRequest.self, from: Data(json.utf8))
#expect(pullRequest.number == 44)
#expect(pullRequest.requestedReviewers?.count == 2)
#expect(pullRequest.requestedReviewers?[0].login == "reviewer1")
#expect(pullRequest.requestedReviewers?[1].fullName == "Reviewer Two")
}
@Test func decodesPullRequestWithoutRequestedReviewers() throws {
@ -507,9 +506,9 @@ struct ModelDecodingTests {
"updated_at": "2024-03-02T09:00:00Z"
}
"""
let pr = try decoder().decode(PullRequest.self, from: Data(json.utf8))
#expect(pr.number == 45)
#expect(pr.requestedReviewers == nil)
let pullRequest = try decoder().decode(PullRequest.self, from: Data(json.utf8))
#expect(pullRequest.number == 45)
#expect(pullRequest.requestedReviewers == nil)
}
@Test func decodesMergedPullRequest() throws {
@ -531,10 +530,10 @@ struct ModelDecodingTests {
"updated_at": "2024-04-01T12:00:00Z"
}
"""
let pr = try decoder().decode(PullRequest.self, from: Data(json.utf8))
#expect(pr.merged == true)
#expect(pr.mergedBy?.login == "maintainer")
#expect(pr.mergedAt != nil)
let pullRequest = try decoder().decode(PullRequest.self, from: Data(json.utf8))
#expect(pullRequest.merged == true)
#expect(pullRequest.mergedBy?.login == "maintainer")
#expect(pullRequest.mergedAt != nil)
}
// MARK: - PullRequestReview
@ -607,10 +606,10 @@ struct ModelDecodingTests {
body: "Fix this",
path: "file.swift",
oldPosition: 5,
newPosition: 7
newPosition: 7,
)
let data = try JSONEncoder().encode(comment)
let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any]
let dict = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect(dict["body"] as? String == "Fix this")
#expect(dict["path"] as? String == "file.swift")
#expect(dict["old_position"] as? Int == 5)
@ -622,10 +621,10 @@ struct ModelDecodingTests {
body: "Note",
path: "readme.md",
oldPosition: nil,
newPosition: nil
newPosition: nil,
)
let data = try JSONEncoder().encode(comment)
let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any]
let dict = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect(dict["old_position"] == nil)
#expect(dict["new_position"] == nil)
}

View file

@ -1,9 +1,8 @@
@testable import ForgejoKit
import Foundation
import Testing
@testable import ForgejoKit
struct NormalizeServerURLTests {
@Test func stripsTrailingSlash() {
let result = ForgejoClient.normalizeServerURL("https://forgejo.example.com/")
#expect(result == "https://forgejo.example.com")

View file

@ -1,13 +1,12 @@
@testable import ForgejoKit
import Foundation
import Testing
@testable import ForgejoKit
struct NotificationTests {
private func decoder() -> JSONDecoder {
let d = JSONDecoder()
d.dateDecodingStrategy = forgejoDateDecodingStrategy
return d
let jsonDecoder = JSONDecoder()
jsonDecoder.dateDecodingStrategy = forgejoDateDecodingStrategy
return jsonDecoder
}
// MARK: - Subject number extraction
@ -64,17 +63,6 @@ struct NotificationTests {
#expect(notificationSubjectNumber(from: url) == 15)
}
// MARK: - ServiceError descriptions
@Test func serviceErrorDescriptions() {
#expect(ServiceError.noActiveInstance.errorDescription == "No active Forgejo instance")
#expect(ServiceError.invalidURL.errorDescription == "Invalid URL")
#expect(ServiceError.invalidResponse.errorDescription == "Invalid response from server")
#expect(ServiceError.httpError(statusCode: 404).errorDescription == "HTTP error: 404")
#expect(ServiceError.httpError(statusCode: 500).errorDescription == "HTTP error: 500")
#expect(ServiceError.decodingFailed(detail: "missing key").errorDescription == "Decoding failed: missing key")
}
// MARK: - Full API response decoding
@Test func decodesNotificationArray() throws {

View file

@ -1,9 +1,8 @@
@testable import ForgejoKit
import Foundation
import Testing
@testable import ForgejoKit
struct RepositoryServiceURLTests {
/// Creates a client with dummy credentials for URL construction tests.
private func makeClient() -> ForgejoClient {
ForgejoClient(serverURL: "https://forgejo.example.com", username: "user", password: "pass")
@ -120,7 +119,7 @@ struct RepositoryServiceURLTests {
"auto_init": true,
]
let data = try JSONSerialization.data(withJSONObject: payload)
let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any]
let dict = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect(dict["name"] as? String == "test-repo")
#expect(dict["auto_init"] as? Bool == true)
#expect(dict["private"] as? Bool == false)
@ -133,7 +132,7 @@ struct RepositoryServiceURLTests {
"new_branch": "feature-branch",
]
let data = try JSONSerialization.data(withJSONObject: payload)
let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any]
let dict = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect(dict["content"] as? String == "base64content")
#expect(dict["message"] as? String == "Add file")
#expect(dict["new_branch"] as? String == "feature-branch")

View file

@ -1,9 +1,8 @@
@testable import ForgejoKit
import Foundation
import Testing
@testable import ForgejoKit
struct URLSessionManagerTests {
// MARK: - trustedHost scoping
@Test func selfSignedWithoutTrustedHostAcceptsAny() {
@ -62,49 +61,48 @@ struct URLSessionManagerTests {
}
struct ForgejoClientHostMatchTests {
// MARK: - Auth header host matching
@Test func authenticatedRequestIncludesAuthForMatchingHost() {
@Test func authenticatedRequestIncludesAuthForMatchingHost() throws {
let client = ForgejoClient(
serverURL: "https://forgejo.example.com",
username: "user",
password: "pass"
password: "pass",
)
let url = URL(string: "https://forgejo.example.com/api/v1/user")!
let url = try #require(URL(string: "https://forgejo.example.com/api/v1/user"))
let request = client.authenticatedRequest(url: url)
#expect(request.value(forHTTPHeaderField: "Authorization") != nil)
}
@Test func authenticatedRequestOmitsAuthForDifferentHost() {
@Test func authenticatedRequestOmitsAuthForDifferentHost() throws {
let client = ForgejoClient(
serverURL: "https://forgejo.example.com",
username: "user",
password: "pass"
password: "pass",
)
let url = URL(string: "https://evil.example.com/api/v1/user")!
let url = try #require(URL(string: "https://evil.example.com/api/v1/user"))
let request = client.authenticatedRequest(url: url)
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
}
@Test func authenticatedRequestMatchesCaseInsensitiveHost() {
@Test func authenticatedRequestMatchesCaseInsensitiveHost() throws {
let client = ForgejoClient(
serverURL: "https://Forgejo.Example.COM",
username: "user",
password: "pass"
password: "pass",
)
let url = URL(string: "https://forgejo.example.com/api/v1/repos")!
let url = try #require(URL(string: "https://forgejo.example.com/api/v1/repos"))
let request = client.authenticatedRequest(url: url)
#expect(request.value(forHTTPHeaderField: "Authorization") != nil)
}
@Test func authenticatedRequestSetsMethodAndBody() {
@Test func authenticatedRequestSetsMethodAndBody() throws {
let client = ForgejoClient(
serverURL: "https://forgejo.example.com",
username: "user",
password: "pass"
password: "pass",
)
let url = URL(string: "https://forgejo.example.com/api/v1/repos")!
let url = try #require(URL(string: "https://forgejo.example.com/api/v1/repos"))
let body = Data("{\"name\":\"test\"}".utf8)
let request = client.authenticatedRequest(url: url, method: "POST", body: body)
#expect(request.httpMethod == "POST")
@ -116,7 +114,7 @@ struct ForgejoClientHostMatchTests {
let client = ForgejoClient(
serverURL: "https://forgejo.example.com",
username: "user",
password: "pass"
password: "pass",
)
let url = try client.makeURL(path: "/api/v1/user")
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/user")
@ -126,11 +124,11 @@ struct ForgejoClientHostMatchTests {
let client = ForgejoClient(
serverURL: "https://forgejo.example.com",
username: "user",
password: "pass"
password: "pass",
)
let url = try client.makeURL(path: "/api/v1/repos/search", queryItems: [
URLQueryItem(name: "q", value: "test"),
URLQueryItem(name: "page", value: "2")
URLQueryItem(name: "page", value: "2"),
])
#expect(url.absoluteString.contains("q=test"))
#expect(url.absoluteString.contains("page=2"))
@ -142,7 +140,7 @@ struct ForgejoClientHostMatchTests {
serverURL: "https://my-forgejo.local:3000",
username: "user",
password: "pass",
allowSelfSignedCertificates: true
allowSelfSignedCertificates: true,
)
// The session should be created with self-signed support
#expect(client.serverURL == "https://my-forgejo.local:3000")

View file

@ -1,16 +1,15 @@
@testable import ForgejoKit
import Foundation
import Testing
@testable import ForgejoKit
struct UserServiceTests {
@Test func createTokenPayloadEncodesCorrectKeys() throws {
let payload: [String: Any] = [
"name": "integration-test-token",
"scopes": ["read:user", "write:user"],
]
let data = try JSONSerialization.data(withJSONObject: payload)
let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any]
let dict = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect(dict["name"] as? String == "integration-test-token")
#expect((dict["scopes"] as? [String])?.count == 2)
}

View file

@ -1,9 +1,8 @@
@testable import ForgejoKit
import Foundation
import Testing
@testable import ForgejoKit
struct WorkflowServiceURLTests {
private func makeClient() -> ForgejoClient {
ForgejoClient(serverURL: "https://forgejo.example.com", username: "user", password: "pass")
}

View file

@ -8,10 +8,10 @@ test:
swift test 2>&1 | xcbeautify
lint:
swiftlint lint Sources
swiftlint lint Sources Tests
format:
swiftformat Sources
swiftformat Sources Tests
# Tag and push a new release: just release 1.0.0
release version: