import Foundation import Testing import TestSupport import WireProtocol import APIClient /// T-iOS-38 · Projects 契约增量(RO,无 Origin): /// - `GET /projects`(src/server.ts:262-269)→ `[ProjectInfo]`(src/types.ts:273-281 实际形状: /// name/path/isGit 必填,branch/dirty/lastActiveMs 可选,sessions 为 ProjectSessionRef[]; /// 无 namespace 字段 —— namespace 是 web 端分组概念,只出现在 prefs.collapsed 的 key 里); /// - `GET /projects/detail?path=`(src/server.ts:293-310)→ `ProjectDetail`; /// path 的 percent-encoding **只在 builder 一处**处理;400/404/500 `{error}` → 显式类型化错误。 /// 服务器是不可信输入源:未知字段忽略、畸形条目逐条丢弃、绝不 crash。 struct ProjectsTests { private static let base = "http://192.168.1.5:3000" private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b" private static let fullProjectJSON = """ {"name":"web-terminal","path":"/Users/dev/web-terminal","isGit":true,\ "branch":"main","dirty":true,"lastActiveMs":1720000000000,\ "sessions":[{"id":"\(sessionIdString)","title":"web-terminal","status":"working",\ "clientCount":2,"createdAt":1719990000000,"exited":false}]} """ private static let minimalProjectJSON = """ {"name":"notes","path":"/Users/dev/notes","isGit":false,"sessions":[]} """ 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)) } private func fetchProjects(_ fixture: Fixture, body: String) async throws -> [ProjectInfo] { await fixture.http.queueSuccess(url: try routeURL("/projects"), body: Data(body.utf8)) return try await fixture.client.projects() } // MARK: - Origin iff-G(两个新 RO 端点都不带 Origin) @Test("Origin iff-G(RO 侧):GET /projects 与 GET /projects/detail 均不带 Origin") func projectsEndpointsAreReadOnlyWithoutOrigin() async throws { // Arrange let fixture = try makeFixture() await fixture.http.queueSuccess(url: try routeURL("/projects"), body: Data("[]".utf8)) await fixture.http.queueSuccess( url: try routeURL("/projects/detail?path=%2FUsers%2Fdev%2Fnotes"), body: Data(Self.detailJSON.utf8) ) // Act _ = try await fixture.client.projects() _ = try await fixture.client.projectDetail(path: "/Users/dev/notes") // Assert let requests = await fixture.http.recordedRequests #expect(requests.count == 2) for request in requests { #expect(request.httpMethod == "GET") #expect(request.value(forHTTPHeaderField: "Origin") == nil) } } // MARK: - /projects 解码 @Test("ProjectInfo 全字段样本解码(src/types.ts:273-281 实际形状,含 sessions ref)") func projectInfoDecodesFullSample() async throws { // Act let projects = try await fetchProjects(try makeFixture(), body: "[\(Self.fullProjectJSON)]") // Assert let expected = ProjectInfo( name: "web-terminal", path: "/Users/dev/web-terminal", isGit: true, branch: "main", dirty: true, lastActiveMs: 1_720_000_000_000, sessions: [ProjectSessionRef( id: try #require(UUID(uuidString: Self.sessionIdString)), title: "web-terminal", status: .working, clientCount: 2, createdAt: 1_719_990_000_000, exited: false )] ) #expect(projects == [expected]) } @Test("可选字段缺省(branch/dirty/lastActiveMs)与未知字段(向前兼容)均容忍,不丢条目") func optionalFieldsDegradeAndUnknownFieldsAreIgnored() async throws { // Arrange — 未知字段模拟未来服务器增量 let body = """ [{"name":"notes","path":"/Users/dev/notes","isGit":false,"sessions":[],\ "futureField":{"nested":true},"favouriteRank":3}] """ // Act let projects = try await fetchProjects(try makeFixture(), body: body) // Assert let project = try #require(projects.first) #expect(project.branch == nil) #expect(project.dirty == nil) #expect(project.lastActiveMs == nil) #expect(project.sessions.isEmpty) } @Test("畸形条目(缺必填/类型错/非对象)逐条丢弃,合法条目保留,不 crash") func malformedProjectEntriesAreDroppedWhileValidOnesSurvive() async throws { // Arrange let body = """ [\(Self.fullProjectJSON),42,{"name":"x"},\ {"name":1,"path":"/p","isGit":true,"sessions":[]},\(Self.minimalProjectJSON)] """ // Act let projects = try await fetchProjects(try makeFixture(), body: body) // Assert #expect(projects.map(\.name) == ["web-terminal", "notes"]) } @Test("session ref:未知 status → .unknown;畸形 ref 丢弃但项目保留;sessions 缺失 → 空数组") func sessionRefsAreTolerantlyDecoded() async throws { // Arrange let body = """ [{"name":"a","path":"/a","isGit":true,"sessions":[\ {"id":"\(Self.sessionIdString)","status":"hyperdrive","clientCount":0,\ "createdAt":1,"exited":false},\ {"id":"not-a-uuid","status":"idle","clientCount":0,"createdAt":2,"exited":true}]},\ {"name":"b","path":"/b","isGit":false}] """ // Act let projects = try await fetchProjects(try makeFixture(), body: body) // Assert #expect(projects.count == 2) let first = try #require(projects.first) #expect(first.sessions.count == 1) // 坏 UUID 的 ref 被丢弃 #expect(first.sessions.first?.status == .unknown) #expect(first.sessions.first?.title == nil) #expect(projects.last?.sessions.isEmpty == true) // sessions 缺失 → [] } @Test("/projects 非数组 body → invalidResponseBody;非 200 → unexpectedStatus") func projectsRejectsNonArrayBodyAndBadStatus() async throws { // Act + Assert — 非数组 await #expect(throws: APIClientError.invalidResponseBody) { _ = try await self.fetchProjects(try self.makeFixture(), body: #"{"error":"x"}"#) } // Act + Assert — 非 200 let fixture = try makeFixture() await fixture.http.queueSuccess(url: try routeURL("/projects"), status: 500) await #expect(throws: APIClientError.unexpectedStatus(500)) { _ = try await fixture.client.projects() } } // MARK: - /projects/detail:percent-encoding 单点处理 @Test("detail 的 path percent-encoding 只在 builder 一处:空格/+/&/=/非 ASCII 全部严格编码") func detailPathIsStrictlyPercentEncodedOnceInBuilder() async throws { // Arrange — '+' 必须编码为 %2B(Express qs 会把裸 '+' 解成空格);α = %CE%B1 let fixture = try makeFixture() let rawPath = "/Users/dev/my proj+α&x=1" let encoded = "%2FUsers%2Fdev%2Fmy%20proj%2B%CE%B1%26x%3D1" let expectedURL = try routeURL("/projects/detail?path=\(encoded)") await fixture.http.queueSuccess(url: expectedURL, body: Data(Self.detailJSON.utf8)) // Act _ = try await fixture.client.projectDetail(path: rawPath) // Assert — FakeHTTPTransport 按整 URL 精确匹配,能走到这里即编码逐字节正确 let request = try #require(await fixture.http.recordedRequests.first) #expect(request.url == expectedURL) } // MARK: - /projects/detail 解码与错误映射 private static let detailJSON = """ {"name":"notes","path":"/Users/dev/notes","isGit":true,"branch":"main","dirty":false,\ "worktrees":[{"path":"/Users/dev/notes","branch":"main","head":"abc1234",\ "isMain":true,"isCurrent":true},\ {"path":"/Users/dev/notes-wt","isMain":false,"isCurrent":false,"locked":true}],\ "sessions":[],"hasClaudeMd":true,"claudeMd":"# Notes"} """ @Test("ProjectDetail 全字段解码(worktrees 含可选字段缺省;src/types.ts:296-306)") func projectDetailDecodesFullSample() async throws { // Arrange let fixture = try makeFixture() await fixture.http.queueSuccess( url: try routeURL("/projects/detail?path=%2FUsers%2Fdev%2Fnotes"), body: Data(Self.detailJSON.utf8) ) // Act let detail = try await fixture.client.projectDetail(path: "/Users/dev/notes") // Assert #expect(detail.name == "notes") #expect(detail.isGit) #expect(detail.branch == "main") #expect(detail.dirty == false) #expect(detail.hasClaudeMd) #expect(detail.claudeMd == "# Notes") #expect(detail.worktrees.count == 2) let second = try #require(detail.worktrees.last) #expect(second.branch == nil) // detached HEAD → branch 缺省 #expect(second.locked == true) #expect(second.prunable == nil) #expect(!second.isMain) } @Test("detail 容忍:hasClaudeMd 缺失 → false;畸形 worktree 条目丢弃;200 但 body 畸形 → invalidResponseBody") func projectDetailToleratesDegradedShapes() async throws { // Arrange — worktrees 里混入缺 path 的畸形条目 let fixture = try makeFixture() let degraded = """ {"name":"a","path":"/a","isGit":false,\ "worktrees":[{"isMain":true},{"path":"/a","isMain":true,"isCurrent":true}],"sessions":[]} """ let url = try routeURL("/projects/detail?path=%2Fa") await fixture.http.queueSuccess(url: url, body: Data(degraded.utf8)) await fixture.http.queueSuccess(url: url, body: Data("[1,2]".utf8)) // Act let detail = try await fixture.client.projectDetail(path: "/a") // Assert #expect(detail.hasClaudeMd == false) #expect(detail.claudeMd == nil) #expect(detail.worktrees.count == 1) // Act + Assert — 200 但整体形状不对 await #expect(throws: APIClientError.invalidResponseBody) { _ = try await fixture.client.projectDetail(path: "/a") } } @Test("detail 400/404/500 {error} → projectPathInvalid/projectNotFound/projectDetailUnavailable,各有非空话术") func projectDetailMapsErrorStatusesToTypedErrors() async throws { // Arrange let fixture = try makeFixture() let url = try routeURL("/projects/detail?path=%2Fgone") await fixture.http.queueSuccess(url: url, status: 400, body: Data(#"{"error":"path query parameter is required"}"#.utf8)) await fixture.http.queueSuccess(url: url, status: 404, body: Data(#"{"error":"project not found"}"#.utf8)) await fixture.http.queueSuccess(url: url, status: 500, body: Data(#"{"error":"failed to read project detail"}"#.utf8)) // Act + Assert await #expect(throws: APIClientError.projectPathInvalid) { _ = try await fixture.client.projectDetail(path: "/gone") } await #expect(throws: APIClientError.projectNotFound) { _ = try await fixture.client.projectDetail(path: "/gone") } await #expect(throws: APIClientError.projectDetailUnavailable) { _ = try await fixture.client.projectDetail(path: "/gone") } for error in [APIClientError.projectPathInvalid, .projectNotFound, .projectDetailUnavailable] { #expect(!error.message.isEmpty) } } @Test("空 path → projectPathInvalid,联网前拒绝(镜像服务器 400 规则)") func emptyDetailPathIsRejectedBeforeNetwork() async throws { // Arrange let fixture = try makeFixture() // Act + Assert await #expect(throws: APIClientError.projectPathInvalid) { _ = try await fixture.client.projectDetail(path: "") } #expect(await fixture.http.recordedRequests.isEmpty) } }