import Foundation import RastrilloApple import RastrilloTestSupport import XCTest @testable import DocsKit /// Stands in for NativeConnection: which account is active, and each /// account's current session. @MainActor private final class Connection { var active: String? var sessions: [String: NativeTransport] = [:] init(active: String?) { self.active = active } var client: NativeTransport? { active.flatMap { sessions[$0] } } /// The production guard, wired the way DocsAccountWindow wires it. func supplier(for account: String?) -> @MainActor () -> NativeTransport? { DocsAccountTransport.supplier(account: account, active: { [weak self] in self?.active }, client: { [weak self] in self?.client }) } } /// Holds a request until the test releases it, so an account can switch /// while the request is in flight. private actor Gate { private var waiters: [CheckedContinuation] = [] private var released = false var waiting: Int { waiters.count } func pass() async { if released { return } await withCheckedContinuation { waiters.append($0) } } func release() { released = true waiters.forEach { $0.resume() } waiters.removeAll() } } private final class GatedTransport: NativeTransport, @unchecked Sendable { let inner: ScriptedTransport let gate = Gate() let gatedPath: String init(_ inner: ScriptedTransport, gating path: String) { self.inner = inner; gatedPath = path } var origin: URL { inner.origin } func responseDetails(_ path: String, method: String, body: Data?, contentType: String, maxBytes: Int) async throws -> NativeResponse { if path == gatedPath { await gate.pass() } return try await inner.responseDetails(path, method: method, body: body, contentType: contentType, maxBytes: maxBytes) } func stream(_ path: String, onOpen: @Sendable () async throws -> Void, onLine: @Sendable (String) async throws -> Void) async throws { try await inner.stream(path, onOpen: onOpen, onLine: onLine) } func download(_ path: String, to destination: URL) async throws { try await inner.download(path, to: destination) } } private let listBody = Data(#"{"documents":[{"sqid":"d1","title":"Mine"}],"can_create":true}"#.utf8) /// One writable, empty document. `stream` says how its live stream answers. private func documentServer(stream: ScriptedTransport.Step = .hang) -> ScriptedTransport { ScriptedTransport { call in switch (call.kind, call.method, call.path) { case (.request, "GET", "/?q="): return .respond(status: 200, body: listBody) case (.request, "GET", "/d/doc"): return .respond(status: 200, body: Data(#"{"sqid":"doc","title":"Doc","guid":"doc-guid","can_write":true,"can_share":false}"#.utf8)) case (.request, "GET", "/d/doc/updates"): return .respond(status: 200, headers: ["Docs-Seq": "0"]) case (.stream, _, "/d/doc/stream"): return stream case (.request, "POST", "/d/doc/updates"): return .respond(status: 204) default: return nil } } } private func posts(_ transport: ScriptedTransport) -> Int { transport.committed.filter { $0.method == "POST" }.count } @MainActor private func settle(_ model: DocsModel, _ deadline: ContinuousClock.Instant) async throws { while model.hasUnsavedChanges && .now < deadline { try await Task.sleep(for: .milliseconds(10)) } } @MainActor private func edit(_ model: DocsModel, _ text: String) -> Bool { model.didRender(model.projectionGeneration) return model.enqueue(["op": .string("display_edit"), "at": .number(0), "length": .number(0), "text": .string(text)]) } final class AccountBoundaryTests: XCTestCase { @MainActor func testSupplierNeverHandsOverAnotherAccountsSession() async { let mine = documentServer(), theirs = documentServer() let connection = Connection(active: "a") connection.sessions = ["a": mine, "b": theirs] let model = DocsModel(transport: connection.supplier(for: "a")) await model.load() XCTAssertEqual(model.listState, .loaded) connection.active = "b" XCTAssertNil(connection.supplier(for: "a")(), "Account a's supplier returned b's session") await model.load() XCTAssertEqual(model.listState, .failed) XCTAssertTrue(theirs.calls.isEmpty, "Account a's model sent a request with account b's session") connection.active = "a" await model.load() XCTAssertEqual(model.listState, .loaded) XCTAssertEqual(mine.calls.count, 2) XCTAssertNil(Connection(active: nil).supplier(for: nil)(), "A window with no account got a session") } /// A signed-out account (the connection has no client) sends nothing, and /// the same model picks the session up when it comes back. @MainActor func testNilSupplierSendsNothingUntilTheSessionReturns() async { let connection = Connection(active: "a") let model = DocsModel(transport: connection.supplier(for: "a")) await model.load() XCTAssertEqual(model.listState, .failed) let session = documentServer() connection.sessions["a"] = session await model.load() XCTAssertEqual(model.listState, .loaded, "The model kept the missing session it started with") XCTAssertEqual(session.calls.count, 1) } /// A save in flight when the window switches accounts finishes on the /// session it started with; the next edit waits instead of going out on /// the other account's session, and is sent when its own account returns. @MainActor func testAccountSwitchDuringAnInFlightSave() async throws { let mine = GatedTransport(documentServer(), gating: "/d/doc/updates") let theirs = documentServer() let connection = Connection(active: "a") connection.sessions = ["a": mine, "b": theirs] let model = DocsModel(transport: connection.supplier(for: "a")) await mine.gate.release() await model.open("doc") let deadline = ContinuousClock.now.advanced(by: .seconds(5)) try await settle(model, deadline) XCTAssertFalse(model.hasUnsavedChanges) let gated = GatedTransport(mine.inner, gating: "/d/doc/updates") connection.sessions["a"] = gated XCTAssertTrue(edit(model, "In flight ")) while await gated.gate.waiting == 0 && .now < deadline { try await Task.sleep(for: .milliseconds(5)) } connection.active = "b" await gated.gate.release() try await settle(model, deadline) XCTAssertFalse(model.hasUnsavedChanges, "The in-flight save was lost") let sent = posts(mine.inner) XCTAssertTrue(edit(model, "Queued ")) let wait = ContinuousClock.now.advanced(by: .milliseconds(500)) try await settle(model, wait) XCTAssertTrue(model.hasUnsavedChanges, "An edit was acknowledged with no session for its account") XCTAssertTrue(theirs.calls.isEmpty, "Account a's edit went out on account b's session") XCTAssertEqual(posts(mine.inner), sent) connection.active = "a" await model.retry() try await settle(model, deadline) XCTAssertFalse(model.hasUnsavedChanges) XCTAssertEqual(posts(mine.inner), sent + 1) await model.discardAll() } /// The live stream reconnects every two seconds. After a switch, those /// reconnects must reach neither the other account nor, with the old /// account inactive, anyone. @MainActor func testAccountSwitchDuringAStream() async throws { let mine = documentServer(stream: .respond(status: 200)) let theirs = documentServer() let connection = Connection(active: "a") connection.sessions = ["a": mine, "b": theirs] let model = DocsModel(transport: connection.supplier(for: "a")) await model.open("doc") let deadline = ContinuousClock.now.advanced(by: .seconds(5)) while !mine.calls.contains(where: { $0.kind == .stream }) && .now < deadline { try await Task.sleep(for: .milliseconds(10)) } try await settle(model, deadline) connection.active = "b" let before = mine.calls.count try await Task.sleep(for: .milliseconds(2500)) XCTAssertTrue(theirs.calls.isEmpty, "The stream reconnected with account b's session: \(theirs.calls.map(\.path))") XCTAssertEqual(mine.calls.count, before, "The stream reconnected for an account that is no longer active") await model.discardAll() } }