ForgejoKit/Tests/ForgejoKitTests/ModelDecodingTests.swift

1302 lines
46 KiB
Swift
Raw Normal View History

import Foundation
import Testing
@testable import ForgejoKit
struct ModelDecodingTests {
private func decoder() -> JSONDecoder {
let d = JSONDecoder()
d.dateDecodingStrategy = forgejoDateDecodingStrategy
return d
}
// MARK: - User
@Test func decodesUser() 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 decodesUserWithMinimalFields() 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 decodesIssueLabel() 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 decodesIssueComment() 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 decodesIssue() 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 decodesIssueWithMilestoneAndAssignees() 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 decodesIssueWithNilMilestoneAndEmptyAssignees() 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 decodesIssueMilestone() 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 decodesIssueMilestoneWithMinimalFields() 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 decodesIssueWithMinimalRepository() 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 decodesIssueWithRepository() 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 decodesIssueWithMergedPullRequestField() 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 decodesIssueWithOpenPullRequestField() 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 decodesIssueWithMinimalPullRequestField() 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 decodesPullRequest() 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 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 {
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 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 {
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 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 {
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 pr = try decoder().decode(PullRequest.self, from: Data(json.utf8))
#expect(pr.number == 45)
#expect(pr.requestedReviewers == nil)
}
@Test func decodesMergedPullRequest() 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 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
@Test func decodesPullRequestReview() 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 decodesReviewWithNullableFields() 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 decodesReviewComment() 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 encodesCreateReviewComment() throws {
let comment = CreateReviewComment(
body: "Fix this",
path: "file.swift",
oldPosition: 5,
newPosition: 7
)
let data = try JSONEncoder().encode(comment)
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)
#expect(dict["new_position"] as? Int == 7)
}
@Test func encodesCreateReviewCommentWithNilPositions() throws {
let comment = CreateReviewComment(
body: "Note",
path: "readme.md",
oldPosition: nil,
newPosition: nil
)
let data = try JSONEncoder().encode(comment)
let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any]
#expect(dict["old_position"] == nil)
#expect(dict["new_position"] == nil)
}
// MARK: - Repository
@Test func decodesRepository() 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 decodesRepositoryWithMinimalFields() 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 decodesRepositoryWithPartialFields() 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 decodesNotificationThread() 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 decodesNotificationSubjectTypes() 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 decodesNotificationCount() throws {
let json = """
{"new": 5}
"""
let count = try decoder().decode(NotificationCount.self, from: Data(json.utf8))
#expect(count.new == 5)
}
@Test func decodesNotificationWithMinimalFields() 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 decodesCommit() 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 decodesCommitWithMinimalFields() 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 decodesCommitWithNullAuthor() 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 decodesCommitList() 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 decodesRepositoryContent() 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 decodesDirectoryContent() 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 decodesBranch() 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 repositoriesWithSameIdAreEqual() 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 repositoriesWithDifferentIdsAreNotEqual() 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 decodesRepositoryWithFullPermissions() 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 decodesRepositoryWithReadOnlyPermissions() 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 decodesRepositoryWithMissingPermissions() 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 decodesToken() 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 decodesTokenWithMinimalFields() 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 decodesWorkflowRunWithFullFields() 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 decodesWorkflowRunWithMinimalFields() 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 decodesWorkflowRunView() 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 decodesWorkflowRunViewWithEmptyLogs() 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 decodingErrorAtRootShowsRoot() {
// 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)")
}
}
}