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,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 token32
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)
}
}