Skip to content

Commit 1de44c6

Browse files
committed
Work in progress: Fixing tests.
Signed-off-by: Iva Horn <iva.horn@nextcloud.com>
1 parent 2abefd9 commit 1de44c6

5 files changed

Lines changed: 148 additions & 101 deletions

File tree

Sources/NextcloudFileProviderKit/Enumeration/RemoteChangeObserver.swift

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ import NextcloudKit
99

1010
public let NotifyPushAuthenticatedNotificationName = Notification.Name("NotifyPushAuthenticated")
1111

12-
public actor RemoteChangeObserver: NSObject, Sendable {
12+
public final class RemoteChangeObserver: NSObject, @unchecked Sendable {
1313
// @unchecked Sendable is used because 'account' is mutable, but mutation is controlled and safe in this context.
1414
public let remoteInterface: RemoteInterface
1515
public let changeNotificationInterface: ChangeNotificationInterface
1616
public let domain: NSFileProviderDomain?
1717
public let dbManager: FilesDatabaseManager
18-
private(set) var account: Account
18+
public var account: Account
1919
public var accountId: String { account.ncKitAccount }
2020

2121
public var webSocketPingIntervalNanoseconds: UInt64 = 3 * 1_000_000_000
@@ -41,14 +41,7 @@ public actor RemoteChangeObserver: NSObject, Sendable {
4141

4242
private(set) var pollingTimer: Timer?
4343

44-
private(set) var pollInterval: TimeInterval = 60 {
45-
didSet {
46-
if pollingActive {
47-
stopPollingTimer()
48-
startPollingTimer()
49-
}
50-
}
51-
}
44+
let pollInterval: TimeInterval
5245

5346
public var pollingActive: Bool {
5447
pollingTimer != nil
@@ -74,13 +67,15 @@ public actor RemoteChangeObserver: NSObject, Sendable {
7467
changeNotificationInterface: ChangeNotificationInterface,
7568
domain: NSFileProviderDomain?,
7669
dbManager: FilesDatabaseManager,
70+
pollInterval: TimeInterval = 60,
7771
log: any FileProviderLogging
7872
) {
7973
self.account = account
8074
self.remoteInterface = remoteInterface
8175
self.changeNotificationInterface = changeNotificationInterface
8276
self.domain = domain
8377
self.dbManager = dbManager
78+
self.pollInterval = pollInterval
8479
logger = FileProviderLogger(category: "RemoteChangeObserver", log: log)
8580
super.init()
8681

@@ -95,17 +90,20 @@ public actor RemoteChangeObserver: NSObject, Sendable {
9590
}
9691

9792
private func startPollingTimer() {
98-
guard !invalidated else { return }
93+
guard !invalidated else {
94+
self.logger.error("Starting polling timer while the current one is not invalidated yet!")
95+
return
96+
}
97+
9998
Task {
100-
pollingTimer = Timer.scheduledTimer(
101-
withTimeInterval: pollInterval, repeats: true
102-
) { [weak self] _ in
99+
pollingTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
103100
self?.logger.info("Polling timer timeout, notifying change.")
104101

105102
Task {
106103
await self?.startWorkingSetCheck()
107104
}
108105
}
106+
109107
logger.info("Starting polling timer.")
110108
}
111109
}
@@ -119,11 +117,13 @@ public actor RemoteChangeObserver: NSObject, Sendable {
119117
}
120118

121119
public func invalidate() {
120+
logger.debug("Invalidating.")
122121
invalidated = true
123122
resetWebSocket()
124123
}
125124

126125
private func reconnectWebSocket() {
126+
logger.debug("Reconnecting web socket...")
127127
stopPollingTimer()
128128
resetWebSocket()
129129

@@ -145,6 +145,7 @@ public actor RemoteChangeObserver: NSObject, Sendable {
145145
}
146146

147147
public func resetWebSocket() {
148+
logger.debug("Resetting web socket...")
148149
webSocketTask?.cancel()
149150
webSocketUrlSession = nil
150151
webSocketTask = nil
@@ -156,7 +157,10 @@ public actor RemoteChangeObserver: NSObject, Sendable {
156157
}
157158

158159
private func configureNotifyPush() async {
160+
logger.debug("Configuring notify push...")
161+
159162
guard !invalidated else {
163+
logger.error("Attempt to configure notify push while being invalidated!")
160164
return
161165
}
162166

@@ -369,10 +373,6 @@ public actor RemoteChangeObserver: NSObject, Sendable {
369373
self.account = account
370374
}
371375

372-
func setPollInterval(to interval: TimeInterval) {
373-
self.pollInterval = interval
374-
}
375-
376376
func setWebSocketPingInterval(to nanoseconds: UInt64) {
377377
self.webSocketPingIntervalNanoseconds = nanoseconds
378378
}

Tests/Interface/MockChangeNotificationInterface.swift

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
import Foundation
55
import NextcloudFileProviderKit
66

7-
public actor MockChangeNotificationInterface: @preconcurrency ChangeNotificationInterface {
7+
public final class MockChangeNotificationInterface: ChangeNotificationInterface {
88
public typealias ChangeHandler = @Sendable () -> Void
99

10-
private(set) var changeHandler: ChangeHandler?
10+
let changeHandler: ChangeHandler?
1111

1212
public init(changeHandler: ChangeHandler? = nil) {
1313
self.changeHandler = changeHandler
@@ -16,8 +16,4 @@ public actor MockChangeNotificationInterface: @preconcurrency ChangeNotification
1616
public func notifyChange() {
1717
changeHandler?()
1818
}
19-
20-
public func replaceChangeHandler(with newHandler: ChangeHandler?) {
21-
self.changeHandler = newHandler
22-
}
2319
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import XCTest
2+
3+
///
4+
/// A concurrency-safe counter for the sequential fulfillment of multiple expectations.
5+
///
6+
actor ExpectationFulfillmentCounter {
7+
///
8+
/// The number of increments during the lifetime of this object.
9+
///
10+
private(set) var count = 0
11+
12+
let expectations: [XCTestExpectation]
13+
14+
init(_ expectations: XCTestExpectation...) {
15+
self.expectations = expectations
16+
}
17+
18+
///
19+
/// Increase the state by one.
20+
///
21+
func next(file: StaticString = #filePath, line: UInt = #line) {
22+
guard expectations.count > count else {
23+
XCTFail("Insufficient expectations to fulfill!", file: file, line: line)
24+
return
25+
}
26+
27+
expectations[count].fulfill()
28+
count += 1
29+
}
30+
}

Tests/NextcloudFileProviderKitTests/RemoteChangeObserverTests.swift

Lines changed: 30 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase {
8383
Self.notifyPushServer.delay = 1_000_000
8484

8585
let authenticated = expectation(description: "authenticated")
86+
authenticated.assertForOverFulfill = false
8687

8788
NotificationCenter.default.addObserver(forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil) { _ in
8889
authenticated.fulfill()
@@ -156,38 +157,33 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase {
156157
debugPrint(db)
157158

158159
let testStartDate = Date()
159-
let remoteInterface =
160-
MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account))
160+
let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account))
161161
remoteInterface.capabilities = mockCapabilities
162162

163163
// --- DB State (What the app thinks is true) ---
164164
// A materialized file in the root that will be updated.
165-
var rootFileToUpdate =
166-
SendableItemMetadata(ocId: "rootFile", fileName: "root-file.txt", account: Self.account)
165+
var rootFileToUpdate = SendableItemMetadata(ocId: "rootFile", fileName: "root-file.txt", account: Self.account)
167166
rootFileToUpdate.downloaded = true
168167
rootFileToUpdate.etag = "ETAG_OLD_ROOTFILE"
169168
Self.dbManager.addItemMetadata(rootFileToUpdate)
170169

171170
// A materialized folder that will have its contents changed.
172-
var folderA =
173-
SendableItemMetadata(ocId: "folderA", fileName: "FolderA", account: Self.account)
171+
var folderA = SendableItemMetadata(ocId: "folderA", fileName: "FolderA", account: Self.account)
174172
folderA.directory = true
175173
folderA.visitedDirectory = true
176174
folderA.etag = "ETAG_OLD_FOLDERA"
177175
Self.dbManager.addItemMetadata(folderA)
178176

179177
// A materialized file inside FolderA that will be deleted.
180-
var fileInAToDelete =
181-
SendableItemMetadata(ocId: "fileInA", fileName: "file-in-a.txt", account: Self.account)
178+
var fileInAToDelete = SendableItemMetadata(ocId: "fileInA", fileName: "file-in-a.txt", account: Self.account)
182179
fileInAToDelete.downloaded = true
183180
fileInAToDelete.serverUrl = Self.account.davFilesUrl + "/FolderA"
184181
// Set an explicit old sync time to verify it gets updated during deletion
185182
fileInAToDelete.syncTime = Date(timeIntervalSince1970: 1000)
186183
Self.dbManager.addItemMetadata(fileInAToDelete)
187184

188185
// A materialized folder that will be deleted entirely.
189-
var folderBToDelete =
190-
SendableItemMetadata(ocId: "folderB", fileName: "FolderB", account: Self.account)
186+
var folderBToDelete = SendableItemMetadata(ocId: "folderB", fileName: "FolderB", account: Self.account)
191187
folderBToDelete.directory = true
192188
folderBToDelete.visitedDirectory = true
193189
// Set an explicit old sync time to verify it gets updated during deletion
@@ -243,9 +239,8 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase {
243239
let authExpectation =
244240
XCTNSNotificationExpectation(name: NotifyPushAuthenticatedNotificationName)
245241
let changeNotifiedExpectation = XCTestExpectation(description: "Change Notified")
246-
let notificationInterface = MockChangeNotificationInterface()
247242

248-
await notificationInterface.replaceChangeHandler {
243+
let notificationInterface = MockChangeNotificationInterface {
249244
changeNotifiedExpectation.fulfill()
250245
}
251246

@@ -303,16 +298,14 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase {
303298
remoteInterface.capabilities = mockCapabilities
304299

305300
let authenticated = expectation(description: "authenticated")
306-
let notified = expectation(description: "notified")
301+
authenticated.assertForOverFulfill = false
307302

308303
NotificationCenter.default.addObserver(forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil) { _ in
309304
authenticated.fulfill()
310305
}
311306

312-
let notificationInterface = MockChangeNotificationInterface()
313-
314-
await notificationInterface.replaceChangeHandler {
315-
notified.fulfill()
307+
let notificationInterface = MockChangeNotificationInterface {
308+
XCTFail("This notification should not happen!")
316309
}
317310

318311
remoteChangeObserver = RemoteChangeObserver(
@@ -329,8 +322,6 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase {
329322
Self.notifyPushServer.send(message: "random")
330323
Self.notifyPushServer.send(message: "notify_activity")
331324
Self.notifyPushServer.send(message: "notify_notification")
332-
333-
await fulfillment(of: [notified])
334325
}
335326

336327
func testPolling() async throws {
@@ -353,9 +344,8 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase {
353344
remoteInterface.rootItem?.children = [serverItem]
354345

355346
let changeNotifiedExpectation = XCTestExpectation(description: "Change Notified via Polling")
356-
let notificationInterface = MockChangeNotificationInterface()
357347

358-
await notificationInterface.replaceChangeHandler {
348+
let notificationInterface = MockChangeNotificationInterface {
359349
changeNotifiedExpectation.fulfill()
360350
}
361351

@@ -365,15 +355,15 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase {
365355
changeNotificationInterface: notificationInterface,
366356
domain: nil,
367357
dbManager: Self.dbManager,
358+
pollInterval: 0.5,
368359
log: FileProviderLogMock()
369360
)
370-
// Set a very short poll interval for the test.
371-
await remoteChangeObserver?.setPollInterval(to: 0.5)
372361

373362
// 2. Act & Assert
374363
// The observer will fail to connect to websocket and start polling.
375364
// We just need to wait for the poll to fire and detect the change.
376365
await wait(for: changeNotifiedExpectation, description: "polling to trigger change")
366+
377367
let pollingActive = await remoteChangeObserver?.pollingActive ?? false
378368
XCTAssertTrue(pollingActive, "Polling should be active.")
379369
}
@@ -384,26 +374,13 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase {
384374

385375
let authenticated = expectation(description: "authenticated")
386376
let reauthenticated = expectation(description: "reauthenticated")
387-
388-
actor AuthCounter {
389-
var count = 0
390-
func increment() -> Int {
391-
count += 1
392-
return count
393-
}
394-
}
395-
let authCounter = AuthCounter()
377+
let fulfillments = ExpectationFulfillmentCounter(authenticated, reauthenticated)
396378

397379
NotificationCenter.default.addObserver(
398380
forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil
399381
) { _ in
400382
Task {
401-
let count = await authCounter.increment()
402-
if count == 1 {
403-
authenticated.fulfill()
404-
} else if count == 2 {
405-
reauthenticated.fulfill()
406-
}
383+
await fulfillments.next()
407384
}
408385
}
409386

@@ -508,7 +485,17 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase {
508485
)
509486
remoteInterface.rootItem?.children = [serverItem]
510487

511-
let notificationInterface = MockChangeNotificationInterface()
488+
let change1 = XCTestExpectation(description: "First change notification")
489+
let change2 = XCTestExpectation(description: "Second change notification")
490+
491+
let fulfillments = ExpectationFulfillmentCounter(change1, change2)
492+
493+
let notificationInterface = MockChangeNotificationInterface {
494+
Task {
495+
await fulfillments.next()
496+
}
497+
}
498+
512499
let remoteChangeObserver = RemoteChangeObserver(
513500
account: Self.account,
514501
remoteInterface: remoteInterface,
@@ -520,19 +507,12 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase {
520507
self.remoteChangeObserver = remoteChangeObserver
521508

522509
// --- Phase 1: Test connection and change notification ---
523-
let authExpectation =
524-
XCTNSNotificationExpectation(name: NotifyPushAuthenticatedNotificationName)
510+
let authExpectation = XCTNSNotificationExpectation(name: NotifyPushAuthenticatedNotificationName)
525511
remoteChangeObserver.networkReachabilityObserver(.reachableEthernetOrWiFi)
526512
await wait(for: authExpectation, description: "initial authentication")
527513

528-
let changeExpectation1 = XCTestExpectation(description: "First change notification")
529-
530-
await notificationInterface.replaceChangeHandler {
531-
changeExpectation1.fulfill()
532-
}
533-
534514
Self.notifyPushServer.send(message: "notify_file")
535-
await wait(for: changeExpectation1, description: "first change")
515+
await wait(for: change1, description: "first change")
536516

537517
// --- Phase 2: Test connection loss ---
538518
remoteChangeObserver.networkReachabilityObserver(.notReachable)
@@ -544,8 +524,7 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase {
544524
Self.notifyPushServer.reset()
545525

546526
// --- Phase 3: Test reconnection and change notification ---
547-
let reauthExpectation =
548-
XCTNSNotificationExpectation(name: NotifyPushAuthenticatedNotificationName)
527+
let reauthExpectation = XCTNSNotificationExpectation(name: NotifyPushAuthenticatedNotificationName)
549528

550529
// Trigger the reconnection logic.
551530
remoteChangeObserver.networkReachabilityObserver(.reachableEthernetOrWiFi)
@@ -556,13 +535,7 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase {
556535

557536
XCTAssertTrue(webSocketTaskActiveAfterReconnect, "Websocket should be active again after reconnection.")
558537

559-
let changeExpectation2 = XCTestExpectation(description: "Second change notification")
560-
561-
await notificationInterface.replaceChangeHandler {
562-
changeExpectation2.fulfill()
563-
}
564-
565538
Self.notifyPushServer.send(message: "notify_file")
566-
await wait(for: changeExpectation2, description: "second change")
539+
await wait(for: change2, description: "second change")
567540
}
568541
}

0 commit comments

Comments
 (0)