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