Files
hapi/ios/Packages/HapiKit/Tests/HapiClientTests/Stores/SyncEventRoutingTests.swift
T
weishuandClaude Opus 5.5 5942b427a9 feat(ios): rename, delete and reopen sessions
The API wrappers existed but iOS had no UI for these actions, while
web and Android did. The session row's context menu and the chat's …
menu now offer Rename for every session. Active sessions keep Archive.
Inactive sessions get Reopen and a confirmed Delete instead of
Archive, which the hub rejects with 409 for them.

The store applies renames and deletes optimistically. A failed rename
refetches server state; a failed delete restores the row and the
cached detail. Reopen marks the returned session active right away.
When reopen returns a superseding id, the chat seeds the new window,
moves the draft and navigates to it, as send-resume already does. A
reopen from the list also moves the draft and opens the session. A
422 shows the hub's message with the missing fields.

On iPhone, an authoritative removal (delete, or archive elsewhere) now
pops that chat, matching the iPad split, which already clears the
selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:00:00 +08:00

155 lines
6.5 KiB
Swift

import Foundation
import HapiClient
import HapiProtocol
import Testing
/// Transcribes the Android reference suite (`StoreSyncTargetsTest.kt`):
/// routing rules of the SyncEvent fan-out, verified against fake stores
/// through the `SessionListStoring`/`MachineListStoring` seams.
@MainActor
private final class FakeSessionListStore: SessionListStoring {
var sessions: [SessionSummary] = []
var calls: [String] = []
func refresh() async throws { calls.append("refresh") }
func scheduleRefresh() { calls.append("scheduleRefresh") }
func fullResync() async throws { calls.append("fullResync") }
func applySessionEvent(_ event: SyncEvent) {
let label: String
switch event {
case .sessionAdded: label = "sessionAdded"
case .sessionUpdated: label = "sessionUpdated"
case .sessionRemoved: label = "sessionRemoved"
case .sessionEnded: label = "sessionEnded"
default: label = "unexpected"
}
calls.append("session:\(label)")
}
func setPinMode(sessionId: String, mode: SessionPinMode) async throws {
calls.append("pin:\(sessionId):\(mode.rawValue)")
}
func archiveSession(sessionId: String) async throws {
calls.append("archive:\(sessionId)")
}
func renameSession(sessionId: String, name: String) async throws {
calls.append("rename:\(sessionId)")
}
func deleteSession(sessionId: String) async throws {
calls.append("delete:\(sessionId)")
}
func reopenSession(sessionId: String) async throws -> ReopenSessionResponse {
calls.append("reopen:\(sessionId)")
return ReopenSessionResponse(ok: true, sessionId: sessionId, resumed: true)
}
}
@MainActor
private final class FakeMachineListStore: MachineListStoring {
var machines: [Machine] = []
var calls: [String] = []
func refresh() async throws { calls.append("refresh") }
func scheduleRefresh() { calls.append("scheduleRefresh") }
func applyMachineEvent(machineId: String, data: MachineUpdatedData?) {
calls.append("machine:\(machineId)")
}
}
@MainActor
@Suite("SyncEventRouter")
struct SyncEventRoutingTests {
@Test func sessionLifecycleEventsReachTheSessionStore() throws {
let sessions = FakeSessionListStore()
let router = SyncEventRouter(sessions: sessions, machines: FakeMachineListStore())
router.route(try decodeSyncEvent("{\"type\":\"session-updated\",\"sessionId\":\"s1\",\"data\":{\"active\":true}}"))
router.route(try decodeSyncEvent("{\"type\":\"session-removed\",\"sessionId\":\"s1\"}"))
router.route(try decodeSyncEvent("{\"type\":\"session-ended\",\"sessionId\":\"s1\",\"reason\":\"completed\"}"))
#expect(sessions.calls == ["session:sessionUpdated", "session:sessionRemoved", "session:sessionEnded"])
}
@Test func machineUpdatedReachesTheMachineStore() throws {
let machines = FakeMachineListStore()
let router = SyncEventRouter(sessions: FakeSessionListStore(), machines: machines)
router.route(try decodeSyncEvent("{\"type\":\"machine-updated\",\"machineId\":\"m1\",\"data\":null}"))
#expect(machines.calls == ["machine:m1"])
}
@Test func globalMessageStreamEventsRefreshTheSessionList() throws {
let sessions = FakeSessionListStore()
let router = SyncEventRouter(sessions: sessions, machines: FakeMachineListStore())
router.route(try decodeSyncEvent("{\"type\":\"messages-invalidated\",\"sessionId\":\"s1\"}"))
router.route(try decodeSyncEvent("{\"type\":\"messages-consumed\",\"sessionId\":\"s1\",\"localIds\":[\"l1\"],\"invokedAt\":1}"))
router.route(try decodeSyncEvent("{\"type\":\"message-cancelled\",\"sessionId\":\"s1\",\"messageId\":\"m1\"}"))
router.route(try decodeSyncEvent("{\"type\":\"scheduled-matured\",\"sessionId\":\"s1\"}"))
#expect(sessions.calls == Array(repeating: "scheduleRefresh", count: 4))
}
@Test func messageReceivedRefreshesTheListOnlyWhenScheduled() throws {
let sessions = FakeSessionListStore()
let router = SyncEventRouter(sessions: sessions, machines: FakeMachineListStore())
router.route(try decodeSyncEvent(
"{\"type\":\"message-received\",\"sessionId\":\"s1\",\"message\":{\"id\":\"m1\",\"createdAt\":1}}"
))
#expect(sessions.calls == [])
router.route(try decodeSyncEvent(
"{\"type\":\"message-received\",\"sessionId\":\"s1\",\"message\":{\"id\":\"m2\",\"createdAt\":1,\"scheduledAt\":99}}"
))
#expect(sessions.calls == ["scheduleRefresh"])
}
@Test func sessionScopedMessageEventsDoNotTouchTheList() throws {
// Window territory (M2f) — the per-chat pipe must not churn the list.
let sessions = FakeSessionListStore()
let router = SyncEventRouter(sessions: sessions, machines: FakeMachineListStore())
router.route(
try decodeSyncEvent("{\"type\":\"messages-invalidated\",\"sessionId\":\"s1\"}"),
scope: .session("s1")
)
#expect(sessions.calls == [])
}
@Test func gapHandshakeTriggersTheFullResync() async throws {
let sessions = FakeSessionListStore()
let machines = FakeMachineListStore()
let router = SyncEventRouter(sessions: sessions, machines: machines)
router.handleHandshake(resume: .gap)
try await expectEventually { sessions.calls == ["fullResync"] && machines.calls == ["refresh"] }
}
@Test func okHandshakeSkipsTheResync() async throws {
let sessions = FakeSessionListStore()
let machines = FakeMachineListStore()
let router = SyncEventRouter(sessions: sessions, machines: machines)
router.handleHandshake(resume: .ok)
// Give a spawned (unexpected) resync task room to run.
try await Task.sleep(for: .milliseconds(50))
#expect(sessions.calls == [])
#expect(machines.calls == [])
}
@Test func toastEventsReachTheCallback() throws {
final class ToastBox {
var toasts: [ToastPayload] = []
}
let box = ToastBox()
let router = SyncEventRouter(
sessions: FakeSessionListStore(),
machines: FakeMachineListStore(),
onToast: { box.toasts.append($0) }
)
router.route(try decodeSyncEvent(
"{\"type\":\"toast\",\"data\":{\"title\":\"t\",\"body\":\"b\",\"sessionId\":\"s1\",\"url\":\"/sessions/s1\"}}"
))
#expect(box.toasts.count == 1)
#expect(box.toasts.first?.title == "t")
}
}