APIClient (77 -> 125 tests, coverage 92.22%): POST /auth probe with the four distinct outcomes from the frozen contract, Cookie/Accept landed at the same single header choke point that already enforces Origin-iff-G, plus the whole project-ops surface the server has had since late July and iOS consumed none of: /projects/log, /projects/pr, /projects/worktree/state, git stage/commit/push/ fetch, worktree create/remove/prune, GET /sessions, follow-up queue. HostRegistry (30 -> 73 tests, 88.12% -> 92.49%): per-host token in the Keychain under the existing SecItemShim conventions (device-only, never synchronizable), charset/length validated at the boundary, old token-less records still decode. SessionCore (93 -> 108 tests, 96.74%): the WS upgrade carries the cookie from the same point that writes Origin, and a 401 handshake is a terminal .unauthorized -- never entering the backoff loop, since retrying one wrong shared token is a brute-force generator against the server's 10/min limiter.
460 lines
21 KiB
Swift
460 lines
21 KiB
Swift
import Foundation
|
||
import Testing
|
||
import TestSupport
|
||
import WireProtocol
|
||
import APIClient
|
||
|
||
/// B1 · **G(变更)** 端点:git 写通道 + worktree 管理 + w2 注入队列
|
||
/// (ios-completion §1.2;真源 = `src/`)。
|
||
///
|
||
/// - `POST /projects/git/stage`(`src/server.ts:1184-1218`)body `{path,files,stage}`
|
||
/// - `POST /projects/git/commit`(`:1219-1255`)body `{path,message}`
|
||
/// - `POST /projects/git/push`(`:1256-1289`)body `{path}`
|
||
/// - `POST /projects/git/fetch`(`:1290-1319`)body `{path}`
|
||
/// - `POST /projects/worktree`(`:1095-1123`)body `{path,branch[,base]}`
|
||
/// - `DELETE /projects/worktree`(`:1124-1153`)body `{path,worktreePath,force}`
|
||
/// - `POST /projects/worktree/prune`(`:1154-1183`)body `{path}`
|
||
/// - `POST /live-sessions/:id/queue`(`:605-643`)body `{text[,appendEnter]}`
|
||
///
|
||
/// 全部 **必带 `Origin`**。失败 body 只带服务端**已分类、已脱敏**的 `error`
|
||
/// 字符串(`src/http/git-ops.ts` / `worktrees.ts`,SEC-M10)—— 客户端逐字展示,
|
||
/// 绝不自己拼 git stderr。403 是**重载**状态(Origin 守卫失败 **和** 功能开关关闭
|
||
/// 都是 403),客户端无法凭状态区分,因此原样展示 message。
|
||
struct GitWriteTests {
|
||
private static let base = "http://192.168.1.5:3000"
|
||
private static let repoPath = "/Users/dev/web-terminal"
|
||
private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"
|
||
|
||
private struct Fixture {
|
||
let http: FakeHTTPTransport
|
||
let endpoint: HostEndpoint
|
||
let client: APIClient
|
||
}
|
||
|
||
private func makeFixture(accessToken: String? = nil) throws -> Fixture {
|
||
let baseURL = try #require(URL(string: Self.base))
|
||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||
let http = FakeHTTPTransport()
|
||
return Fixture(
|
||
http: http, endpoint: endpoint,
|
||
client: APIClient(endpoint: endpoint, http: http, accessToken: accessToken)
|
||
)
|
||
}
|
||
|
||
private func routeURL(_ path: String) throws -> URL {
|
||
try #require(URL(string: Self.base + path))
|
||
}
|
||
|
||
private func bodyObject(_ request: URLRequest) throws -> [String: Any] {
|
||
let body = try #require(request.httpBody)
|
||
return try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||
}
|
||
|
||
// MARK: - Origin iff-G(G 侧:七条写路由全带 Origin,逐字符相等)
|
||
|
||
@Test("Origin iff-G(G 侧):七条 git/worktree 写路由全部带逐字符相等的 Origin + JSON Content-Type")
|
||
func everyGitWriteRouteCarriesByteEqualOrigin() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let ok = Data(#"{"ok":true}"#.utf8)
|
||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/stage"), body: ok)
|
||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/commit"), body: ok)
|
||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/push"), body: ok)
|
||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/fetch"), body: ok)
|
||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok)
|
||
await fixture.http.queueSuccess(method: "DELETE", url: try routeURL("/projects/worktree"), body: ok)
|
||
await fixture.http.queueSuccess(
|
||
method: "POST", url: try routeURL("/projects/worktree/prune"), body: ok
|
||
)
|
||
|
||
// Act
|
||
_ = try await fixture.client.gitStage(path: Self.repoPath, files: ["a.txt"], stage: true)
|
||
_ = try await fixture.client.gitCommit(path: Self.repoPath, message: "feat: x")
|
||
_ = try await fixture.client.gitPush(path: Self.repoPath)
|
||
_ = try await fixture.client.gitFetch(path: Self.repoPath)
|
||
_ = try await fixture.client.createWorktree(
|
||
path: Self.repoPath, branch: "wt-x", base: nil
|
||
)
|
||
_ = try await fixture.client.removeWorktree(
|
||
path: Self.repoPath, worktreePath: "\(Self.repoPath)/.claude/worktrees/x", force: false
|
||
)
|
||
_ = try await fixture.client.pruneWorktrees(path: Self.repoPath)
|
||
|
||
// Assert
|
||
let requests = await fixture.http.recordedRequests
|
||
#expect(requests.count == 7)
|
||
for request in requests {
|
||
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
|
||
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||
}
|
||
}
|
||
|
||
@Test("配置了访问令牌时,G 写路由同时带 Cookie 与 Origin(令牌不替代 Origin)")
|
||
func gitWriteCarriesCookieAlongsideOrigin() async throws {
|
||
// Arrange
|
||
let token = "s3cret-token_value.~+/="
|
||
let fixture = try makeFixture(accessToken: token)
|
||
await fixture.http.queueSuccess(
|
||
method: "POST", url: try routeURL("/projects/git/push"), body: Data(#"{"ok":true}"#.utf8)
|
||
)
|
||
|
||
// Act
|
||
_ = try await fixture.client.gitPush(path: Self.repoPath)
|
||
|
||
// Assert
|
||
let request = try #require(await fixture.http.recordedRequests.first)
|
||
#expect(request.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(token)")
|
||
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
|
||
}
|
||
|
||
// MARK: - body 形状逐字段冻结
|
||
|
||
@Test("body 形状:stage={path,files,stage} · commit={path,message} · push/fetch/prune={path}")
|
||
func gitWriteBodyShapesAreExact() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let ok = Data(#"{"ok":true}"#.utf8)
|
||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/stage"), body: ok)
|
||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/commit"), body: ok)
|
||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/push"), body: ok)
|
||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/fetch"), body: ok)
|
||
await fixture.http.queueSuccess(
|
||
method: "POST", url: try routeURL("/projects/worktree/prune"), body: ok
|
||
)
|
||
|
||
// Act
|
||
_ = try await fixture.client.gitStage(path: Self.repoPath, files: ["a.txt", "b/c.md"], stage: false)
|
||
_ = try await fixture.client.gitCommit(path: Self.repoPath, message: "fix: 修中文")
|
||
_ = try await fixture.client.gitPush(path: Self.repoPath)
|
||
_ = try await fixture.client.gitFetch(path: Self.repoPath)
|
||
_ = try await fixture.client.pruneWorktrees(path: Self.repoPath)
|
||
|
||
// Assert
|
||
let requests = await fixture.http.recordedRequests
|
||
let stage = try bodyObject(try #require(requests.first))
|
||
#expect(Set(stage.keys) == Set(["path", "files", "stage"]))
|
||
#expect(stage["path"] as? String == Self.repoPath)
|
||
#expect(stage["files"] as? [String] == ["a.txt", "b/c.md"])
|
||
#expect(stage["stage"] as? Bool == false)
|
||
let commit = try bodyObject(requests[1])
|
||
#expect(Set(commit.keys) == Set(["path", "message"]))
|
||
#expect(commit["message"] as? String == "fix: 修中文")
|
||
for index in 2...4 {
|
||
let single = try bodyObject(requests[index])
|
||
#expect(Set(single.keys) == Set(["path"]))
|
||
#expect(single["path"] as? String == Self.repoPath)
|
||
}
|
||
}
|
||
|
||
@Test("worktree body:create 的 base 为 nil 时**不带该键**;remove 恒带 {path,worktreePath,force}")
|
||
func worktreeBodyShapesAreExact() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let ok = Data(#"{"ok":true}"#.utf8)
|
||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok)
|
||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok)
|
||
await fixture.http.queueSuccess(method: "DELETE", url: try routeURL("/projects/worktree"), body: ok)
|
||
|
||
// Act
|
||
_ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "wt-a", base: nil)
|
||
_ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "wt-b", base: "develop")
|
||
_ = try await fixture.client.removeWorktree(
|
||
path: Self.repoPath, worktreePath: "/tmp/wt", force: true
|
||
)
|
||
|
||
// Assert
|
||
let requests = await fixture.http.recordedRequests
|
||
let withoutBase = try bodyObject(try #require(requests.first))
|
||
#expect(Set(withoutBase.keys) == Set(["path", "branch"]))
|
||
#expect(withoutBase["branch"] as? String == "wt-a")
|
||
let withBase = try bodyObject(requests[1])
|
||
#expect(Set(withBase.keys) == Set(["path", "branch", "base"]))
|
||
#expect(withBase["base"] as? String == "develop")
|
||
let remove = try bodyObject(requests[2])
|
||
#expect(Set(remove.keys) == Set(["path", "worktreePath", "force"]))
|
||
#expect(remove["worktreePath"] as? String == "/tmp/wt")
|
||
#expect(remove["force"] as? Bool == true)
|
||
#expect(requests[2].httpMethod == "DELETE") // DELETE **带** JSON body
|
||
}
|
||
|
||
// MARK: - 200 payload 解码(畸形 body 降级为默认值,绝不抛)
|
||
|
||
@Test("200 payload 解码:stage/commit/push/fetch/create/remove/prune 各自形状")
|
||
func successPayloadsDecodePerRoute() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
await fixture.http.queueSuccess(
|
||
method: "POST", url: try routeURL("/projects/git/stage"),
|
||
body: Data(#"{"ok":true,"staged":true,"count":2}"#.utf8)
|
||
)
|
||
await fixture.http.queueSuccess(
|
||
method: "POST", url: try routeURL("/projects/git/commit"),
|
||
body: Data(#"{"ok":true,"commit":"abc1234"}"#.utf8)
|
||
)
|
||
await fixture.http.queueSuccess(
|
||
method: "POST", url: try routeURL("/projects/git/push"),
|
||
body: Data(#"{"ok":true,"branch":"develop","remote":"origin"}"#.utf8)
|
||
)
|
||
await fixture.http.queueSuccess(
|
||
method: "POST", url: try routeURL("/projects/git/fetch"),
|
||
body: Data(#"{"ok":true,"remote":"origin","lastFetchMs":1785390645813.5327}"#.utf8)
|
||
)
|
||
await fixture.http.queueSuccess(
|
||
method: "POST", url: try routeURL("/projects/worktree"),
|
||
body: Data(#"{"ok":true,"path":"/tmp/wt","branch":"worktree-x"}"#.utf8)
|
||
)
|
||
await fixture.http.queueSuccess(
|
||
method: "DELETE", url: try routeURL("/projects/worktree"),
|
||
body: Data(#"{"ok":true,"path":"/tmp/wt"}"#.utf8)
|
||
)
|
||
await fixture.http.queueSuccess(
|
||
method: "POST", url: try routeURL("/projects/worktree/prune"),
|
||
body: Data(#"{"ok":true,"pruned":["wt-a","wt-b"]}"#.utf8)
|
||
)
|
||
|
||
// Act + Assert
|
||
#expect(
|
||
try await fixture.client.gitStage(path: Self.repoPath, files: ["a"], stage: true)
|
||
== .ok(StageResult(staged: true, count: 2))
|
||
)
|
||
#expect(
|
||
try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
|
||
== .ok(CommitResult(commit: "abc1234"))
|
||
)
|
||
#expect(
|
||
try await fixture.client.gitPush(path: Self.repoPath)
|
||
== .ok(PushResult(branch: "develop", remote: "origin"))
|
||
)
|
||
let fetched = try await fixture.client.gitFetch(path: Self.repoPath)
|
||
guard case .ok(let fetchResult) = fetched else {
|
||
Issue.record("fetch 应为 .ok")
|
||
return
|
||
}
|
||
#expect(fetchResult.remote == "origin")
|
||
// lastFetchMs 来自 fs.stat().mtimeMs —— 带小数,必须解出来
|
||
#expect(abs(try #require(fetchResult.lastFetchMs) - 1_785_390_645_813.5327) < 0.001)
|
||
#expect(
|
||
try await fixture.client.createWorktree(path: Self.repoPath, branch: "x", base: nil)
|
||
== .ok(CreateWorktreeResult(path: "/tmp/wt", branch: "worktree-x"))
|
||
)
|
||
#expect(
|
||
try await fixture.client.removeWorktree(
|
||
path: Self.repoPath, worktreePath: "/tmp/wt", force: false
|
||
) == .ok(RemoveWorktreeResult(path: "/tmp/wt"))
|
||
)
|
||
#expect(
|
||
try await fixture.client.pruneWorktrees(path: Self.repoPath)
|
||
== .ok(PruneWorktreesResult(pruned: ["wt-a", "wt-b"]))
|
||
)
|
||
}
|
||
|
||
@Test("200 但 payload 缺失/畸形 → 降级为默认值(空 sha / 空 pruned),绝不抛")
|
||
func garbledSuccessPayloadDegradesToDefaults() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
await fixture.http.queueSuccess(
|
||
method: "POST", url: try routeURL("/projects/git/commit"), body: Data("[]".utf8)
|
||
)
|
||
await fixture.http.queueSuccess(
|
||
method: "POST", url: try routeURL("/projects/worktree/prune"),
|
||
body: Data(#"{"ok":true,"pruned":"not-an-array"}"#.utf8)
|
||
)
|
||
|
||
// Act + Assert
|
||
#expect(
|
||
try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
|
||
== .ok(CommitResult(commit: ""))
|
||
)
|
||
#expect(
|
||
try await fixture.client.pruneWorktrees(path: Self.repoPath)
|
||
== .ok(PruneWorktreesResult(pruned: []))
|
||
)
|
||
}
|
||
|
||
// MARK: - 失败映射(429 独立;其余原样带出服务端安全 message)
|
||
|
||
@Test("429 → .rateLimited(stage/commit 共用一个限流器,push/fetch 各有更紧的),不得自动重试")
|
||
func rateLimitedIsItsOwnOutcome() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
await fixture.http.queueSuccess(
|
||
method: "POST", url: try routeURL("/projects/git/push"), status: 429,
|
||
body: Data(#"{"error":"Too many requests."}"#.utf8)
|
||
)
|
||
|
||
// Act
|
||
let outcome = try await fixture.client.gitPush(path: Self.repoPath)
|
||
|
||
// Assert
|
||
#expect(outcome == .rateLimited)
|
||
}
|
||
|
||
@Test("403/409/400/500 → .rejected(status, 服务端已脱敏 message)逐字带出(403 重载:Origin 或开关)")
|
||
func failuresCarryTheServerSafeMessageVerbatim() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let cases: [(Int, String)] = [
|
||
(403, "Git operations are disabled."),
|
||
(409, "Nothing staged to commit."),
|
||
(400, "Set a git author identity (user.name / user.email) first."),
|
||
(500, "Git operation failed."),
|
||
]
|
||
for (status, message) in cases {
|
||
await fixture.http.queueSuccess(
|
||
method: "POST", url: try routeURL("/projects/git/commit"), status: status,
|
||
body: Data(#"{"ok":false,"error":"\#(message)"}"#.utf8)
|
||
)
|
||
}
|
||
|
||
// Act + Assert
|
||
for (status, message) in cases {
|
||
let outcome = try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
|
||
#expect(outcome == .rejected(status: status, message: message))
|
||
}
|
||
}
|
||
|
||
@Test("失败 body 无法解析 → .rejected(status, nil),不编造原因")
|
||
func unparseableFailureBodyYieldsNilMessage() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
await fixture.http.queueSuccess(
|
||
method: "POST", url: try routeURL("/projects/worktree"), status: 500,
|
||
body: Data("<html>oops</html>".utf8)
|
||
)
|
||
|
||
// Act
|
||
let outcome = try await fixture.client.createWorktree(
|
||
path: Self.repoPath, branch: "x", base: nil
|
||
)
|
||
|
||
// Assert
|
||
#expect(outcome == .rejected(status: 500, message: nil))
|
||
}
|
||
|
||
@Test("push 的 401 是**路由自身**语义('Push authentication required on the host.'),不得当成访问令牌 401")
|
||
func push401IsRouteClassifiedNotTheAccessTokenGate() async throws {
|
||
// Arrange — src/http/git-ops.ts:108 把主机侧 git 凭据失败分类成 401
|
||
let fixture = try makeFixture(accessToken: "s3cret-token_value.~+/=")
|
||
let message = "Push authentication required on the host."
|
||
await fixture.http.queueSuccess(
|
||
method: "POST", url: try routeURL("/projects/git/push"), status: 401,
|
||
body: Data(#"{"ok":false,"error":"\#(message)"}"#.utf8)
|
||
)
|
||
|
||
// Act
|
||
let outcome = try await fixture.client.gitPush(path: Self.repoPath)
|
||
|
||
// Assert — 若被 gate 规则吞掉就会抛 .unauthorized,那会误导用户去补令牌
|
||
#expect(outcome == .rejected(status: 401, message: message))
|
||
}
|
||
|
||
@Test("空 path 联网前拒(projectPathInvalid),七条写路由一致")
|
||
func emptyPathIsRejectedBeforeNetworkOnEveryWriteRoute() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
|
||
// Act + Assert
|
||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||
_ = try await fixture.client.gitStage(path: "", files: ["a"], stage: true)
|
||
}
|
||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||
_ = try await fixture.client.gitCommit(path: "", message: "m")
|
||
}
|
||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||
_ = try await fixture.client.gitPush(path: "")
|
||
}
|
||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||
_ = try await fixture.client.gitFetch(path: "")
|
||
}
|
||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||
_ = try await fixture.client.createWorktree(path: "", branch: "x", base: nil)
|
||
}
|
||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||
_ = try await fixture.client.removeWorktree(path: "", worktreePath: "/tmp/wt", force: false)
|
||
}
|
||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||
_ = try await fixture.client.pruneWorktrees(path: "")
|
||
}
|
||
#expect(await fixture.http.recordedRequests.isEmpty)
|
||
}
|
||
|
||
// MARK: - POST /live-sessions/:id/queue(w2 pty 注入队列)
|
||
|
||
@Test("queue 路由:POST /live-sessions/<小写 UUID>/queue,带 Origin,body 恰为 {text,appendEnter}")
|
||
func queueRouteShapeIsExact() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let id = try #require(UUID(uuidString: Self.sessionIdString))
|
||
let url = try routeURL("/live-sessions/\(Self.sessionIdString)/queue")
|
||
await fixture.http.queueSuccess(
|
||
method: "POST", url: url, body: Data(#"{"length":2}"#.utf8)
|
||
)
|
||
|
||
// Act
|
||
let depth = try await fixture.client.enqueueFollowup(
|
||
sessionId: id, text: "继续", appendEnter: true
|
||
)
|
||
|
||
// Assert
|
||
#expect(depth == 2)
|
||
let request = try #require(await fixture.http.recordedRequests.first)
|
||
#expect(request.httpMethod == "POST")
|
||
#expect(request.url == url) // :id 按字符串精确匹配 ⇒ 必须小写
|
||
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
|
||
let body = try bodyObject(request)
|
||
#expect(Set(body.keys) == Set(["text", "appendEnter"]))
|
||
#expect(body["text"] as? String == "继续")
|
||
#expect(body["appendEnter"] as? Bool == true)
|
||
}
|
||
|
||
@Test("queue 错误映射:400/403/404/409/413/429/503 各自类型化,话术非空")
|
||
func queueMapsEveryServerStatusToATypedError() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let id = try #require(UUID(uuidString: Self.sessionIdString))
|
||
let url = try routeURL("/live-sessions/\(Self.sessionIdString)/queue")
|
||
let cases: [(Int, APIClientError)] = [
|
||
(400, .queueTextInvalid),
|
||
(403, .forbidden),
|
||
(404, .sessionNotFound),
|
||
(409, .queueFull),
|
||
(413, .queueTextTooLarge),
|
||
(429, .rateLimited),
|
||
(503, .queueDisabled),
|
||
(500, .unexpectedStatus(500)),
|
||
]
|
||
for (status, _) in cases {
|
||
await fixture.http.queueSuccess(method: "POST", url: url, status: status)
|
||
}
|
||
|
||
// Act + Assert
|
||
for (_, expected) in cases {
|
||
await #expect(throws: expected) {
|
||
_ = try await fixture.client.enqueueFollowup(
|
||
sessionId: id, text: "x", appendEnter: false
|
||
)
|
||
}
|
||
#expect(!expected.message.isEmpty)
|
||
}
|
||
}
|
||
|
||
@Test("queue 空 text 联网前拒(镜像服务器 400 规则);200 但 body 无 length → invalidResponseBody")
|
||
func queueRejectsEmptyTextAndGarbledSuccessBody() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let id = try #require(UUID(uuidString: Self.sessionIdString))
|
||
await fixture.http.queueSuccess(
|
||
method: "POST", url: try routeURL("/live-sessions/\(Self.sessionIdString)/queue"),
|
||
body: Data(#"{"ok":true}"#.utf8)
|
||
)
|
||
|
||
// Act + Assert
|
||
await #expect(throws: APIClientError.queueTextInvalid) {
|
||
_ = try await fixture.client.enqueueFollowup(sessionId: id, text: "", appendEnter: true)
|
||
}
|
||
await #expect(throws: APIClientError.invalidResponseBody) {
|
||
_ = try await fixture.client.enqueueFollowup(sessionId: id, text: "x", appendEnter: false)
|
||
}
|
||
}
|
||
}
|