gitbayTests/SnippetTests.swift
189 lines · 8246 bytes
16 symbols in this file
makeClientargvOfSnippetDecodingTestsaListRowCarriesFileNamesAndSizesButNoContentwithoutADescriptionTheTitleIsTheIdSnippetListViewModelTestsmineListsWithoutAnOwneranOwnersListNamesTheOwnercreateSendsTheFileOnStdinAndReturnsTheSnippetaRefusedCreateSurfacesTheRuleSnippetDetailViewModelTestsloadedshowCarriesEveryFilesContenteditSendsOnlyTheFieldsGivenfileSetTravelsAsStdinAndRemoveNamesTheFiledeleteReportsSuccessAndDoesNotReload
1import Foundation
2import Testing
3@testable import gitbay
4
5private 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
15private func argvOf(_ seen: StubProtocol.Seen) throws -> ([String], String?) {
16 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
17 return (try #require(body["argv"] as? [String]), body["stdin"] as? String)
18}
19
20private let snippetRow = """
21 {"id":"196505b81127","url":"https://gitbay.org/cmc/-/snippets/196505b81127","owner":"cmc",\
22 "description":"bootstrap file for homebrew","visibility":"public",\
23 "created_at":"2026-09-13T18:27:00.989Z","updated_at":"2026-09-13T18:27:00.989Z",\
24 "files":[{"name":"brew.sh","size":812}]}
25 """
26private let bareRow = """
27 {"id":"aa11bb22cc33","url":"https://gitbay.org/cmc/-/snippets/aa11bb22cc33","owner":"cmc",\
28 "visibility":"unlisted","created_at":"2026-09-14T10:00:00Z","updated_at":"2026-09-14T10:00:00Z",\
29 "files":[{"name":"notes.txt","size":4},{"name":"more.txt","size":5}]}
30 """
31private let listPageJSON = """
32 {"protocol_version":1,"data":{"items":[\(snippetRow),\(bareRow)]},"exit_code":0}
33 """
34private let showJSON = """
35 {"protocol_version":1,"data":{"id":"196505b81127","url":"https://gitbay.org/cmc/-/snippets/196505b81127",\
36 "owner":"cmc","description":"bootstrap file for homebrew","visibility":"public",\
37 "created_at":"2026-09-13T18:27:00.989Z","updated_at":"2026-09-13T18:27:00.989Z",\
38 "files":[{"name":"brew.sh","size":812,"content":"#!/usr/bin/env bash\\necho hi\\n"}]},"exit_code":0}
39 """
40private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
41
42struct SnippetDecodingTests {
43
44 @Test func aListRowCarriesFileNamesAndSizesButNoContent() throws {
45 let snippet = try GitbayClient.decoder().decode(Snippet.self, from: Data(snippetRow.utf8))
46 #expect(snippet.id == "196505b81127")
47 #expect(snippet.owner == "cmc")
48 #expect(snippet.visibility == "public")
49 #expect(snippet.files.map(\.name) == ["brew.sh"])
50 #expect(snippet.files[0].size == 812)
51 #expect(snippet.files[0].content == nil)
52 #expect(snippet.title == "bootstrap file for homebrew")
53 }
54
55 /// The web titles a snippet by its description and falls back to the
56 /// id; the description key is omitempty.
57 @Test func withoutADescriptionTheTitleIsTheId() throws {
58 let snippet = try GitbayClient.decoder().decode(Snippet.self, from: Data(bareRow.utf8))
59 #expect(snippet.description == nil)
60 #expect(snippet.title == "aa11bb22cc33")
61 #expect(snippet.fileNames == "notes.txt, more.txt")
62 }
63}
64
65@MainActor
66struct SnippetListViewModelTests {
67
68 @Test func mineListsWithoutAnOwner() async throws {
69 let (client, stub) = try makeClient()
70 stub.enqueue(.init(status: 200, json: listPageJSON))
71 let model = SnippetListViewModel(client: client, owner: nil)
72
73 await model.load()
74
75 #expect(stub.seen.first?.url.query()?.hasPrefix("argv=snippet&argv=list&argv=--limit") == true)
76 #expect(model.state.value?.map(\.id) == ["196505b81127", "aa11bb22cc33"])
77 }
78
79 @Test func anOwnersListNamesTheOwner() async throws {
80 let (client, stub) = try makeClient()
81 stub.enqueue(.init(status: 200, json: listPageJSON))
82 let model = SnippetListViewModel(client: client, owner: "cmc")
83
84 await model.load()
85
86 #expect(stub.seen.first?.url.query()?.hasPrefix("argv=snippet&argv=list&argv=cmc&argv=--limit") == true)
87 }
88
89 /// `snippet create <filename> [--description] [--visibility] < file`:
90 /// the file travels as bare stdin, an empty description sends no
91 /// flag, and the created snippet comes back.
92 @Test func createSendsTheFileOnStdinAndReturnsTheSnippet() async throws {
93 let (client, stub) = try makeClient()
94 stub.enqueue(.init(status: 200, json: listPageJSON))
95 let model = SnippetListViewModel(client: client, owner: nil)
96 await model.load()
97 stub.enqueue(.init(status: 200, json: showJSON, match: "cmd"))
98 stub.enqueue(.init(status: 200, json: listPageJSON, match: "argv=list"))
99
100 let created = await model.create(
101 filename: "brew.sh", description: "", visibility: "public", content: "echo hi\n")
102
103 #expect(created?.id == "196505b81127")
104 let (argv, stdin) = try argvOf(try #require(stub.seen.first { $0.method == "POST" }))
105 #expect(argv == ["snippet", "create", "brew.sh", "--visibility", "public"])
106 #expect(stdin == "echo hi\n")
107 #expect(stub.seen.filter { $0.method == "GET" }.count == 2)
108 }
109
110 @Test func aRefusedCreateSurfacesTheRule() async throws {
111 let (client, stub) = try makeClient()
112 stub.enqueue(.init(status: 200, json: listPageJSON))
113 let model = SnippetListViewModel(client: client, owner: nil)
114 await model.load()
115 stub.enqueue(.init(status: 200, json: """
116 {"protocol_version":1,"error":"snippet limit reached (20); delete one first","exit_code":2}
117 """, match: "cmd"))
118
119 let created = await model.create(
120 filename: "x.txt", description: "d", visibility: "unlisted", content: "x")
121
122 #expect(created == nil)
123 #expect(model.actionError == "snippet limit reached (20); delete one first")
124 }
125}
126
127@MainActor
128struct SnippetDetailViewModelTests {
129
130 private func loaded() async throws -> (SnippetDetailViewModel, StubProtocol.Box) {
131 let (client, stub) = try makeClient()
132 stub.enqueue(.init(status: 200, json: showJSON, match: "argv=show"))
133 let model = SnippetDetailViewModel(client: client, id: "196505b81127")
134 await model.load()
135 return (model, stub)
136 }
137
138 @Test func showCarriesEveryFilesContent() async throws {
139 let (model, stub) = try await loaded()
140 #expect(stub.seen.first?.url.query() == "argv=snippet&argv=show&argv=196505b81127")
141 let snippet = try #require(model.state.value)
142 #expect(snippet.files[0].content == "#!/usr/bin/env bash\necho hi\n")
143 }
144
145 @Test func editSendsOnlyTheFieldsGiven() async throws {
146 let (model, stub) = try await loaded()
147 for _ in 0..<2 {
148 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
149 stub.enqueue(.init(status: 200, json: showJSON, match: "argv=show"))
150 }
151
152 await model.edit(description: "renamed", visibility: "private")
153 await model.edit(description: nil, visibility: "unlisted")
154
155 let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 }
156 #expect(writes == [
157 ["snippet", "edit", "196505b81127", "--description", "renamed", "--visibility", "private"],
158 ["snippet", "edit", "196505b81127", "--visibility", "unlisted"],
159 ])
160 }
161
162 @Test func fileSetTravelsAsStdinAndRemoveNamesTheFile() async throws {
163 let (model, stub) = try await loaded()
164 for _ in 0..<2 {
165 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
166 stub.enqueue(.init(status: 200, json: showJSON, match: "argv=show"))
167 }
168
169 await model.setFile("notes.txt", content: "hello\n")
170 await model.removeFile("notes.txt")
171
172 let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf)
173 #expect(writes[0].0 == ["snippet", "file", "set", "196505b81127", "notes.txt"])
174 #expect(writes[0].1 == "hello\n")
175 #expect(writes[1].0 == ["snippet", "file", "remove", "196505b81127", "notes.txt"])
176 }
177
178 @Test func deleteReportsSuccessAndDoesNotReload() async throws {
179 let (model, stub) = try await loaded()
180 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
181
182 let ok = await model.delete()
183
184 #expect(ok)
185 #expect(try argvOf(try #require(stub.seen.first { $0.method == "POST" })).0
186 == ["snippet", "delete", "196505b81127"])
187 #expect(stub.seen.filter { $0.method == "GET" }.count == 1)
188 }
189}