Commit 6f729def95
Unsigned
Layout: unified · split
gitbay/ContentView.swift +4
| @@ -104,6 +104,10 @@ private struct RouteDestinations: ViewModifier { | |||
| 104 | LabelListView(client: client, repo: repo) | 104 | LabelListView(client: client, repo: repo) |
| 105 | case .bookmarks: | 105 | case .bookmarks: |
| 106 | BookmarkListView(client: client) | 106 | BookmarkListView(client: client) |
| 107 | case .webhooks(let repo): | ||
| 108 | WebhookListView(client: client, repo: repo) | ||
| 109 | case .webhookDeliveries(let repo): | ||
| 110 | WebhookDeliveriesView(client: client, repo: repo) | ||
| 107 | case .wiki(let repo): | 111 | case .wiki(let repo): |
| 108 | WikiView(client: client, repo: repo) | 112 | WikiView(client: client, repo: repo) |
| 109 | case .wikiPage(let repo, let page): | 113 | case .wikiPage(let repo, let page): |
gitbay/Repos/WebhookModels.swift added +32
| @@ -0,0 +1,32 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// One row of `webhook list`. | ||
| 4 | nonisolated struct Webhook: Decodable, Sendable, Hashable, Identifiable { | ||
| 5 | let id: Int64 | ||
| 6 | let url: String | ||
| 7 | let events: String | ||
| 8 | let active: Bool | ||
| 9 | let hasSecret: Bool | ||
| 10 | |||
| 11 | enum CodingKeys: String, CodingKey { | ||
| 12 | case id, url, events, active | ||
| 13 | case hasSecret = "has_secret" | ||
| 14 | } | ||
| 15 | } | ||
| 16 | |||
| 17 | /// One row of `webhook deliveries`. | ||
| 18 | nonisolated struct WebhookDelivery: Decodable, Sendable, Hashable, Identifiable { | ||
| 19 | let id: Int64 | ||
| 20 | let url: String | ||
| 21 | let event: String | ||
| 22 | let status: String | ||
| 23 | let attempts: Int | ||
| 24 | let lastStatus: Int? | ||
| 25 | let lastError: String? | ||
| 26 | |||
| 27 | enum CodingKeys: String, CodingKey { | ||
| 28 | case id, url, event, status, attempts | ||
| 29 | case lastStatus = "last_status" | ||
| 30 | case lastError = "last_error" | ||
| 31 | } | ||
| 32 | } | ||
gitbay/Repos/WebhookViewModels.swift added +105
| @@ -0,0 +1,105 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | |||
| 4 | /// `webhook list`, `webhook add` and `webhook remove` for one repository. | ||
| 5 | @Observable | ||
| 6 | @MainActor | ||
| 7 | final class WebhookListViewModel { | ||
| 8 | |||
| 9 | private(set) var state: LoadState<[Webhook]> = .loading | ||
| 10 | private(set) var actionError: String? | ||
| 11 | private(set) var working = false | ||
| 12 | |||
| 13 | private let client: GitbayClient | ||
| 14 | let repoPath: String | ||
| 15 | |||
| 16 | init(client: GitbayClient, repoPath: String) { | ||
| 17 | self.client = client | ||
| 18 | self.repoPath = repoPath | ||
| 19 | } | ||
| 20 | |||
| 21 | func load() async { | ||
| 22 | state = .loading | ||
| 23 | do { | ||
| 24 | let hooks = try await client.readList(["webhook", "list", repoPath], of: Webhook.self) | ||
| 25 | state = hooks.isEmpty ? .empty("No webhooks yet.") : .loaded(hooks) | ||
| 26 | } catch { | ||
| 27 | state = .from(error) | ||
| 28 | } | ||
| 29 | } | ||
| 30 | |||
| 31 | /// `webhook add <repo> <url> [--secret <s>] [--events <e>]` — an empty | ||
| 32 | /// secret or event list is left out, so the server applies its default. | ||
| 33 | func add(url: String, secret: String, events: String) async -> Bool { | ||
| 34 | let url = url.trimmingCharacters(in: .whitespaces) | ||
| 35 | guard !url.isEmpty else { return false } | ||
| 36 | var argv = ["webhook", "add", repoPath, url] | ||
| 37 | let secret = secret.trimmingCharacters(in: .whitespaces) | ||
| 38 | if !secret.isEmpty { argv += ["--secret", secret] } | ||
| 39 | let events = events.trimmingCharacters(in: .whitespaces) | ||
| 40 | if !events.isEmpty { argv += ["--events", events] } | ||
| 41 | await perform(argv) | ||
| 42 | return actionError == nil | ||
| 43 | } | ||
| 44 | |||
| 45 | func remove(id: Int64) async { | ||
| 46 | await perform(["webhook", "remove", repoPath, String(id)]) | ||
| 47 | } | ||
| 48 | |||
| 49 | private func perform(_ argv: [String]) async { | ||
| 50 | working = true | ||
| 51 | actionError = nil | ||
| 52 | defer { working = false } | ||
| 53 | do { | ||
| 54 | try await client.run(argv) | ||
| 55 | await load() | ||
| 56 | } catch let error as GitbayError { | ||
| 57 | actionError = error.userFacingMessage | ||
| 58 | } catch { | ||
| 59 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 60 | } | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | /// `webhook deliveries` and `webhook redeliver`. | ||
| 65 | @Observable | ||
| 66 | @MainActor | ||
| 67 | final class WebhookDeliveriesViewModel { | ||
| 68 | |||
| 69 | private(set) var state: LoadState<[WebhookDelivery]> = .loading | ||
| 70 | private(set) var actionError: String? | ||
| 71 | private(set) var working = false | ||
| 72 | |||
| 73 | private let client: GitbayClient | ||
| 74 | let repoPath: String | ||
| 75 | |||
| 76 | init(client: GitbayClient, repoPath: String) { | ||
| 77 | self.client = client | ||
| 78 | self.repoPath = repoPath | ||
| 79 | } | ||
| 80 | |||
| 81 | func load() async { | ||
| 82 | state = .loading | ||
| 83 | do { | ||
| 84 | let rows = try await client.readList( | ||
| 85 | ["webhook", "deliveries", repoPath], of: WebhookDelivery.self) | ||
| 86 | state = rows.isEmpty ? .empty("No deliveries yet.") : .loaded(rows) | ||
| 87 | } catch { | ||
| 88 | state = .from(error) | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | func redeliver(id: Int64) async { | ||
| 93 | working = true | ||
| 94 | actionError = nil | ||
| 95 | defer { working = false } | ||
| 96 | do { | ||
| 97 | try await client.run(["webhook", "redeliver", repoPath, String(id)]) | ||
| 98 | await load() | ||
| 99 | } catch let error as GitbayError { | ||
| 100 | actionError = error.userFacingMessage | ||
| 101 | } catch { | ||
| 102 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 103 | } | ||
| 104 | } | ||
| 105 | } | ||
gitbay/Views/Repos/RepoRoute.swift +2
| @@ -18,6 +18,8 @@ nonisolated enum RepoRoute: Hashable { | |||
| 18 | case wikiPage(repo: String, page: String) | 18 | case wikiPage(repo: String, page: String) |
| 19 | case commit(repo: String, sha: String) | 19 | case commit(repo: String, sha: String) |
| 20 | case bookmarks | 20 | case bookmarks |
| 21 | case webhooks(repo: String) | ||
| 22 | case webhookDeliveries(repo: String) | ||
| 21 | case profile(String) | 23 | case profile(String) |
| 22 | case account | 24 | case account |
| 23 | case addAccount | 25 | case addAccount |
gitbay/Views/Repos/RepoSettingsView.swift +11
| @@ -34,6 +34,7 @@ struct RepoSettingsView: View { | |||
| 34 | mergeRulesSection(loaded) | 34 | mergeRulesSection(loaded) |
| 35 | daemonSection(loaded) | 35 | daemonSection(loaded) |
| 36 | accessSection | 36 | accessSection |
| 37 | webhooksSection | ||
| 37 | depsSection() | 38 | depsSection() |
| 38 | } | 39 | } |
| 39 | } | 40 | } |
| @@ -309,6 +310,16 @@ struct RepoSettingsView: View { | |||
| 309 | } | 310 | } |
| 310 | } | 311 | } |
| 311 | 312 | ||
| 313 | private var webhooksSection: some View { | ||
| 314 | Section { | ||
| 315 | NavigationLink(value: RepoRoute.webhooks(repo: model.repoPath)) { | ||
| 316 | Label("Webhooks", systemImage: "antenna.radiowaves.left.and.right") | ||
| 317 | .font(.gbSans(.subheadline)) | ||
| 318 | } | ||
| 319 | .accessibilityIdentifier("settings-webhooks-link") | ||
| 320 | } | ||
| 321 | } | ||
| 322 | |||
| 312 | private func depsSection() -> some View { | 323 | private func depsSection() -> some View { |
| 313 | Section { | 324 | Section { |
| 314 | if let deps = model.deps { | 325 | if let deps = model.deps { |
gitbay/Views/Repos/WebhookListView.swift added +213
| @@ -0,0 +1,213 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// A repository's webhooks: list, add, remove, and the way to its | ||
| 4 | /// deliveries. Reached from repo settings. | ||
| 5 | struct WebhookListView: View { | ||
| 6 | |||
| 7 | @State private var model: WebhookListViewModel | ||
| 8 | @State private var adding = false | ||
| 9 | @State private var removingHook: Webhook? | ||
| 10 | |||
| 11 | init(client: GitbayClient, repo: String) { | ||
| 12 | _model = State(initialValue: WebhookListViewModel(client: client, repoPath: repo)) | ||
| 13 | } | ||
| 14 | |||
| 15 | var body: some View { | ||
| 16 | List { | ||
| 17 | if let error = model.actionError { | ||
| 18 | Section { | ||
| 19 | GBNotice(error, .gbWarn) | ||
| 20 | } | ||
| 21 | } | ||
| 22 | Section { | ||
| 23 | NavigationLink(value: RepoRoute.webhookDeliveries(repo: model.repoPath)) { | ||
| 24 | Label("Deliveries", systemImage: "paperplane") | ||
| 25 | .font(.gbSans(.subheadline)) | ||
| 26 | } | ||
| 27 | .accessibilityIdentifier("webhook-deliveries-link") | ||
| 28 | } | ||
| 29 | Section { | ||
| 30 | ForEach(model.state.value ?? []) { hook in | ||
| 31 | VStack(alignment: .leading, spacing: 3) { | ||
| 32 | Text(hook.url) | ||
| 33 | .font(.gbMono(.caption)) | ||
| 34 | .lineLimit(2) | ||
| 35 | HStack(spacing: 6) { | ||
| 36 | GBChip(hook.events, .secondary) | ||
| 37 | if hook.hasSecret { | ||
| 38 | GBChip("signed", .secondary) | ||
| 39 | } | ||
| 40 | if !hook.active { | ||
| 41 | GBChip("inactive", .gbWarn) | ||
| 42 | } | ||
| 43 | } | ||
| 44 | } | ||
| 45 | .padding(.vertical, 2) | ||
| 46 | .swipeActions { | ||
| 47 | Button("Remove", role: .destructive) { | ||
| 48 | removingHook = hook | ||
| 49 | } | ||
| 50 | .disabled(model.working) | ||
| 51 | } | ||
| 52 | } | ||
| 53 | } | ||
| 54 | } | ||
| 55 | .overlay { LoadStateOverlay(state: model.state) } | ||
| 56 | .navigationTitle("Webhooks") | ||
| 57 | .navigationBarTitleDisplayMode(.inline) | ||
| 58 | .toolbar { | ||
| 59 | ToolbarItem(placement: .topBarTrailing) { | ||
| 60 | Button { | ||
| 61 | adding = true | ||
| 62 | } label: { | ||
| 63 | Image(systemName: "plus") | ||
| 64 | } | ||
| 65 | .disabled(model.working) | ||
| 66 | .accessibilityIdentifier("webhook-add-button") | ||
| 67 | } | ||
| 68 | } | ||
| 69 | .sheet(isPresented: $adding) { | ||
| 70 | WebhookAddSheet(model: model) { adding = false } | ||
| 71 | } | ||
| 72 | .confirmationDialog( | ||
| 73 | "Remove the webhook for \(removingHook?.url ?? "")?", | ||
| 74 | isPresented: Binding( | ||
| 75 | get: { removingHook != nil }, | ||
| 76 | set: { if !$0 { removingHook = nil } } | ||
| 77 | ), | ||
| 78 | titleVisibility: .visible | ||
| 79 | ) { | ||
| 80 | Button("Remove", role: .destructive) { | ||
| 81 | if let hook = removingHook { | ||
| 82 | Task { await model.remove(id: hook.id) } | ||
| 83 | } | ||
| 84 | removingHook = nil | ||
| 85 | } | ||
| 86 | Button("Cancel", role: .cancel) {} | ||
| 87 | } | ||
| 88 | .task { await model.load() } | ||
| 89 | .refreshable { await model.load() } | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | /// `webhook add` — URL, optional signing secret, optional event list. | ||
| 94 | private struct WebhookAddSheet: View { | ||
| 95 | let model: WebhookListViewModel | ||
| 96 | let onAdded: () -> Void | ||
| 97 | |||
| 98 | @State private var url = "" | ||
| 99 | @State private var secret = "" | ||
| 100 | @State private var events = "" | ||
| 101 | |||
| 102 | var body: some View { | ||
| 103 | NavigationStack { | ||
| 104 | Form { | ||
| 105 | Section { | ||
| 106 | TextField("https://…", text: $url) | ||
| 107 | .autocorrectionDisabled() | ||
| 108 | .textInputAutocapitalization(.never) | ||
| 109 | .keyboardType(.URL) | ||
| 110 | .accessibilityIdentifier("webhook-url") | ||
| 111 | SecureField("Secret (optional)", text: $secret) | ||
| 112 | .autocorrectionDisabled() | ||
| 113 | .textInputAutocapitalization(.never) | ||
| 114 | } footer: { | ||
| 115 | Text("With a secret, every delivery is signed so the receiver can verify it.") | ||
| 116 | } | ||
| 117 | Section { | ||
| 118 | TextField("Events", text: $events) | ||
| 119 | .autocorrectionDisabled() | ||
| 120 | .textInputAutocapitalization(.never) | ||
| 121 | } footer: { | ||
| 122 | Text("Comma-separated, such as push,issue.created. Empty means every event.") | ||
| 123 | } | ||
| 124 | if let error = model.actionError { | ||
| 125 | Section { | ||
| 126 | GBNotice(error, .gbWarn) | ||
| 127 | } | ||
| 128 | } | ||
| 129 | } | ||
| 130 | .navigationTitle("New Webhook") | ||
| 131 | .navigationBarTitleDisplayMode(.inline) | ||
| 132 | .toolbar { | ||
| 133 | ToolbarItem(placement: .cancellationAction) { | ||
| 134 | Button("Cancel") { onAdded() } | ||
| 135 | .disabled(model.working) | ||
| 136 | } | ||
| 137 | ToolbarItem(placement: .confirmationAction) { | ||
| 138 | if model.working { | ||
| 139 | ProgressView() | ||
| 140 | } else { | ||
| 141 | Button("Add") { | ||
| 142 | Task { | ||
| 143 | if await model.add(url: url, secret: secret, events: events) { | ||
| 144 | onAdded() | ||
| 145 | } | ||
| 146 | } | ||
| 147 | } | ||
| 148 | .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty) | ||
| 149 | .accessibilityIdentifier("webhook-submit") | ||
| 150 | } | ||
| 151 | } | ||
| 152 | } | ||
| 153 | } | ||
| 154 | .interactiveDismissDisabled(model.working) | ||
| 155 | } | ||
| 156 | } | ||
| 157 | |||
| 158 | /// `webhook deliveries` — recent deliveries, newest first, with a swipe | ||
| 159 | /// to requeue one. | ||
| 160 | struct WebhookDeliveriesView: View { | ||
| 161 | |||
| 162 | @State private var model: WebhookDeliveriesViewModel | ||
| 163 | |||
| 164 | init(client: GitbayClient, repo: String) { | ||
| 165 | _model = State(initialValue: WebhookDeliveriesViewModel(client: client, repoPath: repo)) | ||
| 166 | } | ||
| 167 | |||
| 168 | var body: some View { | ||
| 169 | List { | ||
| 170 | if let error = model.actionError { | ||
| 171 | Section { | ||
| 172 | GBNotice(error, .gbWarn) | ||
| 173 | } | ||
| 174 | } | ||
| 175 | ForEach(model.state.value ?? []) { delivery in | ||
| 176 | VStack(alignment: .leading, spacing: 3) { | ||
| 177 | HStack(spacing: 6) { | ||
| 178 | Text(delivery.event) | ||
| 179 | .font(.gbSans(.subheadline).weight(.medium)) | ||
| 180 | GBChip(delivery.status, delivery.status == "delivered" ? .gbDone : .gbWarn) | ||
| 181 | Spacer() | ||
| 182 | if let code = delivery.lastStatus { | ||
| 183 | Text(String(code)) | ||
| 184 | .font(.gbMono(.caption)) | ||
| 185 | .foregroundStyle(.secondary) | ||
| 186 | } | ||
| 187 | } | ||
| 188 | Text(delivery.url) | ||
| 189 | .font(.gbMono(.caption2)) | ||
| 190 | .foregroundStyle(.secondary) | ||
| 191 | .lineLimit(1) | ||
| 192 | Text("\(delivery.attempts) attempt\(delivery.attempts == 1 ? "" : "s")" | ||
| 193 | + (delivery.lastError.map { " — \($0)" } ?? "")) | ||
| 194 | .font(.gbSans(.caption)) | ||
| 195 | .foregroundStyle(.tertiary) | ||
| 196 | .lineLimit(2) | ||
| 197 | } | ||
| 198 | .padding(.vertical, 2) | ||
| 199 | .swipeActions { | ||
| 200 | Button("Redeliver") { | ||
| 201 | Task { await model.redeliver(id: delivery.id) } | ||
| 202 | } | ||
| 203 | .disabled(model.working) | ||
| 204 | } | ||
| 205 | } | ||
| 206 | } | ||
| 207 | .overlay { LoadStateOverlay(state: model.state) } | ||
| 208 | .navigationTitle("Deliveries") | ||
| 209 | .navigationBarTitleDisplayMode(.inline) | ||
| 210 | .task { await model.load() } | ||
| 211 | .refreshable { await model.load() } | ||
| 212 | } | ||
| 213 | } | ||
gitbayTests/WebhookTests.swift added +146
| @@ -0,0 +1,146 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 6 | let box = StubProtocol.box() | ||
| 7 | let client = GitbayClient( | ||
| 8 | instance: try GitbayInstance(url: "https://gitbay.org"), | ||
| 9 | token: "test-token", | ||
| 10 | session: box.session() | ||
| 11 | ) | ||
| 12 | return (client, box) | ||
| 13 | } | ||
| 14 | |||
| 15 | private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { | ||
| 16 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | ||
| 17 | return try #require(body["argv"] as? [String]) | ||
| 18 | } | ||
| 19 | |||
| 20 | private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# | ||
| 21 | |||
| 22 | private let hooksJSON = """ | ||
| 23 | {"protocol_version":1,"data":[\ | ||
| 24 | {"id":4,"url":"https://ci.example/hook","events":"*","active":true,"has_secret":true},\ | ||
| 25 | {"id":9,"url":"https://chat.example/in","events":"push,issue.created","active":false,"has_secret":false}\ | ||
| 26 | ],"exit_code":0} | ||
| 27 | """ | ||
| 28 | |||
| 29 | private let deliveriesJSON = """ | ||
| 30 | {"protocol_version":1,"data":[\ | ||
| 31 | {"id":71,"url":"https://ci.example/hook","event":"push","status":"delivered","attempts":1,"last_status":200},\ | ||
| 32 | {"id":70,"url":"https://chat.example/in","event":"issue.created","status":"failed","attempts":3,\ | ||
| 33 | "last_status":502,"last_error":"bad gateway"}\ | ||
| 34 | ],"exit_code":0} | ||
| 35 | """ | ||
| 36 | |||
| 37 | @MainActor | ||
| 38 | struct WebhookListViewModelTests { | ||
| 39 | |||
| 40 | private func loaded() async throws -> (WebhookListViewModel, StubProtocol.Box) { | ||
| 41 | let (client, stub) = try makeClient() | ||
| 42 | stub.enqueue(.init(status: 200, json: hooksJSON, match: "argv=list")) | ||
| 43 | let model = WebhookListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 44 | await model.load() | ||
| 45 | return (model, stub) | ||
| 46 | } | ||
| 47 | |||
| 48 | @Test func listsHooksWithTheirEventsAndSecretFlag() async throws { | ||
| 49 | let (model, stub) = try await loaded() | ||
| 50 | |||
| 51 | let hooks = try #require(model.state.value) | ||
| 52 | #expect(hooks.map(\.id) == [4, 9]) | ||
| 53 | #expect(hooks[0].hasSecret && hooks[0].active) | ||
| 54 | #expect(hooks[1].events == "push,issue.created") | ||
| 55 | #expect(stub.seen.first?.url.query() == "argv=webhook&argv=list&argv=krz/gitbay") | ||
| 56 | } | ||
| 57 | |||
| 58 | @Test func noHooksIsAnEmptyState() async throws { | ||
| 59 | let (client, stub) = try makeClient() | ||
| 60 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":[],"exit_code":0}"#)) | ||
| 61 | let model = WebhookListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 62 | |||
| 63 | await model.load() | ||
| 64 | |||
| 65 | guard case .empty = model.state else { | ||
| 66 | Issue.record("expected .empty, got \(model.state)") | ||
| 67 | return | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | @Test func addSendsSecretAndEventsOnlyWhenGiven() async throws { | ||
| 72 | let (model, stub) = try await loaded() | ||
| 73 | for _ in 0..<2 { | ||
| 74 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"id":10,"url":"u","events":"*"},"exit_code":0}"#, match: "cmd")) | ||
| 75 | stub.enqueue(.init(status: 200, json: hooksJSON, match: "argv=list")) | ||
| 76 | } | ||
| 77 | |||
| 78 | let first = await model.add(url: " https://a.example/h ", secret: "s3", events: "push") | ||
| 79 | let second = await model.add(url: "https://b.example/h", secret: "", events: "") | ||
| 80 | |||
| 81 | #expect(first && second) | ||
| 82 | let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf) | ||
| 83 | #expect(writes == [ | ||
| 84 | ["webhook", "add", "krz/gitbay", "https://a.example/h", "--secret", "s3", "--events", "push"], | ||
| 85 | ["webhook", "add", "krz/gitbay", "https://b.example/h"], | ||
| 86 | ]) | ||
| 87 | } | ||
| 88 | |||
| 89 | @Test func removeSendsTheIdThenReloads() async throws { | ||
| 90 | let (model, stub) = try await loaded() | ||
| 91 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | ||
| 92 | stub.enqueue(.init(status: 200, json: hooksJSON, match: "argv=list")) | ||
| 93 | |||
| 94 | await model.remove(id: 9) | ||
| 95 | |||
| 96 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 97 | #expect(try argvOf(write) == ["webhook", "remove", "krz/gitbay", "9"]) | ||
| 98 | #expect(stub.seen.filter { $0.method == "GET" }.count == 2) | ||
| 99 | } | ||
| 100 | |||
| 101 | @Test func aRefusedAddSurfacesAndReturnsFalse() async throws { | ||
| 102 | let (model, stub) = try await loaded() | ||
| 103 | stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"admin access required","exit_code":4}"#, match: "cmd")) | ||
| 104 | |||
| 105 | let ok = await model.add(url: "https://a.example/h", secret: "", events: "") | ||
| 106 | |||
| 107 | #expect(!ok) | ||
| 108 | #expect(model.actionError == "admin access required") | ||
| 109 | } | ||
| 110 | } | ||
| 111 | |||
| 112 | @MainActor | ||
| 113 | struct WebhookDeliveriesViewModelTests { | ||
| 114 | |||
| 115 | @Test func listsDeliveriesWithStatusAndLastError() async throws { | ||
| 116 | let (client, stub) = try makeClient() | ||
| 117 | stub.enqueue(.init(status: 200, json: deliveriesJSON)) | ||
| 118 | let model = WebhookDeliveriesViewModel(client: client, repoPath: "krz/gitbay") | ||
| 119 | |||
| 120 | await model.load() | ||
| 121 | |||
| 122 | let rows = try #require(model.state.value) | ||
| 123 | #expect(rows.map(\.id) == [71, 70]) | ||
| 124 | #expect(rows[1].status == "failed") | ||
| 125 | #expect(rows[1].lastStatus == 502) | ||
| 126 | #expect(rows[1].lastError == "bad gateway") | ||
| 127 | #expect(rows[0].lastError == nil) | ||
| 128 | #expect(stub.seen.first?.url.query() == "argv=webhook&argv=deliveries&argv=krz/gitbay") | ||
| 129 | } | ||
| 130 | |||
| 131 | @Test func redeliverSendsTheDeliveryIdThenReloads() async throws { | ||
| 132 | let (client, stub) = try makeClient() | ||
| 133 | stub.enqueue(.init(status: 200, json: deliveriesJSON, match: "argv=deliveries")) | ||
| 134 | let model = WebhookDeliveriesViewModel(client: client, repoPath: "krz/gitbay") | ||
| 135 | await model.load() | ||
| 136 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"requeued":70},"exit_code":0}"#, match: "cmd")) | ||
| 137 | stub.enqueue(.init(status: 200, json: deliveriesJSON, match: "argv=deliveries")) | ||
| 138 | |||
| 139 | await model.redeliver(id: 70) | ||
| 140 | |||
| 141 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 142 | #expect(try argvOf(write) == ["webhook", "redeliver", "krz/gitbay", "70"]) | ||
| 143 | #expect(stub.seen.filter { $0.method == "GET" }.count == 2) | ||
| 144 | #expect(model.actionError == nil) | ||
| 145 | } | ||
| 146 | } | ||
gitbayUITests/LiveSmokeUITests.swift +25
| @@ -444,6 +444,31 @@ extension LiveSmokeUITests { | |||
| 444 | XCTAssertTrue(confirmGrantRevoke.waitForExistence(timeout: 5), "revoke confirmation missing") | 444 | XCTAssertTrue(confirmGrantRevoke.waitForExistence(timeout: 5), "revoke confirmation missing") |
| 445 | confirmGrantRevoke.tap() | 445 | confirmGrantRevoke.tap() |
| 446 | XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked") | 446 | XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked") |
| 447 | |||
| 448 | // --- webhooks: add one, see it listed, remove it --- | ||
| 449 | let webhooksLink = app.descendants(matching: .any) | ||
| 450 | .matching(identifier: "settings-webhooks-link").firstMatch | ||
| 451 | XCTAssertTrue(scrollTo(webhooksLink, swipes: 8), "webhooks link not reachable") | ||
| 452 | webhooksLink.tap() | ||
| 453 | let addHook = app.descendants(matching: .any) | ||
| 454 | .matching(identifier: "webhook-add-button").firstMatch | ||
| 455 | XCTAssertTrue(addHook.waitForExistence(timeout: 10), "webhooks did not load") | ||
| 456 | addHook.tap() | ||
| 457 | let hookURL = app.descendants(matching: .any) | ||
| 458 | .matching(identifier: "webhook-url").firstMatch | ||
| 459 | XCTAssertTrue(hookURL.waitForExistence(timeout: 5), "webhook sheet did not open") | ||
| 460 | focusAndType(hookURL, "https://example.invalid/ui-smoke") | ||
| 461 | app.descendants(matching: .any).matching(identifier: "webhook-submit") | ||
| 462 | .firstMatch.tap() | ||
| 463 | let hookRow = app.staticTexts["https://example.invalid/ui-smoke"].firstMatch | ||
| 464 | XCTAssertTrue(hookRow.waitForExistence(timeout: 15), "webhook not listed") | ||
| 465 | hookRow.swipeLeft() | ||
| 466 | app.buttons["Remove"].firstMatch.tap() | ||
| 467 | let confirmHookRemove = app.buttons["Remove"].firstMatch | ||
| 468 | XCTAssertTrue(confirmHookRemove.waitForExistence(timeout: 5), "webhook remove confirmation missing") | ||
| 469 | confirmHookRemove.tap() | ||
| 470 | XCTAssertTrue(waitForDisappearance(hookRow, timeout: 15), "webhook not removed") | ||
| 471 | back() // webhooks -> settings | ||
| 447 | back() // settings -> repo | 472 | back() // settings -> repo |
| 448 | back() // repo -> list | 473 | back() // repo -> list |
| 449 | 474 | ||