import Foundation import TestSupport import Testing import WireProtocol @testable import WebTerm /// T-iOS-27 · Diff 查看器 —— `DiffFetcher`(App 层,GET /projects/diff)。 /// /// 覆盖面(Steps 测试先行): /// - 请求形状:GET + path/staged 严格百分号编码 + **无 Origin**(RO 端点, /// §3.4 iff-G 铁律;服务器路由无 requireAllowedOrigin,src/server.ts:601-618); /// - `staged` 序列化为 `1`/`0` —— 服务器精确匹配 `=== '1'`(src/server.ts:613); /// - `DiffResult{files,staged,truncated}` 宽容解码(镜像 public/diff.ts /// normalizeDiffResult:顶层三键缺一 → 整体无效;畸形 file/hunk/line 逐条 /// 丢弃;未知 kind → .context、未知 status → .modified——服务器是不可信输入源); /// - 400 → .pathInvalid、404 → .projectNotFound(SEC-H7 三叉校验失败)、 /// 其余状态 → .unexpectedStatus; /// - 空 path 客户端先拒(镜像服务器 400 规则),零网络。 struct DiffFetcherTests { // MARK: - Fixtures private static func makeEndpoint() throws -> HostEndpoint { let url = try #require(URL(string: "http://127.0.0.1:3000")) return try #require(HostEndpoint(baseURL: url)) } private static func makeFetcher() throws -> (DiffFetcher, FakeHTTPTransport) { let fake = FakeHTTPTransport() let fetcher = DiffFetcher(endpoint: try makeEndpoint(), http: fake) return (fetcher, fake) } private static func url(_ s: String) throws -> URL { try #require(URL(string: s)) } /// 一份完整合法响应体(modified 文件 + 1 hunk + 4 行,覆盖 4 种 kind)。 private static let fullBody = Data(""" { "files": [ { "oldPath": "src/a.ts", "newPath": "src/a.ts", "status": "modified", "added": 2, "removed": 1, "binary": false, "hunks": [ { "header": "@@ -1,3 +1,4 @@", "lines": [ { "kind": "context", "text": "unchanged" }, { "kind": "removed", "text": "old line" }, { "kind": "added", "text": "new line" }, { "kind": "meta", "text": "No newline at end of file" } ] } ] } ], "staged": false, "truncated": false } """.utf8) // MARK: - 请求形状 @Test("GET /projects/diff?path=&staged=0,无 Origin(RO 端点,iff-G 铁律)") func requestShapeIsReadOnlyGetWithoutOrigin() async throws { let (fetcher, fake) = try Self.makeFetcher() let expected = try Self.url( "http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0" ) await fake.queueSuccess(url: expected, body: Self.fullBody) _ = try await fetcher.fetch(path: "/tmp/repo", staged: false) let recorded = await fake.recordedRequests let request = try #require(recorded.first) #expect(recorded.count == 1) #expect(request.httpMethod == "GET") #expect(request.url == expected) // RO 一律不带 Origin:服务器若把该端点改为 G,这里先红(§3.4 铁律)。 #expect(request.value(forHTTPHeaderField: "Origin") == nil) } @Test("staged=true → staged=1(服务器 === '1' 精确匹配,src/server.ts:613)") func stagedSerializesAsOne() async throws { let (fetcher, fake) = try Self.makeFetcher() let expected = try Self.url( "http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=1" ) await fake.queueSuccess(url: expected, body: Self.fullBody) _ = try await fetcher.fetch(path: "/tmp/repo", staged: true) let recorded = await fake.recordedRequests #expect(recorded.first?.url == expected) } @Test("path 严格百分号编码:空格/&/+/= 全部转义(unreserved-only 集)") func pathIsStrictlyPercentEncoded() async throws { let (fetcher, fake) = try Self.makeFetcher() // " " → %20、"&" → %26、"+" → %2B、"=" → %3D、"/" → %2F let expected = try Self.url( "http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Fa%20b%26c%2Bd%3De&staged=0" ) await fake.queueSuccess(url: expected, body: Self.fullBody) _ = try await fetcher.fetch(path: "/tmp/a b&c+d=e", staged: false) let recorded = await fake.recordedRequests #expect(recorded.first?.url == expected) } @Test("空 path → .invalidRequest,零网络(镜像服务器 400 规则,先于 I/O 拒绝)") func emptyPathRejectedBeforeNetwork() async throws { let (fetcher, fake) = try Self.makeFetcher() await #expect(throws: DiffFetchError.invalidRequest) { _ = try await fetcher.fetch(path: "", staged: false) } let recorded = await fake.recordedRequests #expect(recorded.isEmpty) } // MARK: - 解码(服务器为不可信输入源) @Test("200 完整体 → DiffResult 各字段逐一到位") func decodesFullBody() async throws { let (fetcher, fake) = try Self.makeFetcher() let expected = try Self.url( "http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0" ) await fake.queueSuccess(url: expected, body: Self.fullBody) let result = try await fetcher.fetch(path: "/tmp/repo", staged: false) #expect(result.staged == false) #expect(result.truncated == false) #expect(result.files.count == 1) let file = try #require(result.files.first) #expect(file.newPath == "src/a.ts") #expect(file.status == .modified) #expect(file.added == 2) #expect(file.removed == 1) #expect(file.binary == false) let hunk = try #require(file.hunks.first) #expect(hunk.header == "@@ -1,3 +1,4 @@") #expect(hunk.lines.map(\.kind) == [.context, .removed, .added, .meta]) #expect(hunk.lines.map(\.text) == [ "unchanged", "old line", "new line", "No newline at end of file", ]) } @Test("宽容解码:畸形 file 条目丢弃、未知 kind → .context、未知 status → .modified") func tolerantDecodingDropsMalformedEntries() async throws { let (fetcher, fake) = try Self.makeFetcher() let body = Data(""" { "files": [ 42, { "oldPath": 1, "newPath": "x" }, { "oldPath": "u.txt", "newPath": "u.txt", "status": "exotic-future", "added": 0, "removed": 0, "binary": false, "hunks": [ { "lines": [] }, { "header": "@@ -1 +1 @@", "lines": [ { "kind": "sparkle", "text": "mystery" }, { "kind": "added" }, { "kind": "added", "text": "kept" } ] } ] } ], "staged": true, "truncated": true } """.utf8) let expected = try Self.url( "http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=1" ) await fake.queueSuccess(url: expected, body: body) let result = try await fetcher.fetch(path: "/tmp/repo", staged: true) #expect(result.truncated == true) #expect(result.files.count == 1) // 42 与缺字段对象被丢弃 let file = try #require(result.files.first) #expect(file.status == .modified) // 未知 status 降级 #expect(file.hunks.count == 1) // 缺 header 的 hunk 被丢弃 let lines = try #require(file.hunks.first).lines #expect(lines.count == 2) // 缺 text 的行被丢弃 #expect(lines.first?.kind == .context) // 未知 kind 降级(同 web df-context 兜底) #expect(lines.last?.text == "kept") } @Test("顶层畸形(缺键 / 非对象 / 非 JSON)→ .invalidResponse", arguments: [ #"{"staged": false, "truncated": false}"#, #"[1, 2, 3]"#, "not json at all", ]) func malformedTopLevelIsInvalidResponse(raw: String) async throws { let (fetcher, fake) = try Self.makeFetcher() let expected = try Self.url( "http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0" ) await fake.queueSuccess(url: expected, body: Data(raw.utf8)) await #expect(throws: DiffFetchError.invalidResponse) { _ = try await fetcher.fetch(path: "/tmp/repo", staged: false) } } // MARK: - 错误状态映射(route 层语义,src/server.ts:602-618) @Test("400 → .pathInvalid、404 → .projectNotFound、500 → .unexpectedStatus(500)") func statusCodesMapToTypedErrors() async throws { let cases: [(Int, DiffFetchError)] = [ (400, .pathInvalid), (404, .projectNotFound), (500, .unexpectedStatus(500)), (403, .unexpectedStatus(403)), ] for (status, expectedError) in cases { let (fetcher, fake) = try Self.makeFetcher() let expected = try Self.url( "http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0" ) await fake.queueSuccess( url: expected, status: status, body: Data(#"{"error":"x"}"#.utf8) ) await #expect(throws: expectedError) { _ = try await fetcher.fetch(path: "/tmp/repo", staged: false) } } } @Test("传输层错误原样上抛(不吞、不错误分类)") func transportErrorsPropagate() async throws { struct Boom: Error {} let (fetcher, fake) = try Self.makeFetcher() let expected = try Self.url( "http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0" ) await fake.queueFailure(url: expected, error: Boom()) await #expect(throws: Boom.self) { _ = try await fetcher.fetch(path: "/tmp/repo", staged: false) } } }