ios: the contact-email step can actually save (it never could) (#1471)
setGroupSettings read the current trio from GET /api/admin/group — a route that does not exist (main.go registers POST only). So the read always failed and every write carried name: "", which setGroup rejects: on native, Eleven's setup conversation answered every contact email with "Something went wrong saving that", and a name change silently cleared any email already stored. Read the trio from the member read instead (/api/group?token=), as the web bridge does. Covered in the claim integration rig: red before, green after, against a real local server. Claude-Session: https://claude.ai/code/session_01GUA1EJFYwgr2nZNVBRFf8k
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
3 files changed,
+49
−21
ios/LChatCore/Package.resolved+0 −9ios/LChatCore/Sources/LChatCore/LChatAPI.swift+25 −12ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift+24 −0
diff --git a/ios/LChatCore/Package.resolved b/ios/LChatCore/Package.resolved| index fe25d1c..d82f91f 100644 |
| --- a/ios/LChatCore/Package.resolved |
| +++ b/ios/LChatCore/Package.resolved |
| @@ -1,14 +1,5 @@ |
| { |
| "pins" : [ |
| - { |
| - "identity" : "sparkle", |
| - "kind" : "remoteSourceControl", |
| - "location" : "https://github.com/sparkle-project/Sparkle", |
| - "state" : { |
| - "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7", |
| - "version" : "2.9.4" |
| - } |
| - }, |
| { |
| "identity" : "swift-cmark", |
| "kind" : "remoteSourceControl", |
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift| index 8dd359b..dee57e4 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| @@ -347,20 +347,33 @@ public struct LChatAPI { |
| /// saveGroupSettings does the same GET+merge). Admin: the device token's |
| /// user holds the role. |
| public func setGroupSettings(name: String? = nil, adminEmail: String? = nil) async -> Bool { |
| - var current: [String: Any] = [:] |
| - if let url = URL(string: "/api/admin/group", relativeTo: baseURL) { |
| - var req = URLRequest(url: url) |
| - req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") |
| - if let (data, resp) = try? await session.data(for: req), |
| - (resp as? HTTPURLResponse)?.statusCode == 200, |
| - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { |
| - current = obj |
| - } |
| + // setGroup clears any field it isn't sent, so a change to ONE field |
| + // must carry the current other two — which means the read has to work. |
| + // It didn't: there is no GET /api/admin/group (main.go registers POST |
| + // only), so `current` was always empty and every write went out with |
| + // an empty name, which the server rejects outright ("name must be |
| + // 1-60 characters"). Two bugs in one: setting the contact email could |
| + // never succeed on native (Eleven's setup conversation answered every |
| + // address with "Something went wrong saving that"), and a name change |
| + // silently cleared any email already stored. |
| + // |
| + // The trio lives on the MEMBER read instead — /api/group?token=…, |
| + // where the admin fields are member-gated (getGroup) — which is |
| + // exactly what the web bridge reads (shell.js groupInfo). |
| + struct Trio: Codable { |
| + let name: String?; let name_set: Bool? |
| + let admin_name: String?; let admin_email: String? |
| } |
| + let current = try? await getJSON("/api/group", Trio.self) |
| + // chosenName, not the raw field: an unnamed space reports the |
| + // compiled-in default, and carrying that would silently adopt "lchat" |
| + // as this space's chosen name. Empty fails the write instead — honest, |
| + // and unreachable from the setup conversation, which names the space |
| + // before it asks anything else. |
| let body: [String: Any] = [ |
| - "name": name ?? (current["name"] as? String ?? ""), |
| - "admin_name": current["admin_name"] as? String ?? "", |
| - "admin_email": adminEmail ?? (current["admin_email"] as? String ?? ""), |
| + "name": name ?? Self.chosenName(current?.name, nameSet: current?.name_set), |
| + "admin_name": current?.admin_name ?? "", |
| + "admin_email": adminEmail ?? (current?.admin_email ?? ""), |
| ] |
| return await postAdmin("/api/admin/group", body) |
| } |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift b/ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift| index 81150c5..c9a724c 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/SpaceClaimIntegrationTests.swift |
| @@ -69,6 +69,30 @@ final class SpaceClaimIntegrationTests: XCTestCase { |
| let groupName = await api.groupName() |
| XCTAssertEqual(groupName, "Goes To Eleven") |
| + // The setup conversation's contact-email step (setGroupSettings), which |
| + // could never succeed: the trio write carried an empty name — rejected |
| + // by setGroup — because it read a GET /api/admin/group that does not |
| + // exist. So: the email lands, AND the name it had to carry survives. |
| + let emailOK = await api.setGroupSettings(adminEmail: "admin@example.com") |
| + XCTAssertTrue(emailOK, "the contact email must save") |
| + let nameAfterEmail = await api.groupName() |
| + XCTAssertEqual(nameAfterEmail, "Goes To Eleven", |
| + "writing the email must not clear the space's name") |
| + |
| + // And the mirror: a later name change must not silently clear the |
| + // email that is already stored (the same read, the other direction). |
| + let renamed = await api.setGroupSettings(name: "Renamed Space") |
| + XCTAssertTrue(renamed) |
| + let nameAfterRename = await api.groupName() |
| + XCTAssertEqual(nameAfterRename, "Renamed Space") |
| + struct GroupRead: Codable { let admin_email: String? } |
| + let after = try await api.getJSON("/api/group", GroupRead.self) |
| + XCTAssertEqual(after.admin_email, "admin@example.com", |
| + "renaming the space must not clear the contact email") |
| + // Put the name back for the assertions further down. |
| + let restored = await api.setGroupSettings(name: "Goes To Eleven") |
| + XCTAssertTrue(restored) |
| + |
| // A second claim of the same (single-use) invite must fail. |
| do { |
| _ = try await SpaceClaim.claim(setupLink: origin + "/join/setup#" + t, |