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" /// 含未知键的服务器 blob:string/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("".utf8)) // Act + Assert await #expect(throws: APIClientError.invalidResponseBody) { _ = try await fixture.client.prefs() } await #expect(throws: APIClientError.invalidResponseBody) { _ = try await fixture.client.prefs() } } }