@testable import ForgejoKit import Foundation import Testing struct ModelDecodingTests { private func decoder() -> JSONDecoder { let jsonDecoder = JSONDecoder() jsonDecoder.dateDecodingStrategy = forgejoDateDecodingStrategy return jsonDecoder } // MARK: - User @Test func `decodes user`() throws { let json = """ { "id": 1, "login": "admin", "full_name": "Admin User", "email": "admin@example.com", "avatar_url": "https://example.com/avatar.png", "is_admin": true, "created": "2024-01-01T00:00:00Z" } """ let user = try decoder().decode(User.self, from: Data(json.utf8)) #expect(user.id == 1) #expect(user.login == "admin") #expect(user.fullName == "Admin User") #expect(user.email == "admin@example.com") #expect(user.isAdmin == true) } @Test func `decodes user with minimal fields`() throws { let json = """ { "id": 2, "login": "bot" } """ let user = try decoder().decode(User.self, from: Data(json.utf8)) #expect(user.id == 2) #expect(user.login == "bot") #expect(user.fullName == nil) #expect(user.email == nil) } // MARK: - IssueLabel @Test func `decodes issue label`() throws { let json = """ { "id": 10, "name": "bug", "color": "ee0701", "description": "Something is broken" } """ let label = try decoder().decode(IssueLabel.self, from: Data(json.utf8)) #expect(label.id == 10) #expect(label.name == "bug") #expect(label.color == "ee0701") } // MARK: - IssueComment @Test func `decodes issue comment`() throws { let json = """ { "id": 100, "body": "Looks good!", "user": {"id": 1, "login": "reviewer"}, "created_at": "2024-05-01T12:00:00Z", "updated_at": "2024-05-01T12:00:00Z" } """ let comment = try decoder().decode(IssueComment.self, from: Data(json.utf8)) #expect(comment.id == 100) #expect(comment.body == "Looks good!") #expect(comment.user.login == "reviewer") } // MARK: - Issue @Test func `decodes issue`() throws { let json = """ { "id": 50, "number": 7, "title": "Fix login bug", "body": "The login button does not work", "state": "open", "user": {"id": 1, "login": "reporter"}, "labels": [{"id": 1, "name": "bug", "color": "ff0000"}], "assignees": [], "created_at": "2024-02-01T09:00:00Z", "updated_at": "2024-02-02T10:00:00Z", "comments": 3 } """ let issue = try decoder().decode(Issue.self, from: Data(json.utf8)) #expect(issue.number == 7) #expect(issue.title == "Fix login bug") #expect(issue.state == "open") #expect(issue.labels.count == 1) #expect(issue.comments == 3) #expect(issue.repository == nil) } @Test func `decodes issue with milestone and assignees`() throws { let json = """ { "id": 56, "number": 13, "title": "Issue with metadata", "body": "Has milestone and assignees", "state": "open", "user": {"id": 1, "login": "reporter"}, "labels": [{"id": 1, "name": "bug", "color": "ff0000"}], "milestone": { "id": 5, "title": "v1.0", "description": "First release", "state": "open", "due_on": "2026-03-01T00:00:00Z" }, "assignees": [ {"id": 2, "login": "dev1", "full_name": "Developer One"}, {"id": 3, "login": "dev2"} ], "created_at": "2024-02-01T09:00:00Z", "updated_at": "2024-02-02T10:00:00Z", "comments": 0 } """ let issue = try decoder().decode(Issue.self, from: Data(json.utf8)) #expect(issue.number == 13) #expect(issue.labels.count == 1) #expect(issue.milestone?.id == 5) #expect(issue.milestone?.title == "v1.0") #expect(issue.milestone?.description == "First release") #expect(issue.milestone?.state == "open") #expect(issue.milestone?.dueOn != nil) #expect(issue.assignees?.count == 2) #expect(issue.assignees?[0].login == "dev1") #expect(issue.assignees?[0].fullName == "Developer One") #expect(issue.assignees?[1].login == "dev2") } @Test func `decodes issue with nil milestone and empty assignees`() throws { let json = """ { "id": 57, "number": 14, "title": "Issue without metadata", "state": "open", "user": {"id": 1, "login": "reporter"}, "labels": [], "milestone": null, "assignees": [], "created_at": "2024-02-01T09:00:00Z", "updated_at": "2024-02-02T10:00:00Z", "comments": 0 } """ let issue = try decoder().decode(Issue.self, from: Data(json.utf8)) #expect(issue.milestone == nil) #expect(issue.assignees?.isEmpty == true) } @Test func `decodes issue milestone`() throws { let json = """ { "id": 1, "title": "v2.0", "description": "Second major release", "state": "open", "due_on": "2026-06-15T00:00:00Z", "closed_at": null } """ let milestone = try decoder().decode(IssueMilestone.self, from: Data(json.utf8)) #expect(milestone.id == 1) #expect(milestone.title == "v2.0") #expect(milestone.description == "Second major release") #expect(milestone.state == "open") #expect(milestone.dueOn != nil) #expect(milestone.closedAt == nil) } @Test func `decodes issue milestone with minimal fields`() throws { let json = """ { "id": 2, "title": "Backlog", "state": "open" } """ let milestone = try decoder().decode(IssueMilestone.self, from: Data(json.utf8)) #expect(milestone.id == 2) #expect(milestone.title == "Backlog") #expect(milestone.description == nil) #expect(milestone.dueOn == nil) #expect(milestone.closedAt == nil) } @Test func `decodes issue with minimal repository`() throws { let json = """ { "id": 52, "number": 9, "title": "Search result issue", "state": "open", "user": {"id": 1, "login": "reporter"}, "labels": [], "created_at": "2024-02-01T09:00:00Z", "updated_at": "2024-02-02T10:00:00Z", "comments": 1, "repository": { "id": 10, "name": "backend", "full_name": "org/backend" } } """ let issue = try decoder().decode(Issue.self, from: Data(json.utf8)) #expect(issue.number == 9) #expect(issue.repository?.id == 10) #expect(issue.repository?.fullName == "org/backend") #expect(issue.repository?.name == "backend") #expect(issue.repository?.empty == nil) #expect(issue.repository?.starsCount == nil) #expect(issue.repository?.defaultBranch == nil) } @Test func `decodes issue with repository`() throws { let json = """ { "id": 51, "number": 8, "title": "Add dark mode", "state": "open", "user": {"id": 1, "login": "reporter"}, "labels": [], "created_at": "2024-02-01T09:00:00Z", "updated_at": "2024-02-02T10:00:00Z", "comments": 0, "repository": { "id": 1, "name": "my-repo", "full_name": "user/my-repo", "empty": false, "private": false, "fork": false, "mirror": false, "size": 1024, "html_url": "https://forgejo.example.com/user/my-repo", "ssh_url": "git@forgejo.example.com:user/my-repo.git", "clone_url": "https://forgejo.example.com/user/my-repo.git", "stars_count": 5, "forks_count": 2, "watchers_count": 3, "open_issues_count": 1, "open_pr_counter": 0, "release_counter": 1, "default_branch": "main", "archived": false, "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-06-01T00:00:00Z", "has_issues": true, "has_wiki": false, "has_pull_requests": true, "has_projects": false, "has_releases": true, "has_packages": false, "has_actions": false, "template": false } } """ let issue = try decoder().decode(Issue.self, from: Data(json.utf8)) #expect(issue.number == 8) #expect(issue.title == "Add dark mode") #expect(issue.repository?.fullName == "user/my-repo") } @Test func `decodes issue with merged pull request field`() throws { let json = """ { "id": 53, "number": 10, "title": "Add dark mode support", "state": "closed", "user": {"id": 1, "login": "dev"}, "labels": [], "created_at": "2024-03-01T09:00:00Z", "updated_at": "2024-03-05T10:00:00Z", "comments": 2, "pull_request": { "merged": true, "merged_at": "2024-03-05T10:00:00Z" }, "repository": { "id": 10, "name": "app", "full_name": "org/app" } } """ let issue = try decoder().decode(Issue.self, from: Data(json.utf8)) #expect(issue.number == 10) #expect(issue.state == "closed") #expect(issue.pullRequest?.merged == true) #expect(issue.pullRequest?.mergedAt != nil) #expect(issue.repository?.fullName == "org/app") } @Test func `decodes issue with open pull request field`() throws { let json = """ { "id": 54, "number": 11, "title": "WIP: refactor auth", "state": "open", "user": {"id": 2, "login": "contributor"}, "labels": [{"id": 1, "name": "WIP", "color": "fbca04"}], "created_at": "2024-04-01T09:00:00Z", "updated_at": "2024-04-02T10:00:00Z", "comments": 0, "pull_request": { "merged": false }, "repository": { "id": 10, "name": "app", "full_name": "org/app" } } """ let issue = try decoder().decode(Issue.self, from: Data(json.utf8)) #expect(issue.number == 11) #expect(issue.state == "open") #expect(issue.pullRequest?.merged == false) #expect(issue.pullRequest?.mergedAt == nil) #expect(issue.labels.count == 1) #expect(issue.comments == 0) } @Test func `decodes issue with minimal pull request field`() throws { let json = """ { "id": 55, "number": 12, "title": "Quick fix", "state": "closed", "user": {"id": 1, "login": "dev"}, "labels": [], "created_at": "2024-04-10T09:00:00Z", "updated_at": "2024-04-10T10:00:00Z", "comments": 1, "pull_request": {} } """ let issue = try decoder().decode(Issue.self, from: Data(json.utf8)) #expect(issue.number == 12) #expect(issue.pullRequest != nil) #expect(issue.pullRequest?.merged == nil) #expect(issue.pullRequest?.mergedAt == nil) #expect(issue.repository == nil) } // MARK: - PullRequest @Test func `decodes pull request`() throws { let json = """ { "id": 200, "number": 42, "title": "Add feature X", "body": "Implements feature X", "state": "open", "user": {"id": 1, "login": "dev"}, "labels": [], "head": { "label": "dev:feature-x", "ref": "feature-x", "sha": "abc123" }, "base": { "label": "dev:main", "ref": "main", "sha": "def456" }, "mergeable": true, "merged": false, "draft": false, "comments": 1, "created_at": "2024-03-01T08:00:00Z", "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) } @Test func `decodes pull request with full metadata`() throws { let json = """ { "id": 204, "number": 46, "title": "PR with full metadata", "body": "Has labels, milestone, assignees, and reviewers", "state": "open", "user": {"id": 1, "login": "dev"}, "labels": [ {"id": 1, "name": "enhancement", "color": "0075ca"}, {"id": 2, "name": "review-needed", "color": "e4e669"} ], "milestone": { "id": 3, "title": "v1.0", "description": "First release", "state": "open", "due_on": "2026-03-01T00:00:00Z" }, "assignees": [ {"id": 2, "login": "assignee1", "full_name": "Assignee One"}, {"id": 3, "login": "assignee2"} ], "requested_reviewers": [ {"id": 4, "login": "reviewer1"} ], "head": {"label": "dev:feature", "ref": "feature", "sha": "aaa"}, "base": {"label": "dev:main", "ref": "main", "sha": "bbb"}, "mergeable": true, "merged": false, "draft": false, "comments": 0, "created_at": "2024-03-01T08:00:00Z", "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") } @Test func `decodes pull request with requested reviewers`() throws { let json = """ { "id": 202, "number": 44, "title": "PR with reviewers", "state": "open", "user": {"id": 1, "login": "dev"}, "labels": [], "head": {"label": "dev:feature", "ref": "feature", "sha": "aaa"}, "base": {"label": "dev:main", "ref": "main", "sha": "bbb"}, "mergeable": true, "merged": false, "draft": false, "comments": 0, "created_at": "2024-03-01T08:00:00Z", "updated_at": "2024-03-02T09:00:00Z", "requested_reviewers": [ {"id": 10, "login": "reviewer1"}, {"id": 11, "login": "reviewer2", "full_name": "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 `decodes pull request without requested reviewers`() throws { let json = """ { "id": 203, "number": 45, "title": "PR without reviewers", "state": "open", "user": {"id": 1, "login": "dev"}, "labels": [], "head": {"label": "dev:fix", "ref": "fix", "sha": "ccc"}, "base": {"label": "dev:main", "ref": "main", "sha": "ddd"}, "comments": 0, "created_at": "2024-03-01T08:00:00Z", "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) } @Test func `decodes merged pull request`() throws { let json = """ { "id": 201, "number": 43, "title": "Merged PR", "state": "closed", "user": {"id": 1, "login": "dev"}, "labels": [], "head": {"label": "dev:feat", "ref": "feat", "sha": "aaa"}, "base": {"label": "dev:main", "ref": "main", "sha": "bbb"}, "merged": true, "merged_by": {"id": 2, "login": "maintainer"}, "merged_at": "2024-04-01T12:00:00Z", "comments": 0, "created_at": "2024-03-01T08:00:00Z", "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) } // MARK: - PullRequestReview @Test func `decodes pull request review`() throws { let json = """ { "id": 300, "body": "Approved with minor comments", "user": {"id": 5, "login": "reviewer"}, "state": "APPROVED", "comments_count": 2, "submitted_at": "2024-04-10T15:00:00Z", "updated_at": "2024-04-10T15:00:00Z", "commit_id": "abc123", "stale": false, "official": true, "dismissed": false } """ let review = try decoder().decode(PullRequestReview.self, from: Data(json.utf8)) #expect(review.id == 300) #expect(review.state == "APPROVED") #expect(review.user?.login == "reviewer") #expect(review.commentsCount == 2) #expect(review.official == true) } @Test func `decodes review with nullable fields`() throws { let json = """ { "id": 301, "state": "COMMENT" } """ let review = try decoder().decode(PullRequestReview.self, from: Data(json.utf8)) #expect(review.id == 301) #expect(review.body == nil) #expect(review.user == nil) #expect(review.commentsCount == nil) } // MARK: - ReviewComment @Test func `decodes review comment`() throws { let json = """ { "id": 400, "body": "This line needs a guard clause", "user": {"id": 5, "login": "reviewer"}, "path": "Sources/main.swift", "diff_hunk": "@@ -10,5 +10,7 @@", "position": 15, "original_position": 10, "created_at": "2024-04-10T15:30:00Z", "updated_at": "2024-04-10T15:30:00Z" } """ let comment = try decoder().decode(ReviewComment.self, from: Data(json.utf8)) #expect(comment.id == 400) #expect(comment.path == "Sources/main.swift") #expect(comment.position == 15) #expect(comment.originalPosition == 10) } // MARK: - CreateReviewComment encoding @Test func `encodes create review comment`() throws { let comment = CreateReviewComment( body: "Fix this", path: "file.swift", oldPosition: 5, newPosition: 7, ) let data = try JSONEncoder().encode(comment) 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) #expect(dict["new_position"] as? Int == 7) } @Test func `encodes create review comment with nil positions`() throws { let comment = CreateReviewComment( body: "Note", path: "readme.md", oldPosition: nil, newPosition: nil, ) let data = try JSONEncoder().encode(comment) let dict = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) #expect(dict["old_position"] == nil) #expect(dict["new_position"] == nil) } // MARK: - Repository @Test func `decodes repository`() throws { let json = """ { "id": 1, "name": "my-repo", "full_name": "user/my-repo", "empty": false, "private": false, "fork": false, "mirror": false, "size": 1024, "html_url": "https://forgejo.example.com/user/my-repo", "ssh_url": "git@forgejo.example.com:user/my-repo.git", "clone_url": "https://forgejo.example.com/user/my-repo.git", "stars_count": 5, "forks_count": 2, "watchers_count": 3, "open_issues_count": 1, "open_pr_counter": 0, "release_counter": 1, "default_branch": "main", "archived": false, "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-06-01T00:00:00Z", "has_issues": true, "has_wiki": false, "has_pull_requests": true, "has_projects": false, "has_releases": true, "has_packages": false, "has_actions": false, "template": false } """ let repo = try decoder().decode(Repository.self, from: Data(json.utf8)) #expect(repo.name == "my-repo") #expect(repo.fullName == "user/my-repo") #expect(repo.empty != nil) #expect(repo.starsCount != nil) #expect(repo.starsCount == 5) #expect(repo.defaultBranch == "main") #expect(repo.hasPullRequests == true) #expect(repo.createdAt != nil) #expect(repo.updatedAt != nil) } @Test func `decodes repository with minimal fields`() throws { let json = """ { "id": 10, "name": "backend", "full_name": "org/backend" } """ let repo = try decoder().decode(Repository.self, from: Data(json.utf8)) #expect(repo.id == 10) #expect(repo.name == "backend") #expect(repo.fullName == "org/backend") #expect(repo.empty == nil) #expect(repo.private == nil) #expect(repo.fork == nil) #expect(repo.mirror == nil) #expect(repo.size == nil) #expect(repo.htmlUrl == nil) #expect(repo.sshUrl == nil) #expect(repo.cloneUrl == nil) #expect(repo.starsCount == nil) #expect(repo.forksCount == nil) #expect(repo.watchersCount == nil) #expect(repo.openIssuesCount == nil) #expect(repo.defaultBranch == nil) #expect(repo.archived == nil) #expect(repo.createdAt == nil) #expect(repo.updatedAt == nil) #expect(repo.hasIssues == nil) #expect(repo.hasPullRequests == nil) #expect(repo.hasReleases == nil) #expect(repo.hasPackages == nil) #expect(repo.hasActions == nil) #expect(repo.template == nil) } @Test func `decodes repository with partial fields`() throws { let json = """ { "id": 11, "name": "frontend", "full_name": "org/frontend", "private": true, "html_url": "https://forgejo.example.com/org/frontend", "stars_count": 12, "default_branch": "develop" } """ let repo = try decoder().decode(Repository.self, from: Data(json.utf8)) #expect(repo.id == 11) #expect(repo.fullName == "org/frontend") #expect(repo.private == true) #expect(repo.htmlUrl == "https://forgejo.example.com/org/frontend") #expect(repo.starsCount == 12) #expect(repo.defaultBranch == "develop") #expect(repo.empty == nil) #expect(repo.forksCount == nil) } // MARK: - NotificationThread @Test func `decodes notification thread`() throws { let json = """ { "id": 1, "unread": true, "pinned": false, "updated_at": "2024-06-15T14:30:00Z", "url": "https://forgejo.example.com/api/v1/notifications/threads/1", "subject": { "type": "Issue", "title": "Fix login bug", "url": "https://forgejo.example.com/api/v1/repos/org/app/issues/42", "html_url": "https://forgejo.example.com/org/app/issues/42", "state": "open", "latest_comment_url": "https://forgejo.example.com/api/v1/repos/org/app/issues/comments/100", "latest_comment_html_url": "https://forgejo.example.com/org/app/issues/42#issuecomment-100" }, "repository": { "id": 10, "name": "app", "full_name": "org/app" } } """ let notification = try decoder().decode(NotificationThread.self, from: Data(json.utf8)) #expect(notification.id == 1) #expect(notification.unread == true) #expect(notification.pinned == false) #expect(notification.subject.type == "Issue") #expect(notification.subject.title == "Fix login bug") #expect(notification.subject.state == "open") #expect(notification.subject.htmlUrl == "https://forgejo.example.com/org/app/issues/42") #expect(notification.subject.latestCommentUrl != nil) #expect(notification.subject.latestCommentHtmlUrl != nil) #expect(notification.repository.fullName == "org/app") } @Test func `decodes notification subject types`() throws { let issueJson = """ { "id": 2, "unread": true, "pinned": false, "updated_at": "2024-06-15T14:30:00Z", "subject": { "type": "Issue", "title": "Bug report", "state": "open" }, "repository": {"id": 1, "name": "repo", "full_name": "user/repo"} } """ let pullJson = """ { "id": 3, "unread": false, "pinned": false, "updated_at": "2024-06-15T15:00:00Z", "subject": { "type": "Pull", "title": "Add feature", "state": "closed" }, "repository": {"id": 1, "name": "repo", "full_name": "user/repo"} } """ let commitJson = """ { "id": 4, "unread": true, "pinned": false, "updated_at": "2024-06-15T16:00:00Z", "subject": { "type": "Commit", "title": "abc123" }, "repository": {"id": 1, "name": "repo", "full_name": "user/repo"} } """ let issue = try decoder().decode(NotificationThread.self, from: Data(issueJson.utf8)) #expect(issue.subject.type == "Issue") #expect(issue.subject.state == "open") let pull = try decoder().decode(NotificationThread.self, from: Data(pullJson.utf8)) #expect(pull.subject.type == "Pull") #expect(pull.subject.state == "closed") #expect(pull.unread == false) let commit = try decoder().decode(NotificationThread.self, from: Data(commitJson.utf8)) #expect(commit.subject.type == "Commit") #expect(commit.subject.state == nil) } @Test func `decodes notification count`() throws { let json = """ {"new": 5} """ let count = try decoder().decode(NotificationCount.self, from: Data(json.utf8)) #expect(count.new == 5) } @Test func `decodes notification with minimal fields`() throws { let json = """ { "id": 10, "unread": false, "pinned": false, "updated_at": "2024-06-15T14:30:00Z", "subject": { "type": "Repository", "title": "New release" }, "repository": {"id": 5, "name": "lib", "full_name": "org/lib"} } """ let notification = try decoder().decode(NotificationThread.self, from: Data(json.utf8)) #expect(notification.id == 10) #expect(notification.unread == false) #expect(notification.url == nil) #expect(notification.subject.url == nil) #expect(notification.subject.htmlUrl == nil) #expect(notification.subject.state == nil) #expect(notification.subject.latestCommentUrl == nil) #expect(notification.subject.latestCommentHtmlUrl == nil) } // MARK: - Commit @Test func `decodes commit`() throws { let json = """ { "sha": "abc123def456", "url": "https://forgejo.example.com/api/v1/repos/user/repo/git/commits/abc123def456", "html_url": "https://forgejo.example.com/user/repo/commit/abc123def456", "commit": { "url": "https://forgejo.example.com/api/v1/repos/user/repo/git/commits/abc123def456", "message": "Add new feature\\n\\nDetailed description of the change.", "author": { "name": "Test User", "email": "test@example.com", "date": "2024-06-01T10:30:00Z" }, "committer": { "name": "Test User", "email": "test@example.com", "date": "2024-06-01T10:30:00Z" } }, "author": {"id": 1, "login": "testuser"}, "committer": {"id": 1, "login": "testuser"}, "parents": [ { "sha": "parent123", "url": "https://forgejo.example.com/api/v1/repos/user/repo/git/commits/parent123", "html_url": "https://forgejo.example.com/user/repo/commit/parent123" } ] } """ let commit = try decoder().decode(Commit.self, from: Data(json.utf8)) #expect(commit.sha == "abc123def456") #expect(commit.htmlUrl == "https://forgejo.example.com/user/repo/commit/abc123def456") #expect(commit.commit.message == "Add new feature\n\nDetailed description of the change.") #expect(commit.commit.author?.name == "Test User") #expect(commit.commit.author?.email == "test@example.com") #expect(commit.commit.author?.date != nil) #expect(commit.commit.committer?.name == "Test User") #expect(commit.author?.login == "testuser") #expect(commit.committer?.login == "testuser") #expect(commit.parents?.count == 1) #expect(commit.parents?[0].sha == "parent123") #expect(commit.id == "abc123def456") } @Test func `decodes commit with minimal fields`() throws { let json = """ { "sha": "minimal123", "commit": { "message": "Quick fix" } } """ let commit = try decoder().decode(Commit.self, from: Data(json.utf8)) #expect(commit.sha == "minimal123") #expect(commit.commit.message == "Quick fix") #expect(commit.commit.author == nil) #expect(commit.commit.committer == nil) #expect(commit.author == nil) #expect(commit.committer == nil) #expect(commit.parents == nil) #expect(commit.htmlUrl == nil) #expect(commit.url == nil) } @Test func `decodes commit with null author`() throws { let json = """ { "sha": "noauthor456", "commit": { "message": "Automated commit", "author": { "name": "bot", "email": "bot@noreply.example.com", "date": "2024-07-15T08:00:00Z" } }, "author": null, "committer": null, "parents": [] } """ let commit = try decoder().decode(Commit.self, from: Data(json.utf8)) #expect(commit.sha == "noauthor456") #expect(commit.commit.author?.name == "bot") #expect(commit.author == nil) #expect(commit.committer == nil) #expect(commit.parents?.isEmpty == true) } @Test func `decodes commit list`() throws { let json = """ [ { "sha": "first111", "commit": {"message": "First commit"}, "parents": [] }, { "sha": "second222", "commit": {"message": "Second commit"}, "parents": [{"sha": "first111"}] } ] """ let commits = try decoder().decode([Commit].self, from: Data(json.utf8)) #expect(commits.count == 2) #expect(commits[0].sha == "first111") #expect(commits[1].sha == "second222") #expect(commits[1].parents?[0].sha == "first111") } // MARK: - RepositoryContent @Test func `decodes repository content`() throws { let json = """ { "name": "README.md", "path": "README.md", "sha": "abc123", "size": 256, "url": "https://example.com/api/v1/repos/u/r/contents/README.md", "html_url": "https://example.com/u/r/src/branch/main/README.md", "git_url": "https://example.com/api/v1/repos/u/r/git/blobs/abc123", "type": "file" } """ let content = try decoder().decode(RepositoryContent.self, from: Data(json.utf8)) #expect(content.name == "README.md") #expect(content.type == .file) #expect(content.id == "README.md") } @Test func `decodes directory content`() throws { let json = """ { "name": "src", "path": "src", "sha": "def456", "size": 0, "url": "https://example.com/api", "html_url": "https://example.com/html", "git_url": "https://example.com/git", "type": "dir" } """ let content = try decoder().decode(RepositoryContent.self, from: Data(json.utf8)) #expect(content.type == .dir) } // MARK: - Branch @Test func `decodes branch`() throws { let json = """ { "name": "main", "commit": { "id": "abc123", "message": "Initial commit", "url": "https://example.com/commit/abc123" }, "protected": true } """ let branch = try decoder().decode(Branch.self, from: Data(json.utf8)) #expect(branch.name == "main") #expect(branch.commit.message == "Initial commit") #expect(branch.protected == true) #expect(branch.id == "main") } // MARK: - Repository Equatable / Hashable @Test func `repositories with same id are equal`() throws { let json1 = """ {"id": 42, "name": "repo-a", "full_name": "owner/repo-a", "owner": {"id": 1, "login": "owner"}, "html_url": "https://example.com/owner/repo-a", "description": "", "empty": false, "private": false, "fork": false, "mirror": false, "archived": false, "stars_count": 0, "forks_count": 0, "open_issues_count": 0, "open_pr_counter": 0, "has_issues": true, "has_pull_requests": true, "default_branch": "main", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z"} """ let json2 = """ {"id": 42, "name": "repo-b", "full_name": "owner/repo-b", "owner": {"id": 1, "login": "owner"}, "html_url": "https://example.com/owner/repo-b", "description": "different", "empty": false, "private": false, "fork": false, "mirror": false, "archived": false, "stars_count": 5, "forks_count": 1, "open_issues_count": 3, "open_pr_counter": 0, "has_issues": true, "has_pull_requests": true, "default_branch": "main", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z"} """ let repo1 = try decoder().decode(Repository.self, from: Data(json1.utf8)) let repo2 = try decoder().decode(Repository.self, from: Data(json2.utf8)) #expect(repo1 == repo2) #expect(repo1.hashValue == repo2.hashValue) } @Test func `repositories with different ids are not equal`() throws { let json1 = """ {"id": 1, "name": "repo", "full_name": "owner/repo", "owner": {"id": 1, "login": "owner"}, "html_url": "https://example.com/owner/repo", "description": "", "empty": false, "private": false, "fork": false, "mirror": false, "archived": false, "stars_count": 0, "forks_count": 0, "open_issues_count": 0, "open_pr_counter": 0, "has_issues": true, "has_pull_requests": true, "default_branch": "main", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z"} """ let json2 = """ {"id": 2, "name": "repo", "full_name": "owner/repo", "owner": {"id": 1, "login": "owner"}, "html_url": "https://example.com/owner/repo", "description": "", "empty": false, "private": false, "fork": false, "mirror": false, "archived": false, "stars_count": 0, "forks_count": 0, "open_issues_count": 0, "open_pr_counter": 0, "has_issues": true, "has_pull_requests": true, "default_branch": "main", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z"} """ let repo1 = try decoder().decode(Repository.self, from: Data(json1.utf8)) let repo2 = try decoder().decode(Repository.self, from: Data(json2.utf8)) #expect(repo1 != repo2) } // MARK: - RepositoryPermissions @Test func `decodes repository with full permissions`() throws { let json = """ { "id": 20, "name": "collab-repo", "full_name": "org/collab-repo", "permissions": { "admin": true, "push": true, "pull": true } } """ let repo = try decoder().decode(Repository.self, from: Data(json.utf8)) #expect(repo.permissions?.admin == true) #expect(repo.permissions?.push == true) #expect(repo.permissions?.pull == true) } @Test func `decodes repository with read only permissions`() throws { let json = """ { "id": 21, "name": "public-repo", "full_name": "org/public-repo", "permissions": { "admin": false, "push": false, "pull": true } } """ let repo = try decoder().decode(Repository.self, from: Data(json.utf8)) #expect(repo.permissions?.admin == false) #expect(repo.permissions?.push == false) #expect(repo.permissions?.pull == true) } @Test func `decodes repository with missing permissions`() throws { let json = """ { "id": 22, "name": "no-perms", "full_name": "org/no-perms" } """ let repo = try decoder().decode(Repository.self, from: Data(json.utf8)) #expect(repo.permissions == nil) } // MARK: - Token @Test func `decodes token`() throws { let json = """ { "id": 1, "name": "integration-test-token", "sha1": "abc123def456789", "token_last_eight": "56789abc" } """ let token = try decoder().decode(Token.self, from: Data(json.utf8)) #expect(token.id == 1) #expect(token.name == "integration-test-token") #expect(token.sha1 == "abc123def456789") #expect(token.tokenLastEight == "56789abc") } @Test func `decodes token with minimal fields`() throws { let json = """ { "id": 2, "name": "test", "sha1": "xyz" } """ let token = try decoder().decode(Token.self, from: Data(json.utf8)) #expect(token.id == 2) #expect(token.name == "test") #expect(token.sha1 == "xyz") #expect(token.tokenLastEight == nil) } // MARK: - WorkflowRun (Forgejo's ActionRun schema) @Test func `decodes workflow run with full fields`() throws { let json = """ { "id": 100, "title": "Fix bug in login", "status": "success", "event": "push", "trigger_event": "push", "workflow_id": "ci.yml", "index_in_repo": 42, "commit_sha": "abc123def456", "prettyref": "main", "is_ref_deleted": false, "is_fork_pull_request": false, "need_approval": false, "approved_by": 0, "ScheduleID": 0, "created": "2024-05-01T10:00:00Z", "updated": "2024-05-01T10:05:00Z", "started": "2024-05-01T10:00:30Z", "stopped": "2024-05-01T10:04:30Z", "duration": 240000000000, "html_url": "https://example.com/o/r/actions/runs/100", "trigger_user": {"id": 1, "login": "alice"} } """ let run = try decoder().decode(WorkflowRun.self, from: Data(json.utf8)) #expect(run.id == 100) #expect(run.title == "Fix bug in login") #expect(run.status == "success") #expect(run.event == "push") #expect(run.workflowId == "ci.yml") #expect(run.indexInRepo == 42) #expect(run.commitSha == "abc123def456") #expect(run.prettyRef == "main") #expect(run.htmlUrl == "https://example.com/o/r/actions/runs/100") #expect(run.triggerUser?.login == "alice") #expect(run.durationNanos == 240_000_000_000) } @Test func `decodes workflow run with minimal fields`() throws { // Minimum the Forgejo API guarantees for an in-flight run: id and status. let json = """ { "id": 101, "status": "running" } """ let run = try decoder().decode(WorkflowRun.self, from: Data(json.utf8)) #expect(run.status == "running") #expect(run.indexInRepo == nil) #expect(run.title == nil) #expect(run.commitSha == nil) #expect(run.workflowId == nil) } // MARK: - WorkflowRunView (experimental web-route response) @Test func `decodes workflow run view`() throws { let json = """ { "state": { "run": { "title": "ci", "status": "success", "done": true, "preExecutionError": "", "jobs": [ {"id": 11, "name": "build", "status": "success", "duration": "1m 23s"}, {"id": 12, "name": "test", "status": "failure", "duration": "45s"} ] }, "currentJob": { "title": "build", "steps": [ {"summary": "Set up job", "duration": "5s", "status": "success"}, {"summary": "Run script", "duration": "1m 18s", "status": "success"} ] } }, "logs": { "stepsLog": [ { "step": 1, "cursor": 200, "started": 1700000000, "lines": [ {"index": 0, "message": "hello", "timestamp": 1700000000.123}, {"index": 1, "message": "world", "timestamp": 1700000000.456} ] } ] } } """ let view = try decoder().decode(WorkflowRunView.self, from: Data(json.utf8)) #expect(view.state.run.title == "ci") #expect(view.state.run.jobs.count == 2) #expect(view.state.run.jobs.first?.duration == "1m 23s") #expect(view.state.currentJob.title == "build") #expect(view.state.currentJob.steps.count == 2) #expect(view.logs.stepsLog.first?.lines.count == 2) #expect(view.logs.stepsLog.first?.lines.first?.message == "hello") } @Test func `decodes workflow run view with empty logs`() throws { let json = """ { "state": { "run": { "title": "ci", "status": "running", "done": false, "jobs": [] }, "currentJob": { "title": "", "steps": [] } }, "logs": { "stepsLog": [] } } """ let view = try decoder().decode(WorkflowRunView.self, from: Data(json.utf8)) #expect(view.state.run.jobs.isEmpty) #expect(view.logs.stepsLog.isEmpty) } // MARK: - DecodingError description @Test func `decoding error at root shows root`() { // Decoding a String where an object is expected produces a root-level error let json = #""not-an-object""# do { _ = try decoder().decode(User.self, from: Data(json.utf8)) Issue.record("Expected decoding to fail") } catch let error as DecodingError { let description = describeDecodingError(error) #expect(description.contains("root")) } catch { Issue.record("Unexpected error type: \(error)") } } }