import Foundation import Testing import TestSupport import WireProtocol import APIClient /// B1 · git 面板**只读**端点(ios-completion §1.2;真源 = `src/`): /// - `GET /projects/log?path=[&n=]`(`src/server.ts:1030-1056`)→ `GitLogResult` /// (`src/types.ts:757-772`,含 w6/G4 的 `unpushed`/`upstream`); /// - `GET /projects/pr?path=`(`src/server.ts:1058-1076`)→ `PrStatus` /// (`src/types.ts:617-648`);有效 git 目录一律 200,降级写在 body 的 availability 里; /// - `GET /projects/worktree/state?path=`(`src/server.ts:542-562`)→ `WorktreeState` /// (`src/types.ts:392-408`,`sync` 各字段独立降级); /// - `GET /sessions`(`src/server.ts:479-481`)→ `[HistorySession]` /// (`src/http/history.ts:13-19`,`claude --resume` 历史)。 /// /// 三条 `/projects/*` 都做同样的三叉校验:`path` 缺失 → 400、非 git 目录 → 404、 /// 读失败 → 500。全部 RO ⇒ **绝不带 Origin**。 struct GitPanelReadTests { private static let base = "http://192.168.1.5:3000" private static let repoPath = "/Users/dev/web-terminal" private static let encodedRepoPath = "%2FUsers%2Fdev%2Fweb-terminal" private struct Fixture { let http: FakeHTTPTransport let client: APIClient } private func makeFixture() throws -> Fixture { let baseURL = try #require(URL(string: Self.base)) let endpoint = try #require(HostEndpoint(baseURL: baseURL)) let http = FakeHTTPTransport() return Fixture(http: http, client: APIClient(endpoint: endpoint, http: http)) } private func routeURL(_ pathAndQuery: String) throws -> URL { try #require(URL(string: Self.base + pathAndQuery)) } // MARK: - Origin iff-G(RO 侧:四个端点一律不带 Origin) @Test("Origin iff-G(RO 侧):log/pr/worktree-state/sessions 四个端点均为 GET 且不带 Origin") func readOnlyGitEndpointsNeverCarryOrigin() async throws { // Arrange let fixture = try makeFixture() let query = "?path=\(Self.encodedRepoPath)" await fixture.http.queueSuccess( url: try routeURL("/projects/log\(query)"), body: Data(#"{"commits":[]}"#.utf8) ) await fixture.http.queueSuccess( url: try routeURL("/projects/pr\(query)"), body: Data(#"{"availability":"no-pr"}"#.utf8) ) await fixture.http.queueSuccess( url: try routeURL("/projects/worktree/state\(query)"), body: Data(#"{"path":"\#(Self.repoPath)"}"#.utf8) ) await fixture.http.queueSuccess(url: try routeURL("/sessions"), body: Data("[]".utf8)) // Act _ = try await fixture.client.gitLog(path: Self.repoPath) _ = try await fixture.client.prStatus(path: Self.repoPath) _ = try await fixture.client.worktreeState(path: Self.repoPath) _ = try await fixture.client.claudeSessions() // Assert let requests = await fixture.http.recordedRequests #expect(requests.count == 4) for request in requests { #expect(request.httpMethod == "GET") #expect(request.value(forHTTPHeaderField: "Origin") == nil) } } // MARK: - /projects/log @Test("log 的 path 严格 percent-encode(单点);n 缺省则不带 n 参数") func logPathIsStrictlyEncodedAndNilCountOmitsTheParam() async throws { // Arrange let fixture = try makeFixture() let raw = "/Users/dev/my proj+α" let encoded = "%2FUsers%2Fdev%2Fmy%20proj%2B%CE%B1" let expected = try routeURL("/projects/log?path=\(encoded)") await fixture.http.queueSuccess(url: expected, body: Data(#"{"commits":[]}"#.utf8)) // Act _ = try await fixture.client.gitLog(path: raw, n: nil) // Assert #expect(await fixture.http.recordedRequests.first?.url == expected) } @Test("log 的 n 客户端夹到 [1,50](镜像 src/http/git-log.ts GIT_LOG_MAX,服务端仍会再夹一次)") func logCountIsClampedClientSide() async throws { // Arrange let fixture = try makeFixture() let body = Data(#"{"commits":[]}"#.utf8) let cases: [(Int, Int)] = [(0, 1), (-7, 1), (10, 10), (999, 50)] for (_, clamped) in cases { await fixture.http.queueSuccess( url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)&n=\(clamped)"), body: body ) } // Act for (requested, _) in cases { _ = try await fixture.client.gitLog(path: Self.repoPath, n: requested) } // Assert — FakeHTTPTransport 按整 URL 精确匹配,走到这里即夹取正确 let queries = await fixture.http.recordedRequests.compactMap(\.url?.query) #expect(queries == cases.map { "path=\(Self.encodedRepoPath)&n=\($0.1)" }) } @Test("GitLogResult 全字段解码(hash/at/subject/unpushed + truncated + upstream,w6/G4)") func gitLogDecodesFullSample() async throws { // Arrange let fixture = try makeFixture() let body = """ {"commits":[{"hash":"abc1234","at":1720000000000,"subject":"feat: x","unpushed":true},\ {"hash":"def5678","at":1719990000000,"subject":"fix: y"}],\ "truncated":true,"upstream":"origin/develop"} """ await fixture.http.queueSuccess( url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"), body: Data(body.utf8) ) // Act let result = try await fixture.client.gitLog(path: Self.repoPath) // Assert #expect(result.commits.count == 2) #expect(result.truncated) #expect(result.upstream == "origin/develop") let first = try #require(result.commits.first) #expect(first.hash == "abc1234") #expect(first.at == 1_720_000_000_000) #expect(first.subject == "feat: x") #expect(first.unpushed == true) #expect(result.commits.last?.unpushed == nil) // 缺省 ⇒ nil(不是 false) } @Test("log 容忍:畸形 commit 逐条丢弃、subject 缺省为空、truncated 缺省 false、未知字段忽略") func gitLogToleratesDegradedShapes() async throws { // Arrange let fixture = try makeFixture() let body = """ {"commits":[{"hash":"ok1","at":1,"subject":"s"},42,{"at":2},{"hash":"ok2","at":3},\ {"hash":"bad","at":"nope"}],"futureField":{"x":1}} """ await fixture.http.queueSuccess( url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"), body: Data(body.utf8) ) // Act let result = try await fixture.client.gitLog(path: Self.repoPath) // Assert #expect(result.commits.map(\.hash) == ["ok1", "ok2"]) #expect(result.commits.last?.subject == "") #expect(!result.truncated) #expect(result.upstream == nil) } @Test("log 400/404/500 → projectPathInvalid/projectNotFound/gitDataUnavailable;空 path 联网前拒") func gitLogMapsErrorStatuses() async throws { // Arrange let fixture = try makeFixture() let url = try routeURL("/projects/log?path=\(Self.encodedRepoPath)") for status in [400, 404, 500] { await fixture.http.queueSuccess( url: url, status: status, body: Data(#"{"error":"nope"}"#.utf8) ) } // Act + Assert let expected: [APIClientError] = [ .projectPathInvalid, .projectNotFound, .gitDataUnavailable, ] for error in expected { await #expect(throws: error) { _ = try await fixture.client.gitLog(path: Self.repoPath) } #expect(!error.message.isEmpty) } await #expect(throws: APIClientError.projectPathInvalid) { _ = try await fixture.client.gitLog(path: "") } #expect(await fixture.http.recordedRequests.count == 3) // 空 path 未联网 } @Test("log 200 但 body 非对象 → invalidResponseBody") func gitLogRejectsNonObjectBody() async throws { // Arrange let fixture = try makeFixture() await fixture.http.queueSuccess( url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"), body: Data("[1,2]".utf8) ) // Act + Assert await #expect(throws: APIClientError.invalidResponseBody) { _ = try await fixture.client.gitLog(path: Self.repoPath) } } // MARK: - /projects/pr @Test("PrStatus 全字段解码(availability=ok + checks 嵌套计数)") func prStatusDecodesFullSample() async throws { // Arrange let fixture = try makeFixture() let body = """ {"availability":"ok","number":42,"title":"feat: x","url":"https://github.com/a/b/pull/42",\ "state":"open","isDraft":false,"mergeable":"mergeable","headRefName":"feat/x",\ "baseRefName":"develop","checks":{"total":5,"passing":4,"failing":0,"pending":1}} """ await fixture.http.queueSuccess( url: try routeURL("/projects/pr?path=\(Self.encodedRepoPath)"), body: Data(body.utf8) ) // Act let pr = try await fixture.client.prStatus(path: Self.repoPath) // Assert #expect(pr.availability == .ok) #expect(pr.number == 42) #expect(pr.state == "open") #expect(pr.isDraft == false) #expect(pr.mergeable == "mergeable") #expect(pr.headRefName == "feat/x") #expect(pr.baseRefName == "develop") #expect(pr.checks == PrCheckSummary(total: 5, passing: 4, failing: 0, pending: 1)) } @Test("PrStatus 降级:未知/缺失 availability → .error;各降级值可解;字段缺省 → nil") func prStatusDegradesUnknownAvailability() async throws { // Arrange let fixture = try makeFixture() let url = try routeURL("/projects/pr?path=\(Self.encodedRepoPath)") let bodies = [ #"{"availability":"quantum-ci"}"#, // 未来值 → .error #"{}"#, // 缺失 → .error #"{"availability":"not-installed"}"#, #"{"availability":"unauthenticated"}"#, #"{"availability":"disabled"}"#, #"{"availability":"no-pr"}"#, ] for body in bodies { await fixture.http.queueSuccess(url: url, body: Data(body.utf8)) } // Act var seen: [PrAvailability] = [] for _ in bodies { seen.append(try await fixture.client.prStatus(path: Self.repoPath).availability) } // Assert #expect(seen == [.error, .error, .notInstalled, .unauthenticated, .disabled, .noPr]) // 非 ok 时兄弟字段一律缺省 → nil(绝不编造 PR 号) await fixture.http.queueSuccess(url: url, body: Data(#"{"availability":"no-pr"}"#.utf8)) let degraded = try await fixture.client.prStatus(path: Self.repoPath) #expect(degraded.number == nil) #expect(degraded.checks == nil) } @Test("pr 400/404/500 → projectPathInvalid/projectNotFound/gitDataUnavailable;空 path 联网前拒") func prStatusMapsErrorStatuses() async throws { // Arrange let fixture = try makeFixture() let url = try routeURL("/projects/pr?path=\(Self.encodedRepoPath)") for status in [400, 404, 500] { await fixture.http.queueSuccess(url: url, status: status) } // Act + Assert for error in [APIClientError.projectPathInvalid, .projectNotFound, .gitDataUnavailable] { await #expect(throws: error) { _ = try await fixture.client.prStatus(path: Self.repoPath) } } await #expect(throws: APIClientError.projectPathInvalid) { _ = try await fixture.client.prStatus(path: "") } } // MARK: - /projects/worktree/state @Test("WorktreeState 全字段解码;sync.lastFetchMs 是 fs.stat().mtimeMs —— **带小数**也必须解出来") func worktreeStateDecodesFullSampleIncludingFractionalMtime() async throws { // Arrange — 1785390645813.5327 是真实 stat().mtimeMs 的形状(APFS 纳秒精度) let fixture = try makeFixture() let body = """ {"path":"\(Self.repoPath)","branch":"develop","dirtyCount":3,\ "sync":{"upstream":"origin/develop","ahead":2,"behind":1,\ "lastFetchMs":1785390645813.5327,"detached":false}} """ await fixture.http.queueSuccess( url: try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)"), body: Data(body.utf8) ) // Act let state = try await fixture.client.worktreeState(path: Self.repoPath) // Assert #expect(state.path == Self.repoPath) #expect(state.branch == "develop") #expect(state.dirtyCount == 3) let sync = try #require(state.sync) #expect(sync.upstream == "origin/develop") #expect(sync.ahead == 2) #expect(sync.behind == 1) #expect(sync.detached == false) let lastFetchMs = try #require(sync.lastFetchMs) #expect(abs(lastFetchMs - 1_785_390_645_813.5327) < 0.001) } @Test("WorktreeState 各字段独立降级:无 upstream/detached HEAD/从未 fetch 都是正常态") func worktreeStateDegradesEachFieldIndependently() async throws { // Arrange let fixture = try makeFixture() let body = """ {"path":"\(Self.repoPath)","sync":{"detached":true},"unknownField":1} """ await fixture.http.queueSuccess( url: try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)"), body: Data(body.utf8) ) // Act let state = try await fixture.client.worktreeState(path: Self.repoPath) // Assert #expect(state.branch == nil) #expect(state.dirtyCount == nil) let sync = try #require(state.sync) #expect(sync.detached == true) #expect(sync.upstream == nil) #expect(sync.ahead == nil) #expect(sync.behind == nil) #expect(sync.lastFetchMs == nil) // 从未 fetch —— 绝不编造时间戳 } @Test("worktree/state 400/404/500 → projectPathInvalid/worktreeNotFound/gitDataUnavailable;非对象 body → invalidResponseBody") func worktreeStateMapsErrorStatuses() async throws { // Arrange let fixture = try makeFixture() let url = try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)") for status in [400, 404, 500] { await fixture.http.queueSuccess(url: url, status: status) } await fixture.http.queueSuccess(url: url, body: Data("7".utf8)) // Act + Assert for error in [APIClientError.projectPathInvalid, .worktreeNotFound, .gitDataUnavailable] { await #expect(throws: error) { _ = try await fixture.client.worktreeState(path: Self.repoPath) } #expect(!error.message.isEmpty) } await #expect(throws: APIClientError.invalidResponseBody) { _ = try await fixture.client.worktreeState(path: Self.repoPath) } await #expect(throws: APIClientError.projectPathInvalid) { _ = try await fixture.client.worktreeState(path: "") } } // MARK: - GET /sessions(claude --resume 历史) @Test("HistorySession 解码:mtimeMs 来自 fs.stat() —— **带小数**必须解出来(否则整条被丢)") func claudeSessionsDecodeFractionalMtime() async throws { // Arrange let fixture = try makeFixture() let body = """ [{"id":"0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b","cwd":"/Users/dev/web-terminal",\ "project":"web-terminal","mtimeMs":1785390645813.5327,"preview":"修一下 CJK locale"},\ {"id":"11111111-2222-4333-8444-555555555555","cwd":"","project":"unknown",\ "mtimeMs":1700000000000,"preview":""}] """ await fixture.http.queueSuccess(url: try routeURL("/sessions"), body: Data(body.utf8)) // Act let sessions = try await fixture.client.claudeSessions() // Assert #expect(sessions.count == 2) let first = try #require(sessions.first) #expect(first.id == "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b") #expect(first.cwd == "/Users/dev/web-terminal") #expect(first.project == "web-terminal") #expect(abs(first.mtimeMs - 1_785_390_645_813.5327) < 0.001) #expect(first.preview == "修一下 CJK locale") #expect(sessions.last?.mtimeMs == 1_700_000_000_000) // 整数也要能解 } @Test("/sessions 容忍:畸形条目逐条丢弃、可选字段缺省、非数组 body → invalidResponseBody") func claudeSessionsToleratesDegradedShapes() async throws { // Arrange let fixture = try makeFixture() let url = try routeURL("/sessions") let degraded = """ [{"id":"keep-me","mtimeMs":1},"nope",{"cwd":"/x"},{"id":"","mtimeMs":2},\ {"id":"also-keep","mtimeMs":3,"extra":true}] """ await fixture.http.queueSuccess(url: url, body: Data(degraded.utf8)) await fixture.http.queueSuccess(url: url, body: Data(#"{"error":"x"}"#.utf8)) // Act let sessions = try await fixture.client.claudeSessions() // Assert — id 是 `claude --resume ` 的实参,缺失/空 ⇒ 该条无用,丢弃 #expect(sessions.map(\.id) == ["keep-me", "also-keep"]) #expect(sessions.first?.cwd == "") #expect(sessions.first?.project == "") #expect(sessions.first?.preview == "") await #expect(throws: APIClientError.invalidResponseBody) { _ = try await fixture.client.claudeSessions() } } @Test("/sessions 非 200 → unexpectedStatus") func claudeSessionsRejectsBadStatus() async throws { // Arrange let fixture = try makeFixture() await fixture.http.queueSuccess(url: try routeURL("/sessions"), status: 500) // Act + Assert await #expect(throws: APIClientError.unexpectedStatus(500)) { _ = try await fixture.client.claudeSessions() } } }