335 lines
14 KiB
Swift
335 lines
14 KiB
Swift
import Foundation
|
|
import Testing
|
|
|
|
@testable import RunnerCore
|
|
|
|
/// Tests for the Gitea API models' JSON mapping.
|
|
///
|
|
/// The fixtures below are written from the shapes Gitea actually serializes,
|
|
/// verified against `modules/structs/repo_actions.go` and
|
|
/// `routers/api/v1/shared/{action,runners}.go` at tag `v1.25.0`, cross-checked
|
|
/// against the published swagger (`ActionWorkflowJobsResponse`,
|
|
/// `ActionRunnersResponse`, `ActionRunner`, `ActionRunnerLabel`). Two details
|
|
/// are easy to get wrong and are pinned here deliberately:
|
|
///
|
|
/// * a job's `labels` is an array of **strings**, but a runner's `labels` is an
|
|
/// array of **objects** (`{id, name, type}`);
|
|
/// * timestamps are Go `time.Time` values, so fractional seconds appear only
|
|
/// when non-zero and an unset time serializes as `0001-01-01T00:00:00Z`.
|
|
@Suite("Gitea models")
|
|
struct GiteaModelsTests {
|
|
|
|
private func decode<T: Decodable>(_ type: T.Type, _ json: String) throws -> T {
|
|
try GiteaClient.makeDecoder().decode(T.self, from: Data(json.utf8))
|
|
}
|
|
|
|
// MARK: - Job status
|
|
|
|
@Test("a job with status 'queued' is schedulable")
|
|
func queuedStatusIsSchedulable() {
|
|
let job = WorkflowJob(id: 1, runID: 2, name: "build", status: "queued", labels: ["macos-arm64"])
|
|
#expect(job.isQueued)
|
|
#expect(job.jobStatus == .queued)
|
|
}
|
|
|
|
@Test("'waiting' means blocked and is never schedulable")
|
|
func waitingIsNotSchedulable() {
|
|
// Gitea maps the external "queued" onto its internal StatusWaiting, and
|
|
// the external "waiting" onto StatusBlocked — a job waiting on a
|
|
// dependency, not on a runner. Booting a VM for one would be pure waste.
|
|
let job = WorkflowJob(id: 1, runID: 2, name: "build", status: "waiting", labels: ["macos-arm64"])
|
|
#expect(!job.isQueued)
|
|
#expect(job.jobStatus == .waiting)
|
|
}
|
|
|
|
@Test("every reported status maps to a case, unknown strings included")
|
|
func statusMapping() {
|
|
#expect(JobStatus(rawValue: "queued") == .queued)
|
|
#expect(JobStatus(rawValue: "waiting") == .waiting)
|
|
#expect(JobStatus(rawValue: "in_progress") == .inProgress)
|
|
#expect(JobStatus(rawValue: "completed") == .completed)
|
|
// A status this build has never heard of must round-trip, not throw:
|
|
// the mapping is Gitea's to change, and a decode failure would stop the
|
|
// poll loop entirely.
|
|
#expect(JobStatus(rawValue: "some_new_status") == .unknown("some_new_status"))
|
|
#expect(JobStatus(rawValue: "some_new_status").rawValue == "some_new_status")
|
|
}
|
|
|
|
// MARK: - Job decoding
|
|
|
|
@Test("a realistic queued-job response decodes")
|
|
func decodeQueuedJobsResponse() throws {
|
|
let json = """
|
|
{
|
|
"total_count": 1,
|
|
"jobs": [
|
|
{
|
|
"id": 4711,
|
|
"url": "https://gitea.example.com/api/v1/repos/acme/widget/actions/jobs/4711",
|
|
"html_url": "https://gitea.example.com/acme/widget/actions/runs/12/jobs/0",
|
|
"run_id": 12,
|
|
"run_url": "https://gitea.example.com/api/v1/repos/acme/widget/actions/runs/12",
|
|
"name": "build (macos)",
|
|
"labels": ["macos-arm64"],
|
|
"run_attempt": 1,
|
|
"head_sha": "0f1e2d3c4b5a69788796a5b4c3d2e1f001234567",
|
|
"head_branch": "main",
|
|
"status": "queued",
|
|
"steps": [],
|
|
"created_at": "2026-08-07T09:15:04Z",
|
|
"started_at": "0001-01-01T00:00:00Z",
|
|
"completed_at": "0001-01-01T00:00:00Z"
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
|
|
let response = try decode(WorkflowJobsResponse.self, json)
|
|
#expect(response.totalCount == 1)
|
|
#expect(response.items.count == 1)
|
|
|
|
let job = try #require(response.items.first)
|
|
#expect(job.id == 4711)
|
|
#expect(job.runID == 12)
|
|
#expect(job.name == "build (macos)")
|
|
#expect(job.status == "queued")
|
|
#expect(job.isQueued)
|
|
#expect(job.labels == ["macos-arm64"])
|
|
// `runner_id` / `runner_name` carry omitempty and are absent while queued.
|
|
#expect(job.runnerID == nil)
|
|
#expect(job.runnerName == nil)
|
|
#expect(job.createdAt != nil)
|
|
// Go's zero time must not surface as a year-1 date.
|
|
#expect(job.startedAt == nil)
|
|
#expect(job.completedAt == nil)
|
|
}
|
|
|
|
@Test("a running job reports its runner")
|
|
func decodeRunningJob() throws {
|
|
let json = """
|
|
{
|
|
"total_count": 1,
|
|
"jobs": [
|
|
{
|
|
"id": 4712,
|
|
"run_id": 12,
|
|
"name": "test",
|
|
"labels": ["macos-arm64", "self-hosted"],
|
|
"status": "in_progress",
|
|
"runner_id": 9,
|
|
"runner_name": "macos-vm-3f1c2f8e-0a4b-4c1d-9e2f-5a6b7c8d9e0f",
|
|
"created_at": "2026-08-07T09:15:04Z",
|
|
"started_at": "2026-08-07T09:16:31.482913Z",
|
|
"completed_at": "0001-01-01T00:00:00Z"
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
|
|
let job = try #require(try decode(WorkflowJobsResponse.self, json).items.first)
|
|
#expect(!job.isQueued)
|
|
#expect(job.jobStatus == .inProgress)
|
|
#expect(job.runnerID == 9)
|
|
#expect(job.runnerName == "macos-vm-3f1c2f8e-0a4b-4c1d-9e2f-5a6b7c8d9e0f")
|
|
// Fractional seconds are present here and absent above — both must parse.
|
|
#expect(job.startedAt != nil)
|
|
#expect(job.completedAt == nil)
|
|
}
|
|
|
|
@Test("an unknown status string decodes rather than throwing")
|
|
func unknownStatusTolerated() throws {
|
|
let json = """
|
|
{"total_count": 1, "jobs": [
|
|
{"id": 1, "run_id": 1, "name": "j", "labels": [], "status": "quantum_superposition"}
|
|
]}
|
|
"""
|
|
let job = try #require(try decode(WorkflowJobsResponse.self, json).items.first)
|
|
#expect(job.status == "quantum_superposition")
|
|
#expect(job.jobStatus == .unknown("quantum_superposition"))
|
|
#expect(!job.isQueued)
|
|
}
|
|
|
|
@Test("an empty jobs response decodes to no jobs")
|
|
func decodeEmptyJobsResponse() throws {
|
|
#expect(try decode(WorkflowJobsResponse.self, #"{"total_count": 0, "jobs": []}"#).items.isEmpty)
|
|
// A server that omits the array entirely, or nulls it, must not throw:
|
|
// "nothing queued" is the overwhelmingly common case in the poll loop.
|
|
#expect(try decode(WorkflowJobsResponse.self, #"{"total_count": 0}"#).items.isEmpty)
|
|
#expect(try decode(WorkflowJobsResponse.self, #"{"total_count": 0, "jobs": null}"#).items.isEmpty)
|
|
#expect(try decode(WorkflowJobsResponse.self, "{}").items.isEmpty)
|
|
}
|
|
|
|
@Test("the 'workflow_jobs' array key is accepted as a fallback")
|
|
func decodeAlternateJobsKey() throws {
|
|
let json = """
|
|
{"total_count": 1, "workflow_jobs": [
|
|
{"id": 7, "run_id": 1, "name": "j", "labels": ["macos-arm64"], "status": "queued"}
|
|
]}
|
|
"""
|
|
let response = try decode(WorkflowJobsResponse.self, json)
|
|
#expect(response.items.map(\.id) == [7])
|
|
}
|
|
|
|
@Test("a job round-trips through encode and decode")
|
|
func jobRoundTrip() throws {
|
|
let original = WorkflowJobsResponse(
|
|
totalCount: 1,
|
|
jobs: [WorkflowJob(id: 1, runID: 2, name: "n", status: "queued", labels: ["macos-arm64"])])
|
|
let encoder = JSONEncoder()
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
let data = try encoder.encode(original)
|
|
let decoded = try GiteaClient.makeDecoder().decode(WorkflowJobsResponse.self, from: data)
|
|
#expect(decoded == original)
|
|
}
|
|
|
|
// MARK: - Runner decoding
|
|
|
|
@Test("a runners response with object-shaped labels decodes")
|
|
func decodeRunnersResponse() throws {
|
|
// This is the shape that matters most: `ActionRunner.Labels` is
|
|
// `[]*ActionRunnerLabel`, NOT `[]string` as on a job.
|
|
let json = """
|
|
{
|
|
"total_count": 2,
|
|
"runners": [
|
|
{
|
|
"id": 9,
|
|
"name": "macos-vm-3f1c2f8e-0a4b-4c1d-9e2f-5a6b7c8d9e0f",
|
|
"status": "online",
|
|
"busy": false,
|
|
"ephemeral": true,
|
|
"labels": [
|
|
{"id": 31, "name": "macos-arm64", "type": "custom"}
|
|
]
|
|
},
|
|
{
|
|
"id": 10,
|
|
"name": "shared-linux-1",
|
|
"status": "offline",
|
|
"busy": true,
|
|
"ephemeral": false,
|
|
"labels": [
|
|
{"id": 32, "name": "ubuntu-latest", "type": "custom"},
|
|
{"id": 33, "name": "self-hosted", "type": "custom"}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
|
|
let response = try decode(RunnersResponse.self, json)
|
|
#expect(response.totalCount == 2)
|
|
#expect(response.items.count == 2)
|
|
|
|
let ours = try #require(response.items.first)
|
|
#expect(ours.id == 9)
|
|
#expect(ours.labels == ["macos-arm64"])
|
|
#expect(ours.status == "online")
|
|
#expect(ours.isEphemeral)
|
|
#expect(!ours.isBusy)
|
|
// All four reconcile conditions are readable from this row.
|
|
#expect(RunnerNaming.hasPrefix(ours.name, prefix: "macos-vm-"))
|
|
|
|
let theirs = try #require(response.items.last)
|
|
#expect(theirs.labels == ["ubuntu-latest", "self-hosted"])
|
|
#expect(!theirs.isEphemeral)
|
|
#expect(theirs.isBusy)
|
|
#expect(!RunnerNaming.hasPrefix(theirs.name, prefix: "macos-vm-"))
|
|
}
|
|
|
|
@Test("string-shaped runner labels are also accepted")
|
|
func decodeRunnerWithStringLabels() throws {
|
|
// Defensive: a proxy, an older build, or a hand-written fixture may use
|
|
// the job-style array of strings.
|
|
let json = #"{"id": 1, "name": "r", "labels": ["macos-arm64", "macos"]}"#
|
|
let runner = try decode(ActionRunner.self, json)
|
|
#expect(runner.labels == ["macos-arm64", "macos"])
|
|
}
|
|
|
|
@Test("absent, null, and empty runner labels all decode to no labels")
|
|
func decodeRunnerWithoutLabels() throws {
|
|
#expect(try decode(ActionRunner.self, #"{"id": 1, "name": "r"}"#).labels.isEmpty)
|
|
#expect(try decode(ActionRunner.self, #"{"id": 1, "name": "r", "labels": null}"#).labels.isEmpty)
|
|
#expect(try decode(ActionRunner.self, #"{"id": 1, "name": "r", "labels": []}"#).labels.isEmpty)
|
|
}
|
|
|
|
@Test("omitted busy and ephemeral default to false")
|
|
func runnerFlagDefaults() throws {
|
|
// The reconcile loop only ever deletes a row it is sure is ephemeral and
|
|
// idle, so absence must read as "not ephemeral", never as "assume yes".
|
|
let runner = try decode(ActionRunner.self, #"{"id": 1, "name": "r", "labels": []}"#)
|
|
#expect(runner.busy == nil)
|
|
#expect(runner.ephemeral == nil)
|
|
#expect(!runner.isBusy)
|
|
#expect(!runner.isEphemeral)
|
|
}
|
|
|
|
@Test("an unknown runner status string is tolerated")
|
|
func runnerUnknownStatus() throws {
|
|
let json = #"{"id": 1, "name": "r", "labels": [], "status": "hibernating"}"#
|
|
#expect(try decode(ActionRunner.self, json).status == "hibernating")
|
|
}
|
|
|
|
@Test("extra server-side fields are ignored")
|
|
func unknownFieldsIgnored() throws {
|
|
// Gitea 1.27 adds `disabled` to ActionRunner; newer fields must not
|
|
// break a 1.25-era client.
|
|
let json = """
|
|
{"id": 1, "name": "r", "labels": [], "disabled": false, "some_future_field": {"a": 1}}
|
|
"""
|
|
#expect(try decode(ActionRunner.self, json).id == 1)
|
|
}
|
|
|
|
@Test("an empty runners response decodes to no runners")
|
|
func decodeEmptyRunnersResponse() throws {
|
|
#expect(try decode(RunnersResponse.self, #"{"total_count": 0, "runners": []}"#).items.isEmpty)
|
|
#expect(try decode(RunnersResponse.self, "{}").items.isEmpty)
|
|
}
|
|
|
|
@Test("the 'entries' array key is accepted as a fallback")
|
|
func decodeAlternateRunnersKey() throws {
|
|
let json = #"{"total_count": 1, "entries": [{"id": 5, "name": "r", "labels": []}]}"#
|
|
#expect(try decode(RunnersResponse.self, json).items.map(\.id) == [5])
|
|
}
|
|
|
|
// MARK: - Registration token
|
|
|
|
@Test("a registration-token response decodes")
|
|
func decodeRegistrationToken() throws {
|
|
// Verified: `shared.RegistrationToken` is `{Token string `json:"token"`}`.
|
|
let response = try decode(RegistrationTokenResponse.self, #"{"token": "AABBCCDDEEFF00112233"}"#)
|
|
#expect(response.token == "AABBCCDDEEFF00112233")
|
|
}
|
|
|
|
// MARK: - Timestamps
|
|
|
|
@Test("timestamps parse with and without fractional seconds")
|
|
func timestampParsing() throws {
|
|
// Go marshals time.Time as RFC 3339 Nano: the fractional part appears
|
|
// only when non-zero, so both spellings occur in one response.
|
|
let plain = try #require(GiteaClient.parseTimestamp("2026-08-07T09:15:04Z"))
|
|
let fractional = try #require(GiteaClient.parseTimestamp("2026-08-07T09:15:04.482913Z"))
|
|
#expect(fractional > plain)
|
|
#expect(fractional.timeIntervalSince(plain) < 1)
|
|
|
|
// An offset rather than Z.
|
|
let offset = try #require(GiteaClient.parseTimestamp("2026-08-07T11:15:04+02:00"))
|
|
#expect(offset == plain)
|
|
|
|
#expect(GiteaClient.parseTimestamp("not a date") == nil)
|
|
}
|
|
|
|
@Test("a malformed timestamp fails the field, loudly")
|
|
func malformedTimestampThrows() {
|
|
let json = """
|
|
{"total_count": 1, "jobs": [
|
|
{"id": 1, "run_id": 1, "name": "j", "labels": [], "status": "queued",
|
|
"created_at": "yesterday"}
|
|
]}
|
|
"""
|
|
#expect(throws: DecodingError.self) {
|
|
try decode(WorkflowJobsResponse.self, json)
|
|
}
|
|
}
|
|
}
|