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