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
This commit is contained in:
Yaojia Wang
2026-07-05 13:34:01 +02:00
parent aa956fcbb4
commit 4871e8ac3d
20 changed files with 3319 additions and 48 deletions

View File

@@ -0,0 +1,289 @@
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)
}
}