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:
175
ios/Packages/APIClient/Tests/APIClientTests/ApnsTokenTests.swift
Normal file
175
ios/Packages/APIClient/Tests/APIClientTests/ApnsTokenTests.swift
Normal file
@@ -0,0 +1,175 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import TestSupport
|
||||
import WireProtocol
|
||||
import APIClient
|
||||
|
||||
/// T-iOS-38 · APNs device-token 注册端点(orchestrator 冻结 wire shape):
|
||||
/// - `POST /push/apns-token` body `{"token":"<64-160 位小写 hex>"}` → 204(幂等 upsert)
|
||||
/// - `DELETE /push/apns-token` body 同形 → 204(幂等;未知 token 仍 204)
|
||||
/// 两者均为 G 端点(Origin 逐字符等于 `endpoint.originHeader`);服务器约束:
|
||||
/// 400 非法 token/形状 · 403 Origin 守卫 · 429 限频 5 次/分/IP · body ≤ 8 KB
|
||||
///(对齐 POST /push/subscribe 约定,src/server.ts:461-480,73)。
|
||||
/// hex 校验镜像服务器规则并在**联网前**拒绝(客户端边界验证,plan §4)。
|
||||
struct ApnsTokenTests {
|
||||
private static let base = "http://192.168.1.5:3000"
|
||||
/// 64 位小写 hex —— 典型 APNs device token(32 字节)。
|
||||
private static let validToken = String(repeating: "0123456789abcdef", count: 4)
|
||||
|
||||
private struct Fixture {
|
||||
let endpoint: HostEndpoint
|
||||
let http: FakeHTTPTransport
|
||||
let client: APIClient
|
||||
let url: URL
|
||||
}
|
||||
|
||||
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(
|
||||
endpoint: endpoint,
|
||||
http: http,
|
||||
client: APIClient(endpoint: endpoint, http: http),
|
||||
url: try #require(URL(string: Self.base + "/push/apns-token"))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 冻结 wire shape(方法/路径/Origin/body)
|
||||
|
||||
@Test("register 为 POST /push/apns-token,G 端点:Origin 逐字符等于 endpoint.originHeader,body 恰为 {token}")
|
||||
func registerBuildsFrozenWireShape() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 204)
|
||||
|
||||
// Act
|
||||
try await fixture.client.registerApnsToken(Self.validToken)
|
||||
|
||||
// Assert
|
||||
let request = try #require(await fixture.http.recordedRequests.first)
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.url == fixture.url)
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
|
||||
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||
let body = try #require(request.httpBody)
|
||||
let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
#expect(Set(object.keys) == Set(["token"]))
|
||||
#expect(object["token"] as? String == Self.validToken)
|
||||
}
|
||||
|
||||
@Test("unregister 为 DELETE /push/apns-token,同 G 守卫同 body 形状,204 → 成功(幂等)")
|
||||
func unregisterBuildsFrozenWireShape() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(method: "DELETE", url: fixture.url, status: 204)
|
||||
|
||||
// Act
|
||||
try await fixture.client.unregisterApnsToken(Self.validToken)
|
||||
|
||||
// Assert
|
||||
let request = try #require(await fixture.http.recordedRequests.first)
|
||||
#expect(request.httpMethod == "DELETE")
|
||||
#expect(request.url == fixture.url)
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
|
||||
let body = try #require(request.httpBody)
|
||||
let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
#expect(object["token"] as? String == Self.validToken)
|
||||
}
|
||||
|
||||
// MARK: - 客户端 hex 校验(镜像服务器规则,联网前拒绝)
|
||||
|
||||
@Test("大写 hex 输入 → 联网前归一化为小写(服务器按小写归一存储)")
|
||||
func uppercaseTokenIsLowercaseNormalizedBeforeNetwork() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 204)
|
||||
|
||||
// Act
|
||||
try await fixture.client.registerApnsToken(Self.validToken.uppercased())
|
||||
|
||||
// Assert
|
||||
let request = try #require(await fixture.http.recordedRequests.first)
|
||||
let body = try #require(request.httpBody)
|
||||
let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
#expect(object["token"] as? String == Self.validToken)
|
||||
}
|
||||
|
||||
@Test("非法 token(过短/过长/非 hex/空/含空格) → invalidApnsToken 且零网络请求")
|
||||
func invalidTokensAreRejectedBeforeAnyNetworkRequest() async throws {
|
||||
// Arrange — 63 位(短 1)、161 位(长 1)、非 hex 字符、空串、内嵌空格
|
||||
let fixture = try makeFixture()
|
||||
let invalidTokens = [
|
||||
String(repeating: "a", count: 63),
|
||||
String(repeating: "a", count: 161),
|
||||
String(repeating: "g", count: 64),
|
||||
"",
|
||||
String(repeating: "a", count: 32) + " " + String(repeating: "a", count: 31),
|
||||
]
|
||||
|
||||
// Act + Assert — register 与 unregister 两条路径都在联网前拒绝
|
||||
for token in invalidTokens {
|
||||
await #expect(throws: APIClientError.invalidApnsToken) {
|
||||
try await fixture.client.registerApnsToken(token)
|
||||
}
|
||||
await #expect(throws: APIClientError.invalidApnsToken) {
|
||||
try await fixture.client.unregisterApnsToken(token)
|
||||
}
|
||||
}
|
||||
#expect(await fixture.http.recordedRequests.isEmpty)
|
||||
}
|
||||
|
||||
@Test("边界长度 64 与 160 位 hex 均接受(服务器规则 64-160)")
|
||||
func boundaryLengthTokensAreAccepted() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 204)
|
||||
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 204)
|
||||
|
||||
// Act
|
||||
try await fixture.client.registerApnsToken(String(repeating: "f", count: 64))
|
||||
try await fixture.client.registerApnsToken(String(repeating: "f", count: 160))
|
||||
|
||||
// Assert
|
||||
#expect(await fixture.http.recordedRequests.count == 2)
|
||||
}
|
||||
|
||||
// MARK: - 状态码映射
|
||||
|
||||
@Test("400(服务器判 token 非法) → invalidApnsToken;403(Origin 守卫) → forbidden")
|
||||
func status400And403MapToTypedErrors() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 400)
|
||||
await fixture.http.queueSuccess(method: "DELETE", url: fixture.url, status: 403)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.invalidApnsToken) {
|
||||
try await fixture.client.registerApnsToken(Self.validToken)
|
||||
}
|
||||
await #expect(throws: APIClientError.forbidden) {
|
||||
try await fixture.client.unregisterApnsToken(Self.validToken)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("429 → rateLimited(≤5 次/分/IP,对齐 push/subscribe 限频);500 → unexpectedStatus")
|
||||
func status429And500MapToTypedErrors() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 429)
|
||||
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 500)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.rateLimited) {
|
||||
try await fixture.client.registerApnsToken(Self.validToken)
|
||||
}
|
||||
await #expect(throws: APIClientError.unexpectedStatus(500)) {
|
||||
try await fixture.client.registerApnsToken(Self.validToken)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("invalidApnsToken 有非空 UI 话术(plan §4 错误处理全面显式)")
|
||||
func invalidApnsTokenHasNonEmptyMessage() {
|
||||
#expect(!APIClientError.invalidApnsToken.message.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import WireProtocol
|
||||
import APIClient
|
||||
|
||||
/// T-iOS-38 · 四层 host 分级(plan §5.4 提示分层表),公开 API:
|
||||
/// loopback / privateLAN / tailscale / public。W3 时 PairingViewModel 因
|
||||
/// `isPrivateOrLocalHost` 是 internal 单桶谓词而自行重写了分类——本类型
|
||||
/// 是给 VM 换用的单一出处(本任务不改 VM,见日志条目)。
|
||||
/// 注意本文件**不用 @testable**:证明 API 确实 public。
|
||||
struct HostClassificationTests {
|
||||
@Test("loopback 层:localhost/127.0.0.0\u{2044}8/IPv6 ::1(裸与带括号)")
|
||||
func loopbackTierMatchesLocalTargets() {
|
||||
// Arrange
|
||||
let hosts = ["localhost", "LOCALHOST", "127.0.0.1", "127.8.8.8", "::1", "[::1]"]
|
||||
|
||||
// Act + Assert
|
||||
for host in hosts {
|
||||
#expect(HostClassifier.classify(host: host) == .loopback, "\(host) 应为 loopback")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("privateLAN 层:RFC1918 三段 + link-local 169.254\u{2044}16 + mDNS .local")
|
||||
func privateLANTierMatchesRFC1918AndLinkLocal() {
|
||||
// Arrange
|
||||
let hosts = [
|
||||
"10.0.0.5", "192.168.0.9", "172.16.0.1", "172.31.255.255",
|
||||
"169.254.1.1", "mac-mini.local", "Mac-Mini.LOCAL",
|
||||
]
|
||||
|
||||
// Act + Assert
|
||||
for host in hosts {
|
||||
#expect(HostClassifier.classify(host: host) == .privateLAN, "\(host) 应为 privateLAN")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("tailscale 层:CGNAT 100.64.0.0\u{2044}10 与 MagicDNS *.ts.net")
|
||||
func tailscaleTierMatchesCGNATAndMagicDNS() {
|
||||
// Arrange
|
||||
let hosts = [
|
||||
"100.64.0.1", "100.100.1.1", "100.127.255.255",
|
||||
"mac.tailnet.ts.net", "foo.TS.NET",
|
||||
]
|
||||
|
||||
// Act + Assert
|
||||
for host in hosts {
|
||||
#expect(HostClassifier.classify(host: host) == .tailscale, "\(host) 应为 tailscale")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("public 层:公网 IP/域名/越界 CIDR/畸形 IPv4 一律最强警告层")
|
||||
func publicTierIsTheFailSafeDefault() {
|
||||
// Arrange — 越界:172.32 超出 172.16/12;100.128 与 100.63 超出 100.64/10
|
||||
let hosts = [
|
||||
"8.8.8.8", "203.0.113.7", "example.com", "tsnet.example.com",
|
||||
"172.32.0.1", "100.128.0.1", "100.63.255.255", "256.1.1.1", "1.2.3", "",
|
||||
]
|
||||
|
||||
// Act + Assert — 分不清 → public(fail-safe:宁可多警告,plan §5.4)
|
||||
for host in hosts {
|
||||
#expect(HostClassifier.classify(host: host) == .public, "\(host) 应为 public")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("classify(endpoint:) 便捷入口与 host 字符串版一致(VM 只有 HostEndpoint 可用)")
|
||||
func endpointConvenienceMatchesHostClassification() throws {
|
||||
// Arrange
|
||||
let vectors: [(url: String, tier: HostNetworkTier)] = [
|
||||
("http://127.0.0.1:3000", .loopback),
|
||||
("http://192.168.1.5:3000", .privateLAN),
|
||||
("http://100.100.1.1:3000", .tailscale),
|
||||
("https://mac.tailnet.ts.net", .tailscale),
|
||||
("https://example.com", .public),
|
||||
]
|
||||
|
||||
// Act + Assert
|
||||
for vector in vectors {
|
||||
let url = try #require(URL(string: vector.url))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
#expect(HostClassifier.classify(endpoint: endpoint) == vector.tier, "\(vector.url)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import TestSupport
|
||||
import WireProtocol
|
||||
import APIClient
|
||||
|
||||
/// T-iOS-38 · `GET /prefs`(RO,无 Origin) / `PUT /prefs`(G,Origin;body ≤ 64 KB,
|
||||
/// src/server.ts:278)。prefs 是"不透明但已验证"的 JSON 对象往返:已知键
|
||||
/// favourites/collapsed 松散镜像 web 端(public/prefs.ts sanitizePrefs),
|
||||
/// **未知键在往返中原样保留** —— 关键正确性陷阱:iOS 改一个已知键后 PUT,
|
||||
/// 绝不能把 web/未来服务器写入的未知键清掉。
|
||||
struct PrefsRoundTripTests {
|
||||
private static let base = "http://192.168.1.5:3000"
|
||||
|
||||
/// 含未知键的服务器 blob:string/number/bool/null/嵌套 array/object 全类型。
|
||||
private static let blobWithUnknownKeys = """
|
||||
{"favourites":["/Users/dev/a"],"collapsed":{"grp":true},\
|
||||
"theme":"dark","pollSeconds":42,"beta":false,"legacy":null,\
|
||||
"futureList":[1,"two",{"deep":true}],"futureObj":{"x":{"y":[3]}}}
|
||||
"""
|
||||
|
||||
private struct Fixture {
|
||||
let endpoint: HostEndpoint
|
||||
let http: FakeHTTPTransport
|
||||
let client: APIClient
|
||||
let url: URL
|
||||
}
|
||||
|
||||
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(
|
||||
endpoint: endpoint,
|
||||
http: http,
|
||||
client: APIClient(endpoint: endpoint, http: http),
|
||||
url: try #require(URL(string: Self.base + "/prefs"))
|
||||
)
|
||||
}
|
||||
|
||||
private func decodePrefs(_ json: String) throws -> UiPrefs {
|
||||
try #require(UiPrefs.decode(from: Data(json.utf8)))
|
||||
}
|
||||
|
||||
/// 重新解析 encode 产物为字典(键序无关的深度比较用)。
|
||||
private func reparse(_ data: Data) throws -> NSDictionary {
|
||||
try #require(try JSONSerialization.jsonObject(with: data) as? NSDictionary)
|
||||
}
|
||||
|
||||
// MARK: - Origin iff-G
|
||||
|
||||
@Test("Origin iff-G:GET /prefs 无 Origin;PUT /prefs 带 Origin(逐字符相等)+JSON Content-Type")
|
||||
func prefsOriginAppearsIffPut() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(url: fixture.url, body: Data(Self.blobWithUnknownKeys.utf8))
|
||||
await fixture.http.queueSuccess(
|
||||
method: "PUT", url: fixture.url, body: Data(#"{"favourites":[],"collapsed":{}}"#.utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
_ = try await fixture.client.prefs()
|
||||
_ = try await fixture.client.putPrefs(UiPrefs())
|
||||
|
||||
// Assert
|
||||
let requests = await fixture.http.recordedRequests
|
||||
#expect(requests.count == 2)
|
||||
let get = try #require(requests.first)
|
||||
#expect(get.httpMethod == "GET")
|
||||
#expect(get.value(forHTTPHeaderField: "Origin") == nil)
|
||||
let put = try #require(requests.last)
|
||||
#expect(put.httpMethod == "PUT")
|
||||
#expect(put.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
|
||||
#expect(put.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||
}
|
||||
|
||||
// MARK: - 已知键的松散镜像(web sanitizePrefs 语义)
|
||||
|
||||
@Test("已知键解码:favourites 只留非空字符串并去重;collapsed 只留 true 项")
|
||||
func knownKeysAreSanitizedLikeWebClient() throws {
|
||||
// Arrange — 混入非字符串/空串/重复/非 true 值
|
||||
let json = """
|
||||
{"favourites":["/a",42,"","/a","/b"],\
|
||||
"collapsed":{"g1":true,"g2":false,"g3":"yes","":true}}
|
||||
"""
|
||||
|
||||
// Act
|
||||
let prefs = try decodePrefs(json)
|
||||
|
||||
// Assert
|
||||
#expect(prefs.favourites == ["/a", "/b"])
|
||||
#expect(prefs.collapsed == ["g1": true])
|
||||
}
|
||||
|
||||
@Test("已知键缺失/类型错 → 空缺省;空构造与 memberwise 构造可用")
|
||||
func missingKnownKeysDegradeToEmpty() throws {
|
||||
// Act
|
||||
let empty = try decodePrefs(#"{"favourites":"not-an-array"}"#)
|
||||
let constructed = UiPrefs(favourites: ["/x"], collapsed: ["g": true])
|
||||
|
||||
// Assert
|
||||
#expect(empty.favourites.isEmpty)
|
||||
#expect(empty.collapsed.isEmpty)
|
||||
#expect(constructed.favourites == ["/x"])
|
||||
#expect(constructed.collapsed == ["g": true])
|
||||
}
|
||||
|
||||
// MARK: - 关键正确性陷阱:未知键在往返中存活
|
||||
|
||||
@Test("关键陷阱:解码含未知键的 blob → withFavourites 改已知键 → 重编码,未知键逐字节存活")
|
||||
func unknownKeysSurviveFavouritesMutationRoundTrip() throws {
|
||||
// Arrange
|
||||
let original = try decodePrefs(Self.blobWithUnknownKeys)
|
||||
|
||||
// Act — 只动 favourites
|
||||
let mutated = original.withFavourites(["/Users/dev/a", "/Users/dev/b"])
|
||||
let reencoded = try reparse(try mutated.encodeBody())
|
||||
|
||||
// Assert — 已知键更新、另一已知键不动、未知键全类型深度相等
|
||||
#expect(mutated.favourites == ["/Users/dev/a", "/Users/dev/b"])
|
||||
#expect(reencoded["favourites"] as? [String] == ["/Users/dev/a", "/Users/dev/b"])
|
||||
#expect(reencoded["collapsed"] as? [String: Bool] == ["grp": true])
|
||||
#expect(reencoded["theme"] as? String == "dark")
|
||||
#expect(reencoded["pollSeconds"] as? Int == 42)
|
||||
#expect(reencoded["beta"] as? Bool == false)
|
||||
#expect(reencoded["legacy"] is NSNull)
|
||||
#expect(reencoded["futureList"] as? NSArray == [1, "two", ["deep": true]] as NSArray)
|
||||
#expect(reencoded["futureObj"] as? NSDictionary == ["x": ["y": [3]]] as NSDictionary)
|
||||
// 原值不可变(plan §4 不可变铁律)
|
||||
#expect(original.favourites == ["/Users/dev/a"])
|
||||
}
|
||||
|
||||
@Test("withCollapsed 同样保留未知键与 favourites;往返不放大不丢失")
|
||||
func unknownKeysSurviveCollapsedMutationRoundTrip() throws {
|
||||
// Arrange
|
||||
let original = try decodePrefs(Self.blobWithUnknownKeys)
|
||||
|
||||
// Act
|
||||
let mutated = original.withCollapsed(["grp": true, "new-group": true])
|
||||
let reencoded = try reparse(try mutated.encodeBody())
|
||||
|
||||
// Assert
|
||||
#expect(reencoded["collapsed"] as? [String: Bool] == ["grp": true, "new-group": true])
|
||||
#expect(reencoded["favourites"] as? [String] == ["/Users/dev/a"])
|
||||
#expect(reencoded["theme"] as? String == "dark")
|
||||
#expect(reencoded["futureObj"] as? NSDictionary == ["x": ["y": [3]]] as NSDictionary)
|
||||
// 未知键总数不变(8 个顶层键)
|
||||
#expect(reencoded.count == 8)
|
||||
}
|
||||
|
||||
// MARK: - 客户端调用(echo/错误映射)
|
||||
|
||||
@Test("putPrefs 200 → 返回服务器回显的(已净化)prefs;403(Origin 守卫) → forbidden")
|
||||
func putPrefsReturnsServerEchoAndMapsForbidden() async throws {
|
||||
// Arrange — 服务器回显是净化后的 blob(src/server.ts:283 res.json(prefsStore.get()))
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(
|
||||
method: "PUT", url: fixture.url,
|
||||
body: Data(#"{"favourites":["/kept"],"collapsed":{}}"#.utf8)
|
||||
)
|
||||
await fixture.http.queueSuccess(method: "PUT", url: fixture.url, status: 403)
|
||||
|
||||
// Act
|
||||
let echoed = try await fixture.client.putPrefs(UiPrefs(favourites: ["/kept", ""]))
|
||||
|
||||
// Assert
|
||||
#expect(echoed.favourites == ["/kept"])
|
||||
await #expect(throws: APIClientError.forbidden) {
|
||||
_ = try await fixture.client.putPrefs(UiPrefs())
|
||||
}
|
||||
}
|
||||
|
||||
@Test("GET /prefs 顶层非对象(数组/HTML) → invalidResponseBody(绝不静默降级为空——防止后续 PUT 清空服务器 prefs)")
|
||||
func nonObjectPrefsBodyFailsLoudlyInsteadOfDegradingToEmpty() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(url: fixture.url, body: Data("[]".utf8))
|
||||
await fixture.http.queueSuccess(url: fixture.url, body: Data("<html>".utf8))
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.invalidResponseBody) {
|
||||
_ = try await fixture.client.prefs()
|
||||
}
|
||||
await #expect(throws: APIClientError.invalidResponseBody) {
|
||||
_ = try await fixture.client.prefs()
|
||||
}
|
||||
}
|
||||
}
|
||||
289
ios/Packages/APIClient/Tests/APIClientTests/ProjectsTests.swift
Normal file
289
ios/Packages/APIClient/Tests/APIClientTests/ProjectsTests.swift
Normal 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/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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user