Files
web-terminal/ios/Packages/APIClient/Tests/APIClientTests/PrefsRoundTripTests.swift
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

189 lines
7.9 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 · `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()
}
}
}