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