Files
gitea-macos-vm-orchestrator/Tests/RunnerCoreTests/GiteaModelsTests.swift
T

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