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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user