mirror of
https://codeberg.org/secana/ForgejoKit.git
synced 2026-06-16 05:13:53 -07:00
Compare commits
No commits in common. "main" and "0.7.0" have entirely different histories.
15 changed files with 76 additions and 476 deletions
|
|
@ -1,3 +1,2 @@
|
|||
--swiftversion 6.2
|
||||
--disable redundantMemberwiseInit
|
||||
--disable swiftTestingTestCaseNames
|
||||
|
|
|
|||
|
|
@ -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.7.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ 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)
|
||||
|
|
@ -204,7 +204,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 +372,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 +413,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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,7 +114,7 @@ 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 body = try client.encodeRequestBody(ViewRequestBody(logCursors: logCursors))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -33,14 +33,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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue