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,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"
/// blobstring/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()
}
}
}