Files
Yaojia Wang 4871e8ac3d 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
2026-07-05 13:34:01 +02:00

176 lines
7.6 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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