oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import RastrilloApple
3import RastrilloTestSupport
4import XCTest
5@testable import DocsKit
6
7/// Stands in for NativeConnection: which account is active, and each
8/// account's current session.
9@MainActor private final class Connection {
10 var active: String?
11 var sessions: [String: NativeTransport] = [:]
12 init(active: String?) { self.active = active }
13 var client: NativeTransport? { active.flatMap { sessions[$0] } }
14 /// The production guard, wired the way DocsAccountWindow wires it.
15 func supplier(for account: String?) -> @MainActor () -> NativeTransport? {
16 DocsAccountTransport.supplier(account: account, active: { [weak self] in self?.active },
17 client: { [weak self] in self?.client })
18 }
19}
20
21/// Holds a request until the test releases it, so an account can switch
22/// while the request is in flight.
23private actor Gate {
24 private var waiters: [CheckedContinuation<Void, Never>] = []
25 private var released = false
26 var waiting: Int { waiters.count }
27 func pass() async {
28 if released { return }
29 await withCheckedContinuation { waiters.append($0) }
30 }
31 func release() {
32 released = true
33 waiters.forEach { $0.resume() }
34 waiters.removeAll()
35 }
36}
37
38private final class GatedTransport: NativeTransport, @unchecked Sendable {
39 let inner: ScriptedTransport
40 let gate = Gate()
41 let gatedPath: String
42 init(_ inner: ScriptedTransport, gating path: String) { self.inner = inner; gatedPath = path }
43 var origin: URL { inner.origin }
44 func responseDetails(_ path: String, method: String, body: Data?, contentType: String, maxBytes: Int) async throws -> NativeResponse {
45 if path == gatedPath { await gate.pass() }
46 return try await inner.responseDetails(path, method: method, body: body, contentType: contentType, maxBytes: maxBytes)
47 }
48 func stream(_ path: String, onOpen: @Sendable () async throws -> Void, onLine: @Sendable (String) async throws -> Void) async throws {
49 try await inner.stream(path, onOpen: onOpen, onLine: onLine)
50 }
51 func download(_ path: String, to destination: URL) async throws { try await inner.download(path, to: destination) }
52}
53
54private let listBody = Data(#"{"documents":[{"sqid":"d1","title":"Mine"}],"can_create":true}"#.utf8)
55
56/// One writable, empty document. `stream` says how its live stream answers.
57private func documentServer(stream: ScriptedTransport.Step = .hang) -> ScriptedTransport {
58 ScriptedTransport { call in
59 switch (call.kind, call.method, call.path) {
60 case (.request, "GET", "/?q="): return .respond(status: 200, body: listBody)
61 case (.request, "GET", "/d/doc"):
62 return .respond(status: 200, body: Data(#"{"sqid":"doc","title":"Doc","guid":"doc-guid","can_write":true,"can_share":false}"#.utf8))
63 case (.request, "GET", "/d/doc/updates"): return .respond(status: 200, headers: ["Docs-Seq": "0"])
64 case (.stream, _, "/d/doc/stream"): return stream
65 case (.request, "POST", "/d/doc/updates"): return .respond(status: 204)
66 default: return nil
67 }
68 }
69}
70
71private func posts(_ transport: ScriptedTransport) -> Int { transport.committed.filter { $0.method == "POST" }.count }
72
73@MainActor private func settle(_ model: DocsModel, _ deadline: ContinuousClock.Instant) async throws {
74 while model.hasUnsavedChanges && .now < deadline { try await Task.sleep(for: .milliseconds(10)) }
75}
76
77@MainActor private func edit(_ model: DocsModel, _ text: String) -> Bool {
78 model.didRender(model.projectionGeneration)
79 return model.enqueue(["op": .string("display_edit"), "at": .number(0), "length": .number(0), "text": .string(text)])
80}
81
82final class AccountBoundaryTests: XCTestCase {
83 @MainActor func testSupplierNeverHandsOverAnotherAccountsSession() async {
84 let mine = documentServer(), theirs = documentServer()
85 let connection = Connection(active: "a")
86 connection.sessions = ["a": mine, "b": theirs]
87 let model = DocsModel(transport: connection.supplier(for: "a"))
88 await model.load()
89 XCTAssertEqual(model.listState, .loaded)
90
91 connection.active = "b"
92 XCTAssertNil(connection.supplier(for: "a")(), "Account a's supplier returned b's session")
93 await model.load()
94 XCTAssertEqual(model.listState, .failed)
95 XCTAssertTrue(theirs.calls.isEmpty, "Account a's model sent a request with account b's session")
96
97 connection.active = "a"
98 await model.load()
99 XCTAssertEqual(model.listState, .loaded)
100 XCTAssertEqual(mine.calls.count, 2)
101 XCTAssertNil(Connection(active: nil).supplier(for: nil)(), "A window with no account got a session")
102 }
103
104 /// A signed-out account (the connection has no client) sends nothing, and
105 /// the same model picks the session up when it comes back.
106 @MainActor func testNilSupplierSendsNothingUntilTheSessionReturns() async {
107 let connection = Connection(active: "a")
108 let model = DocsModel(transport: connection.supplier(for: "a"))
109 await model.load()
110 XCTAssertEqual(model.listState, .failed)
111 let session = documentServer()
112 connection.sessions["a"] = session
113 await model.load()
114 XCTAssertEqual(model.listState, .loaded, "The model kept the missing session it started with")
115 XCTAssertEqual(session.calls.count, 1)
116 }
117
118 /// A save in flight when the window switches accounts finishes on the
119 /// session it started with; the next edit waits instead of going out on
120 /// the other account's session, and is sent when its own account returns.
121 @MainActor func testAccountSwitchDuringAnInFlightSave() async throws {
122 let mine = GatedTransport(documentServer(), gating: "/d/doc/updates")
123 let theirs = documentServer()
124 let connection = Connection(active: "a")
125 connection.sessions = ["a": mine, "b": theirs]
126 let model = DocsModel(transport: connection.supplier(for: "a"))
127 await mine.gate.release()
128 await model.open("doc")
129 let deadline = ContinuousClock.now.advanced(by: .seconds(5))
130 try await settle(model, deadline)
131 XCTAssertFalse(model.hasUnsavedChanges)
132
133 let gated = GatedTransport(mine.inner, gating: "/d/doc/updates")
134 connection.sessions["a"] = gated
135 XCTAssertTrue(edit(model, "In flight "))
136 while await gated.gate.waiting == 0 && .now < deadline { try await Task.sleep(for: .milliseconds(5)) }
137 connection.active = "b"
138 await gated.gate.release()
139 try await settle(model, deadline)
140 XCTAssertFalse(model.hasUnsavedChanges, "The in-flight save was lost")
141 let sent = posts(mine.inner)
142
143 XCTAssertTrue(edit(model, "Queued "))
144 let wait = ContinuousClock.now.advanced(by: .milliseconds(500))
145 try await settle(model, wait)
146 XCTAssertTrue(model.hasUnsavedChanges, "An edit was acknowledged with no session for its account")
147 XCTAssertTrue(theirs.calls.isEmpty, "Account a's edit went out on account b's session")
148 XCTAssertEqual(posts(mine.inner), sent)
149
150 connection.active = "a"
151 await model.retry()
152 try await settle(model, deadline)
153 XCTAssertFalse(model.hasUnsavedChanges)
154 XCTAssertEqual(posts(mine.inner), sent + 1)
155 await model.discardAll()
156 }
157
158 /// The live stream reconnects every two seconds. After a switch, those
159 /// reconnects must reach neither the other account nor, with the old
160 /// account inactive, anyone.
161 @MainActor func testAccountSwitchDuringAStream() async throws {
162 let mine = documentServer(stream: .respond(status: 200))
163 let theirs = documentServer()
164 let connection = Connection(active: "a")
165 connection.sessions = ["a": mine, "b": theirs]
166 let model = DocsModel(transport: connection.supplier(for: "a"))
167 await model.open("doc")
168 let deadline = ContinuousClock.now.advanced(by: .seconds(5))
169 while !mine.calls.contains(where: { $0.kind == .stream }) && .now < deadline { try await Task.sleep(for: .milliseconds(10)) }
170 try await settle(model, deadline)
171 connection.active = "b"
172 let before = mine.calls.count
173 try await Task.sleep(for: .milliseconds(2500))
174 XCTAssertTrue(theirs.calls.isEmpty, "The stream reconnected with account b's session: \(theirs.calls.map(\.path))")
175 XCTAssertEqual(mine.calls.count, before, "The stream reconnected for an account that is no longer active")
176 await model.discardAll()
177 }
178}
179