Files
web-terminal/ios/Packages/APIClient/Tests/APIClientTests/ProjectsTests.swift
Yaojia Wang 4871e8ac3d feat(ios): P1-A — server touch-points (lastOutputAt, APNs sender+token endpoint) + APIClient P1 contract
T-iOS-37: LiveSessionInfo.lastOutputAt additive-optional field + manager.list() mapping (+4 tests; web consumers unaffected)
T-iOS-20: src/push/apns.ts — env-gated (all-or-disabled, no crash, zero key-material logging), hand-rolled ES256 JWT
(node:crypto ieee-p1363, zero new deps), NEEDS-INPUT/DONE payloads with structural minimization, token store per
subscription-store conventions, combineNotifyServices parallel to web-push, POST/DELETE /push/apns-token per frozen
wire shape (G-guard, 5/min/IP, 8kb); 65 tests incl. dual-channel e2e vs local fake APNs
T-iOS-38: APIClient builders — apns-token (client-side hex mirror, pre-network reject), projects/detail (lossy decode,
single-point percent-encoding), prefs (unknown-key byte-exact round-trip preservation), public four-tier HostNetworkTier
Coordination: webterminal:// CFBundleURLTypes pre-registered in project.yml for T-iOS-22
Verified: root 1470 tests + tsc clean; APIClient 76 tests, 95.26% coverage; wire-shape cross-check zero mismatches
2026-07-05 13:34:01 +02:00

290 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/detailpercent-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)
}
}