Compare commits

..

No commits in common. "main" and "0.6.1" have entirely different histories.
main ... 0.6.1

32 changed files with 184 additions and 648 deletions

View file

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

View file

@ -3,13 +3,3 @@
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), API tokens, or OAuth2 bearer tokens
- Authentication via username/password (with optional OTP) or API 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.8.1"),
.package(url: "https://codeberg.org/secana/ForgejoKit.git", from: "0.6.1"),
],
targets: [
.target(
@ -35,9 +35,6 @@ 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

@ -1,41 +0,0 @@
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,7 +17,6 @@ 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,
@ -29,7 +28,6 @@ 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
@ -47,7 +45,6 @@ public struct Issue: Codable, Identifiable, Sendable {
self.pullRequest = pullRequest
self.comments = comments
self.repository = repository
self.assets = assets
}
enum CodingKeys: String, CodingKey {
@ -67,7 +64,6 @@ public struct Issue: Codable, Identifiable, Sendable {
case pullRequest = "pull_request"
case comments
case repository
case assets
}
}

View file

@ -6,19 +6,13 @@ 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,
assets: [Attachment]? = nil,
) {
public init(id: Int, body: String, user: User, createdAt: Date, updatedAt: Date) {
self.id = id
self.body = body
self.user = user
self.createdAt = createdAt
self.updatedAt = updatedAt
self.assets = assets
}
enum CodingKeys: String, CodingKey {
@ -27,6 +21,5 @@ public struct IssueComment: Codable, Identifiable, Sendable {
case user
case createdAt = "created_at"
case updatedAt = "updated_at"
case assets
}
}

View file

@ -23,7 +23,6 @@ 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,
@ -39,7 +38,6 @@ 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
@ -63,7 +61,6 @@ public struct PullRequest: Codable, Identifiable, Sendable {
self.closedAt = closedAt
self.mergedAt = mergedAt
self.htmlUrl = htmlUrl
self.assets = assets
}
enum CodingKeys: String, CodingKey {
@ -89,7 +86,6 @@ 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

@ -1,73 +0,0 @@
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,10 +4,9 @@ public final class ForgejoClient: Sendable {
public let serverURL: String
public let username: String
private enum AuthCredential {
private enum AuthCredential: Sendable {
case basic(base64: String)
case token(String)
case bearer(String)
}
private let credential: AuthCredential
@ -49,21 +48,6 @@ 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,
@ -141,19 +125,11 @@ 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):
"Basic \(base64)"
request.setValue("Basic \(base64)", forHTTPHeaderField: "Authorization")
case let .token(token):
"token \(token)"
case let .bearer(token):
"Bearer \(token)"
request.setValue("token \(token)", forHTTPHeaderField: "Authorization")
}
}
@ -204,7 +180,7 @@ 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.from(statusCode: httpResponse.statusCode, body: body)
@ -372,53 +348,6 @@ 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("/") {
@ -460,6 +389,73 @@ public enum HTTPErrorCategory: Equatable, Sendable {
}
}
public enum AuthenticationError: LocalizedError, Sendable, Equatable {
case invalidURL
case invalidCredentials
case otpRequired
case serverNotFound
case invalidResponse
case certificateError
case unknownError(statusCode: Int)
static func from(statusCode: Int, body: String = "") -> AuthenticationError {
switch statusCode {
case 401:
body.localizedCaseInsensitiveContains("OTP") ? .otpRequired : .invalidCredentials
case 404:
.serverNotFound
default:
.unknownError(statusCode: statusCode)
}
}
public var httpStatusCode: Int? {
switch self {
case .invalidCredentials, .otpRequired:
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 .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))"
}
}
}
}
public enum ServiceError: LocalizedError, Sendable, Equatable {
case noActiveInstance
case invalidURL

View file

@ -184,37 +184,4 @@ 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,6 +37,7 @@ 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"
@ -57,6 +58,7 @@ public final class PullRequestService: Sendable {
let oldPosition: Int?
let newPosition: Int?
// swiftlint:disable:next nesting
enum CodingKeys: String, CodingKey {
case body
case path
@ -154,6 +156,7 @@ 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,

View file

@ -1,5 +1,7 @@
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 = await (try? client.discoverRedirectLocation(url: baseURL)) ?? baseURL
let postURL = (try? await client.discoverRedirectLocation(url: baseURL)) ?? baseURL
let body = try client.encodeRequestBody(ViewRequestBody(logCursors: logCursors))
return try await client.performRequest(
url: postURL, method: "POST", body: body,

View file

@ -1,18 +0,0 @@
# 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,8 +1,9 @@
@testable import ForgejoKit
import Foundation
import Testing
@testable import ForgejoKit
struct AdminServiceTests {
@Test func createUserPayloadEncodesCorrectKeys() throws {
let json = """
{
@ -18,7 +19,7 @@ struct AdminServiceTests {
}
"""
let data = Data(json.utf8)
let dict = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
let dict = try 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

@ -1,249 +0,0 @@
@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

@ -1,27 +0,0 @@
@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,12 +1,13 @@
@testable import ForgejoKit
import Foundation
import Testing
@testable import ForgejoKit
struct DateDecodingTests {
private func decoder() -> JSONDecoder {
let jsonDecoder = JSONDecoder()
jsonDecoder.dateDecodingStrategy = forgejoDateDecodingStrategy
return jsonDecoder
let d = JSONDecoder()
d.dateDecodingStrategy = forgejoDateDecodingStrategy
return d
}
private struct DateWrapper: Codable {
@ -73,9 +74,9 @@ struct DateDecodingTests {
let jsonWithout = #"{"date":"2024-03-10T08:00:00Z"}"#
let jsonWith = #"{"date":"2024-03-10T08:00:00.000Z"}"#
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))
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))
#expect(resultWithout.date == resultWith.date)
}

View file

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

View file

@ -1,7 +1,8 @@
@testable import ForgejoKit
import Testing
@testable import ForgejoKit
struct ErrorMappingTests {
@Test func classifiesHTTPStatusCodes() {
#expect(HTTPErrorCategory(statusCode: 401) == .authentication)
#expect(HTTPErrorCategory(statusCode: 403) == .permissionDenied)
@ -33,14 +34,6 @@ struct ErrorMappingTests {
#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)

View file

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

View file

@ -1,12 +1,13 @@
@testable import ForgejoKit
import Foundation
import Testing
@testable import ForgejoKit
struct ModelDecodingTests {
private func decoder() -> JSONDecoder {
let jsonDecoder = JSONDecoder()
jsonDecoder.dateDecodingStrategy = forgejoDateDecodingStrategy
return jsonDecoder
let d = JSONDecoder()
d.dateDecodingStrategy = forgejoDateDecodingStrategy
return d
}
// MARK: - User
@ -399,14 +400,14 @@ struct ModelDecodingTests {
"updated_at": "2024-03-02T09:00:00Z"
}
"""
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)
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)
}
@Test func decodesPullRequestWithFullMetadata() throws {
@ -446,18 +447,18 @@ struct ModelDecodingTests {
"updated_at": "2024-03-02T09:00:00Z"
}
"""
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")
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")
}
@Test func decodesPullRequestWithRequestedReviewers() throws {
@ -483,11 +484,11 @@ struct ModelDecodingTests {
]
}
"""
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")
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")
}
@Test func decodesPullRequestWithoutRequestedReviewers() throws {
@ -506,9 +507,9 @@ struct ModelDecodingTests {
"updated_at": "2024-03-02T09:00:00Z"
}
"""
let pullRequest = try decoder().decode(PullRequest.self, from: Data(json.utf8))
#expect(pullRequest.number == 45)
#expect(pullRequest.requestedReviewers == nil)
let pr = try decoder().decode(PullRequest.self, from: Data(json.utf8))
#expect(pr.number == 45)
#expect(pr.requestedReviewers == nil)
}
@Test func decodesMergedPullRequest() throws {
@ -530,10 +531,10 @@ struct ModelDecodingTests {
"updated_at": "2024-04-01T12:00:00Z"
}
"""
let pullRequest = try decoder().decode(PullRequest.self, from: Data(json.utf8))
#expect(pullRequest.merged == true)
#expect(pullRequest.mergedBy?.login == "maintainer")
#expect(pullRequest.mergedAt != nil)
let pr = try decoder().decode(PullRequest.self, from: Data(json.utf8))
#expect(pr.merged == true)
#expect(pr.mergedBy?.login == "maintainer")
#expect(pr.mergedAt != nil)
}
// MARK: - PullRequestReview
@ -606,10 +607,10 @@ struct ModelDecodingTests {
body: "Fix this",
path: "file.swift",
oldPosition: 5,
newPosition: 7,
newPosition: 7
)
let data = try JSONEncoder().encode(comment)
let dict = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
let dict = try 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)
@ -621,10 +622,10 @@ struct ModelDecodingTests {
body: "Note",
path: "readme.md",
oldPosition: nil,
newPosition: nil,
newPosition: nil
)
let data = try JSONEncoder().encode(comment)
let dict = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any]
#expect(dict["old_position"] == nil)
#expect(dict["new_position"] == nil)
}

View file

@ -1,8 +1,9 @@
@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,12 +1,13 @@
@testable import ForgejoKit
import Foundation
import Testing
@testable import ForgejoKit
struct NotificationTests {
private func decoder() -> JSONDecoder {
let jsonDecoder = JSONDecoder()
jsonDecoder.dateDecodingStrategy = forgejoDateDecodingStrategy
return jsonDecoder
let d = JSONDecoder()
d.dateDecodingStrategy = forgejoDateDecodingStrategy
return d
}
// MARK: - Subject number extraction

View file

@ -1,8 +1,9 @@
@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")
@ -119,7 +120,7 @@ struct RepositoryServiceURLTests {
"auto_init": true,
]
let data = try JSONSerialization.data(withJSONObject: payload)
let dict = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
let dict = try 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)
@ -132,7 +133,7 @@ struct RepositoryServiceURLTests {
"new_branch": "feature-branch",
]
let data = try JSONSerialization.data(withJSONObject: payload)
let dict = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
let dict = try 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,8 +1,9 @@
@testable import ForgejoKit
import Foundation
import Testing
@testable import ForgejoKit
struct URLSessionManagerTests {
// MARK: - trustedHost scoping
@Test func selfSignedWithoutTrustedHostAcceptsAny() {
@ -61,48 +62,49 @@ struct URLSessionManagerTests {
}
struct ForgejoClientHostMatchTests {
// MARK: - Auth header host matching
@Test func authenticatedRequestIncludesAuthForMatchingHost() throws {
@Test func authenticatedRequestIncludesAuthForMatchingHost() {
let client = ForgejoClient(
serverURL: "https://forgejo.example.com",
username: "user",
password: "pass",
password: "pass"
)
let url = try #require(URL(string: "https://forgejo.example.com/api/v1/user"))
let url = URL(string: "https://forgejo.example.com/api/v1/user")!
let request = client.authenticatedRequest(url: url)
#expect(request.value(forHTTPHeaderField: "Authorization") != nil)
}
@Test func authenticatedRequestOmitsAuthForDifferentHost() throws {
@Test func authenticatedRequestOmitsAuthForDifferentHost() {
let client = ForgejoClient(
serverURL: "https://forgejo.example.com",
username: "user",
password: "pass",
password: "pass"
)
let url = try #require(URL(string: "https://evil.example.com/api/v1/user"))
let url = URL(string: "https://evil.example.com/api/v1/user")!
let request = client.authenticatedRequest(url: url)
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
}
@Test func authenticatedRequestMatchesCaseInsensitiveHost() throws {
@Test func authenticatedRequestMatchesCaseInsensitiveHost() {
let client = ForgejoClient(
serverURL: "https://Forgejo.Example.COM",
username: "user",
password: "pass",
password: "pass"
)
let url = try #require(URL(string: "https://forgejo.example.com/api/v1/repos"))
let url = URL(string: "https://forgejo.example.com/api/v1/repos")!
let request = client.authenticatedRequest(url: url)
#expect(request.value(forHTTPHeaderField: "Authorization") != nil)
}
@Test func authenticatedRequestSetsMethodAndBody() throws {
@Test func authenticatedRequestSetsMethodAndBody() {
let client = ForgejoClient(
serverURL: "https://forgejo.example.com",
username: "user",
password: "pass",
password: "pass"
)
let url = try #require(URL(string: "https://forgejo.example.com/api/v1/repos"))
let url = 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")
@ -114,7 +116,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")
@ -124,11 +126,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"))
@ -140,7 +142,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,15 +1,16 @@
@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 #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
let dict = try 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,8 +1,9 @@
@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 Tests
swiftlint lint Sources
format:
swiftformat Sources Tests
swiftformat Sources
# Tag and push a new release: just release 1.0.0
release version: