import Foundation import Testing @testable import gitbay private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) return (client, box) } /// `dashboard` as an admin sees it, trimmed to the admin blocks, with /// one row in every queue that lists rows. private let adminDashboardJSON = """ {"protocol_version":1,"data":{"unread":0,\ "server":{"commit":"db7503f06f11"},\ "queues":{\ "webhooks":{"pending":2,"retrying":1,"failed":1,"oldest_pending":"2026-09-22T04:00:00Z","items":[\ {"id":9,"repo":"krz/gitbay","url":"https://ci.example.com/hook","attempts":5,\ "last_status":502,"last_error":"bad gateway","failed_at":"2026-09-22T05:00:00Z","created_at":"2026-09-22T03:00:00Z"}]},\ "mail":{"pending":0,"retrying":1,"failed":0,"items":[\ {"id":4,"recipient":"a@example.com","subject":"krz/gitbay#12","attempts":2,\ "last_error":"dial tcp: timeout","created_at":"2026-09-22T03:00:00Z"}]},\ "mirrors":{"dirty":3,"errors":1,"items":[\ {"id":1,"repo":"krz/solar","direction":"push","url":"https://github.com/x/solar",\ "last_error":"auth failed"}]},\ "builds":{"pending":1,"running":1,"items":[\ {"repo":"krz/gitbay","number":1480,"job":"test","status":"running",\ "created_at":"2026-09-22T04:40:00Z","started_at":"2026-09-22T04:41:00Z"},\ {"repo":"krz/gitbay","number":1481,"job":"build","status":"pending",\ "created_at":"2026-09-22T04:42:00Z","started_at":""}]},\ "deps":{"errors":0,"items":[]},\ "push":{"pending":0,"retrying":0,"failed":1,"items":[\ {"id":7,"device_id":3,"title":"krz/gitbay!450 merged","attempts":3,\ "last_error":"BadDeviceToken","failed_at":"2026-09-22T05:00:00Z","created_at":"2026-09-22T04:00:00Z"}]}\ }},"exit_code":0} """ nonisolated private struct DashboardEnvelope: Decodable { let data: AdminDashboard } private func sections() throws -> [QueueSection] { let dashboard = try GitbayClient.decoder() .decode(DashboardEnvelope.self, from: Data(adminDashboardJSON.utf8)).data let queues = try #require(dashboard.queues) // A fixed stand-in for relative time keeps the text deterministic. return queues.sections(when: { "at \($0)" }) } struct AdminQueueSectionTests { @Test func sixSectionsInTheWebsOrder() throws { let all = try sections() #expect(all.map(\.heading) == [ "Webhook deliveries", "Mail", "Push", "Mirrors", "Builds", "Dependency checks", ]) #expect(all.map(\.count) == [2, 0, 0, 1, 1, 0]) } @Test func countsLinesUseTheWebsWords() throws { let all = try sections() #expect(all[0].summary == "2 pending · 1 retrying · 1 dead-lettered · oldest pending at 2026-09-22T04:00:00Z") #expect(all[1].summary == "0 pending · 1 retrying · 0 failed") #expect(all[3].summary == "3 waiting for a sync · 1 with a failed last sync") #expect(all[4].summary == "1 pending · 1 running") #expect(all[5].summary == nil) } @Test func rowsSayWhatTheWebsTableColumnsSay() throws { let all = try sections() #expect(all[0].rows.map(\.title) == ["krz/gitbay"]) #expect(all[0].rows[0].detail == "https://ci.example.com/hook · 5 attempts · dead-lettered at 2026-09-22T05:00:00Z · 502 bad gateway") #expect(all[1].rows[0].title == "a@example.com") #expect(all[1].rows[0].detail == "krz/gitbay#12 · 2 attempts · retrying · dial tcp: timeout") #expect(all[2].rows[0].title == "device 3") #expect(all[2].rows[0].detail == "krz/gitbay!450 merged · 3 attempts · failed at 2026-09-22T05:00:00Z · BadDeviceToken") #expect(all[3].rows[0].detail == "push · https://github.com/x/solar · never synced · auth failed") // A pending build has started_at "" and reports since it was queued. #expect(all[4].rows.map(\.title) == ["krz/gitbay #1480", "krz/gitbay #1481"]) #expect(all[4].rows[0].detail == "test · running · since at 2026-09-22T04:41:00Z") #expect(all[4].rows[1].detail == "build · pending · since at 2026-09-22T04:42:00Z") } @Test func anEmptyQueueSaysSoInTheWebsWords() throws { let deps = try #require(try sections().last) #expect(deps.rows.isEmpty) #expect(deps.empty == "No check has failed") } /// Everyone else's dashboard omits both blocks. @Test func aNonAdminDashboardHasNoQueues() throws { let json = #"{"protocol_version":1,"data":{"unread":3},"exit_code":0}"# let dashboard = try GitbayClient.decoder().decode(DashboardEnvelope.self, from: Data(json.utf8)).data #expect(dashboard.queues == nil) #expect(dashboard.server == nil) } @Test func anUnparseableTimestampIsShownAsIs() { #expect(AdminQueues.relative("soon") == "soon") } } private func user(_ name: String, state: String, admin: Bool = false) throws -> AdminUser { let json = """ {"username":"\(name)","state":"\(state)","admin":\(admin),\ "created_at":"2026-08-28T21:55:00.752Z"} """ return try GitbayClient.decoder().decode(AdminUser.self, from: Data(json.utf8)) } struct AdminUserActionTests { @Test func anAdminIsDemotedAndCanBeDisabled() throws { let u = try user("cmc", state: "active", admin: true) #expect(u.roleAction == .demote) #expect(u.canChangeRole) #expect(u.accessAction == .disable) } @Test func anActiveAccountCanBePromoted() throws { let u = try user("blotter-ci", state: "active") #expect(u.roleAction == .promote) #expect(u.canChangeRole) } /// The web draws Promote on every non-admin row but disables it for /// an account that is not active. @Test func onlyAnActiveAccountCanBePromoted() throws { #expect(try user("new", state: "pending").canChangeRole == false) #expect(try user("gone", state: "disabled").canChangeRole == false) } @Test func aDisabledAccountIsEnabledAndAnyOtherDisabled() throws { #expect(try user("gone", state: "disabled").accessAction == .enable) #expect(try user("new", state: "pending").accessAction == .disable) } @Test func demoteAndDisableAskForTheTypedName() { #expect(AdminAction.demote.needsTypedName) #expect(AdminAction.disable.needsTypedName) #expect(!AdminAction.promote.needsTypedName) #expect(!AdminAction.enable.needsTypedName) } @Test func eachActionIsItsAdminUserCommand() { #expect(AdminAction.promote.argv("x") == ["admin", "user", "promote", "x"]) #expect(AdminAction.demote.argv("x") == ["admin", "user", "demote", "x"]) #expect(AdminAction.disable.argv("x") == ["admin", "user", "disable", "x"]) #expect(AdminAction.enable.argv("x") == ["admin", "user", "enable", "x"]) } @Test func theFiltersAreTheWebsFive() { #expect(AdminUserFilter.allCases.map(\.label) == ["All", "Active", "Pending", "Disabled", "Admins"]) #expect(AdminUserFilter.all.flags().isEmpty) #expect(AdminUserFilter.admin.flags() == ["--state", "admin"]) } } @MainActor struct AdminCheckTests { @Test func whoamisAdminFlagDecides() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"username":"cmc","admin":true,"key_scope":"full"}}"#)) #expect(await AdminCheck.isAdmin(client)) #expect(stub.seen.first?.url.query() == "argv=whoami") } @Test func aNonAdminIsNot() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"username":"ios-smoke","admin":false}}"#)) #expect(await AdminCheck.isAdmin(client) == false) } /// The menu just leaves the entry out when the read fails. @Test func aFailedReadIsNotAnAdmin() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#)) #expect(await AdminCheck.isAdmin(client) == false) } } @MainActor struct AdminOverviewViewModelTests { @Test func readsDashboardIntoSections() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: adminDashboardJSON)) let model = AdminOverviewViewModel(client: client) await model.load() let overview = try #require(model.state.value) #expect(overview.commit == "db7503f06f11") #expect(overview.sections.count == 6) #expect(stub.seen.first?.url.query() == "argv=dashboard") } @Test func aDashboardWithoutQueuesIsAnEmptyState() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"unread":0},"exit_code":0}"#)) let model = AdminOverviewViewModel(client: client) await model.load() guard case .empty(let message) = model.state else { Issue.record("expected .empty, got \(model.state)") return } #expect(message == "Only instance admins can see the worker queues.") } } private let userPageJSON = """ {"protocol_version":1,"data":{"items":[\ {"username":"apple-review","state":"active","admin":false,"created_at":"2026-08-28T21:55:00.752Z",\ "last_seen":"2026-09-21T07:05:26.201Z"},\ {"username":"bhargavkk","state":"pending","admin":false,"created_at":"2026-09-11T01:50:04.063Z"}],\ "next":"YWRtaW4tdXNlcjpiaGFyZ2F2a2s"},"exit_code":0} """ @MainActor struct AdminUsersViewModelTests { @Test func listsEveryAccountByDefault() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: userPageJSON)) let model = AdminUsersViewModel(client: client) await model.load() let users = try #require(model.list.state.value) #expect(users.map(\.username) == ["apple-review", "bhargavkk"]) #expect(users[0].lastSeen != nil) #expect(users[1].lastSeen == nil) #expect(model.list.hasMore) #expect(stub.seen.first?.url.query() == "argv=admin&argv=user&argv=list&argv=--limit&argv=50") } @Test func loadIfNeededOnlyReadsOnce() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: userPageJSON)) let model = AdminUsersViewModel(client: client) await model.loadIfNeeded() await model.loadIfNeeded() #expect(stub.seen.count == 1) #expect(stub.seen.first?.url.query() == "argv=admin&argv=user&argv=list&argv=--limit&argv=50") } @Test func aFilterReloadsWithItsState() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: userPageJSON)) let model = AdminUsersViewModel(client: client) await model.load() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#)) model.filter = .disabled await until { if case .empty = model.list.state { return true } return false } #expect(stub.seen.last?.url.query() == "argv=admin&argv=user&argv=list&argv=--state&argv=disabled&argv=--limit&argv=50") guard case .empty(let message) = model.list.state else { return } #expect(message == "No account matches") } @Test func anActionSendsItsCommandAndReloads() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "cmd")) stub.enqueue(.init(status: 200, json: userPageJSON, match: "argv=list")) let model = AdminUsersViewModel(client: client) await model.perform(.disable, on: "bhargavkk") let write = try #require(stub.seen.first { $0.method == "POST" }) let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) #expect(body["argv"] as? [String] == ["admin", "user", "disable", "bhargavkk"]) #expect(model.actionError == nil) #expect(model.list.state.value?.count == 2) } /// The last-admin refusal is a real server failure (exit 1, HTTP 500), /// which the client retries once before surfacing it. @Test func aRefusalIsShownVerbatim() async throws { let (client, stub) = try makeClient() let refusal = StubProtocol.Stub(status: 500, json: #"{"protocol_version":1,"error":"that is the only instance admin; promote someone else first","exit_code":1}"#, match: "cmd") stub.enqueue(refusal) stub.enqueue(refusal) stub.enqueue(.init(status: 200, json: userPageJSON, match: "argv=list")) let model = AdminUsersViewModel(client: client) await model.perform(.demote, on: "cmc") #expect(model.actionError == "that is the only instance admin; promote someone else first") #expect(model.list.state.value?.count == 2) } /// A refusal must still reload the list, so a stale row does not /// linger after the account changed state meanwhile. @Test func aRefusedActionStillReloadsTheList() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"account changed state","exit_code":4}"#, match: "cmd")) stub.enqueue(.init(status: 200, json: userPageJSON, match: "argv=list")) let model = AdminUsersViewModel(client: client) await model.perform(.disable, on: "bhargavkk") #expect(model.actionError == "account changed state") #expect(stub.seen.contains { $0.url.query()?.contains("argv=list") == true }) } }