mirror of
https://github.com/nextcloud/desktop.git
synced 2026-01-14 02:01:28 +00:00
530 lines
31 KiB
Swift
530 lines
31 KiB
Swift
// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
|
// SPDX-License-Identifier: LGPL-3.0-or-later
|
|
|
|
@preconcurrency import FileProvider
|
|
import Foundation
|
|
import NextcloudCapabilitiesKit
|
|
@testable import NextcloudFileProviderKit
|
|
import NextcloudFileProviderKitMocks
|
|
import RealmSwift
|
|
import Testing
|
|
import TestInterface
|
|
import XCTest
|
|
|
|
private let mockCapabilities = ##"{"ocs":{"meta":{"status":"ok","statuscode":100,"message":"OK","totalitems":"","itemsperpage":""},"data":{"version":{"major":28,"minor":0,"micro":4,"string":"28.0.4","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)((?:[-A-Z0-9+_]+\\.)+[-A-Z]+(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)"},"bruteforce":{"delay":0,"allow-listed":false},"files":{"bigfilechunking":true,"blacklisted_files":[".htaccess"],"directEditing":{"url":"localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag":"c748e8fc588b54fc5af38c4481a19d20","supportsFileId":true},"comments":true,"undelete":true,"versioning":true,"version_labeling":true,"version_deletion":true},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"circles":{"version":"28.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Single","2":"Personal","4":"System","8":"Visible","16":"Open","32":"Invite","64":"Join Request","128":"Friends","256":"Password Protected","512":"No Owner","1024":"Hidden","2048":"Backend","4096":"Local","8192":"Root","16384":"Circle Invite","32768":"Federated","65536":"Mount point"},"source":{"core":{"1":"Nextcloud Account","2":"Nextcloud Group","4":"Email Address","8":"Contact","16":"Circle","10000":"Nextcloud App"},"extra":{"10001":"Circles App","10002":"Admin Command Line"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Member","4":"Moderator","8":"Admin","9":"Owner"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}}},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","endPoint":"localhost\/ocm","resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}]},"dav":{"chunking":"1.0","bulkupload":"1.0"},"deck":{"version":"1.12.2","canCreateBoards":true,"apiVersions":["1.0","1.1"]},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":true,"days":7,"enforced":true},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":true}}},"fulltextsearch":{"remote":true,"providers":[{"id":"deck","name":"Deck"},{"id":"files","name":"Files"}]},"notes":{"api_version":["0.2","1.3"],"version":"4.9.4"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"notify_push":{"type":["files","activities","notifications"],"endpoints":{"websocket":"ws:\/\/localhost:8888\/websocket","pre_auth":"localhost\/apps\/notify_push\/pre_auth"}},"password_policy":{"minLength":10,"enforceNonCommonPassword":true,"enforceNumericCharacters":false,"enforceSpecialCharacters":false,"enforceUpperLowerCase":false,"api":{"generate":"localhost\/ocs\/v2.php\/apps\/password_policy\/api\/v1\/generate","validate":"localhost\/ocs\/v2.php\/apps\/password_policy\/api\/v1\/validate"}},"provisioning_api":{"version":"1.18.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":true},"richdocuments":{"version":"8.3.4","mimetypes":["application\/vnd.oasis.opendocument.text","application\/vnd.oasis.opendocument.spreadsheet","application\/vnd.oasis.opendocument.graphics","application\/vnd.oasis.opendocument.presentation","application\/vnd.oasis.opendocument.text-flat-xml","application\/vnd.oasis.opendocument.spreadsheet-flat-xml","application\/vnd.oasis.opendocument.graphics-flat-xml","application\/vnd.oasis.opendocument.presentation-flat-xml","application\/vnd.lotus-wordpro","application\/vnd.visio","application\/vnd.ms-visio.drawing","application\/vnd.wordperfect","application\/rtf","text\/rtf","application\/msonenote","application\/msword","application\/vnd.openxmlformats-officedocument.wordprocessingml.document","application\/vnd.openxmlformats-officedocument.wordprocessingml.template","application\/vnd.ms-word.document.macroEnabled.12","application\/vnd.ms-word.template.macroEnabled.12","application\/vnd.ms-excel","application\/vnd.openxmlformats-officedocument.spreadsheetml.sheet","application\/vnd.openxmlformats-officedocument.spreadsheetml.template","application\/vnd.ms-excel.sheet.macroEnabled.12","application\/vnd.ms-excel.template.macroEnabled.12","application\/vnd.ms-excel.addin.macroEnabled.12","application\/vnd.ms-excel.sheet.binary.macroEnabled.12","application\/vnd.ms-powerpoint","application\/vnd.openxmlformats-officedocument.presentationml.presentation","application\/vnd.openxmlformats-officedocument.presentationml.template","application\/vnd.openxmlformats-officedocument.presentationml.slideshow","application\/vnd.ms-powerpoint.addin.macroEnabled.12","application\/vnd.ms-powerpoint.presentation.macroEnabled.12","application\/vnd.ms-powerpoint.template.macroEnabled.12","application\/vnd.ms-powerpoint.slideshow.macroEnabled.12","text\/csv"],"mimetypesNoDefaultOpen":["image\/svg+xml","application\/pdf","text\/plain","text\/spreadsheet"],"mimetypesSecureView":[],"collabora":{"convert-to":{"available":true,"endpoint":"\/cool\/convert-to"},"hasMobileSupport":true,"hasProxyPrefix":false,"hasTemplateSaveAs":false,"hasTemplateSource":true,"hasWASMSupport":false,"hasZoteroSupport":true,"productName":"Collabora Online Development Edition","productVersion":"23.05.10.1","productVersionHash":"baa6eef","serverId":"8bee4df3"},"direct_editing":true,"templates":true,"productName":"Nextcloud Office","editonline_endpoint":"localhost\/apps\/richdocuments\/editonline","config":{"wopi_url":"localhost\/","public_wopi_url":"localhost","wopi_callback_url":"","disable_certificate_verification":null,"edit_groups":null,"use_groups":null,"doc_format":null,"timeout":15}},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","message-expiration","reactions","chat-reference-id"],"config":{"attachments":{"allowed":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4d","\ud83d\udc4e","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"sip-enabled":false,"sip-dialout-enabled":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"can-upload-background":true,"can-enable-sip":true},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"typing-privacy":0},"conversations":{"can-create":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"hello-v2-token-key":"-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAECOu2NBMo4juGx6hHNIGa550gGaxN\nzqe\/TPxsX3QRjCrkyvdQaltjuRt\/9PddhpbMxcJSzwVLqZRVHylfllD8pg==\n-----END PUBLIC KEY-----\n"}},"version":"18.0.7"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"a safe home for all your data","color":"#6ea68f","color-text":"#000000","color-element":"#6ea68f","color-element-bright":"#6ea68f","color-element-dark":"#6ea68f","logo":"localhost\/core\/img\/logo\/logo.svg?v=1","background":"#6ea68f","background-plain":true,"background-default":true,"logoheader":"localhost\/core\/img\/logo\/logo.svg?v=1","favicon":"localhost\/core\/img\/logo\/logo.svg?v=1"},"user_status":{"enabled":true,"restore":true,"supports_emoji":true},"weather_status":{"enabled":true}}}}}"##
|
|
|
|
private let username = "testUser"
|
|
private let userId = "testUserId"
|
|
private let serverUrl = "localhost"
|
|
private let password = "abcd"
|
|
|
|
@available(macOS 14.0, iOS 17.0, *)
|
|
final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase {
|
|
static let timeout = 5000 // tries
|
|
static let account = Account(
|
|
user: username, id: userId, serverUrl: serverUrl, password: password
|
|
)
|
|
static let dbManager = FilesDatabaseManager(account: account, databaseDirectory: makeDatabaseDirectory(), fileProviderDomainIdentifier: NSFileProviderDomainIdentifier("test"), log: FileProviderLogMock())
|
|
static let notifyPushServer = MockNotifyPushServer(
|
|
host: serverUrl,
|
|
port: 8888,
|
|
username: username,
|
|
password: password,
|
|
eventLoopGroup: .singleton
|
|
)
|
|
var remoteChangeObserver: RemoteChangeObserver?
|
|
|
|
override func setUp() async throws {
|
|
Realm.Configuration.defaultConfiguration.inMemoryIdentifier = name
|
|
let server = Self.notifyPushServer
|
|
|
|
Task {
|
|
try await server.run()
|
|
}
|
|
}
|
|
|
|
override func tearDown() async throws {
|
|
remoteChangeObserver?.resetWebSocket()
|
|
remoteChangeObserver = nil
|
|
Self.notifyPushServer.reset()
|
|
}
|
|
|
|
/// Helper to wait for an expectation with a standard timeout.
|
|
private func wait(for expectation: XCTestExpectation, description: String) async {
|
|
let result = await XCTWaiter.fulfillment(of: [expectation], timeout: 10.0)
|
|
|
|
if result != .completed {
|
|
XCTFail("Timeout waiting for \(description)")
|
|
}
|
|
}
|
|
|
|
func testAuthentication() async throws {
|
|
let remoteInterface = MockRemoteInterface(account: Self.account)
|
|
remoteInterface.capabilities = mockCapabilities
|
|
|
|
let authenticated = expectation(description: "authenticated")
|
|
authenticated.assertForOverFulfill = false
|
|
|
|
NotificationCenter.default.addObserver(forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil) { _ in
|
|
authenticated.fulfill()
|
|
}
|
|
|
|
remoteChangeObserver = RemoteChangeObserver(
|
|
account: Self.account,
|
|
remoteInterface: remoteInterface,
|
|
changeNotificationInterface: MockChangeNotificationInterface(),
|
|
domain: nil,
|
|
dbManager: Self.dbManager,
|
|
log: FileProviderLogMock()
|
|
)
|
|
|
|
await fulfillment(of: [authenticated])
|
|
}
|
|
|
|
func testRetryAuthentication() async throws {
|
|
Self.notifyPushServer.delay = 1_000_000
|
|
|
|
let authenticated = expectation(description: "authenticated")
|
|
authenticated.assertForOverFulfill = false
|
|
|
|
NotificationCenter.default.addObserver(forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil) { _ in
|
|
authenticated.fulfill()
|
|
}
|
|
|
|
let incorrectAccount = Account(user: username, id: userId, serverUrl: serverUrl, password: "wrong!")
|
|
let remoteInterface = MockRemoteInterface(account: Self.account)
|
|
remoteInterface.capabilities = mockCapabilities
|
|
|
|
remoteChangeObserver = RemoteChangeObserver(
|
|
account: incorrectAccount,
|
|
remoteInterface: remoteInterface,
|
|
changeNotificationInterface: MockChangeNotificationInterface(),
|
|
domain: nil,
|
|
dbManager: Self.dbManager,
|
|
log: FileProviderLogMock()
|
|
)
|
|
|
|
let remoteChangeObserver = remoteChangeObserver!
|
|
|
|
for _ in 0 ... Self.timeout {
|
|
try await Task.sleep(nanoseconds: 1_000_001)
|
|
if remoteChangeObserver.webSocketAuthenticationFailCount > 0 {
|
|
break
|
|
}
|
|
}
|
|
|
|
let count = remoteChangeObserver.webSocketAuthenticationFailCount
|
|
XCTAssertTrue(count > 0)
|
|
|
|
remoteChangeObserver.replaceAccount(with: Self.account)
|
|
|
|
await fulfillment(of: [authenticated])
|
|
remoteChangeObserver.resetWebSocket()
|
|
}
|
|
|
|
func testStopRetryingConnection() async throws {
|
|
let incorrectAccount =
|
|
Account(user: username, id: userId, serverUrl: serverUrl, password: "wrong!")
|
|
let remoteInterface = MockRemoteInterface(account: Self.account)
|
|
remoteInterface.capabilities = mockCapabilities
|
|
let remoteChangeObserver = RemoteChangeObserver(
|
|
account: incorrectAccount,
|
|
remoteInterface: remoteInterface,
|
|
changeNotificationInterface: MockChangeNotificationInterface(),
|
|
domain: nil,
|
|
dbManager: Self.dbManager,
|
|
log: FileProviderLogMock()
|
|
)
|
|
|
|
for _ in 0 ... Self.timeout {
|
|
try await Task.sleep(nanoseconds: 1_000_000)
|
|
if remoteChangeObserver.webSocketAuthenticationFailCount ==
|
|
remoteChangeObserver.webSocketAuthenticationFailLimit
|
|
{
|
|
break
|
|
}
|
|
}
|
|
|
|
let count = remoteChangeObserver.webSocketAuthenticationFailCount
|
|
let limit = remoteChangeObserver.webSocketAuthenticationFailLimit
|
|
let active = remoteChangeObserver.webSocketTaskActive
|
|
|
|
XCTAssertEqual(count, limit)
|
|
XCTAssertFalse(active)
|
|
}
|
|
|
|
func testChangeRecognised() async throws {
|
|
// 1. Arrange
|
|
let db = Self.dbManager.ncDatabase()
|
|
debugPrint(db)
|
|
|
|
let testStartDate = Date()
|
|
let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account))
|
|
remoteInterface.capabilities = mockCapabilities
|
|
|
|
// --- DB State (What the app thinks is true) ---
|
|
// A materialized file in the root that will be updated.
|
|
var rootFileToUpdate = SendableItemMetadata(ocId: "rootFile", fileName: "root-file.txt", account: Self.account)
|
|
rootFileToUpdate.downloaded = true
|
|
rootFileToUpdate.etag = "ETAG_OLD_ROOTFILE"
|
|
Self.dbManager.addItemMetadata(rootFileToUpdate)
|
|
|
|
// A materialized folder that will have its contents changed.
|
|
var folderA = SendableItemMetadata(ocId: "folderA", fileName: "FolderA", account: Self.account)
|
|
folderA.directory = true
|
|
folderA.visitedDirectory = true
|
|
folderA.etag = "ETAG_OLD_FOLDERA"
|
|
Self.dbManager.addItemMetadata(folderA)
|
|
|
|
// A materialized file inside FolderA that will be deleted.
|
|
var fileInAToDelete = SendableItemMetadata(ocId: "fileInA", fileName: "file-in-a.txt", account: Self.account)
|
|
fileInAToDelete.downloaded = true
|
|
fileInAToDelete.serverUrl = Self.account.davFilesUrl + "/FolderA"
|
|
// Set an explicit old sync time to verify it gets updated during deletion
|
|
fileInAToDelete.syncTime = Date(timeIntervalSince1970: 1000)
|
|
Self.dbManager.addItemMetadata(fileInAToDelete)
|
|
|
|
// A materialized folder that will be deleted entirely.
|
|
var folderBToDelete = SendableItemMetadata(ocId: "folderB", fileName: "FolderB", account: Self.account)
|
|
folderBToDelete.directory = true
|
|
folderBToDelete.visitedDirectory = true
|
|
// Set an explicit old sync time to verify it gets updated during deletion
|
|
folderBToDelete.syncTime = Date(timeIntervalSince1970: 2000)
|
|
Self.dbManager.addItemMetadata(folderBToDelete)
|
|
|
|
// Record original sync times to verify they are updated during deletion
|
|
let originalFileInASyncTime = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "fileInA")).syncTime
|
|
let originalFolderBSyncTime = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "folderB")).syncTime
|
|
|
|
// --- Server State (The new "remote truth") ---
|
|
let rootItem = remoteInterface.rootItem!
|
|
|
|
// Update the root file on the server.
|
|
let serverRootFile = MockRemoteItem(
|
|
identifier: "rootFile",
|
|
versionIdentifier: "ETAG_NEW_ROOTFILE",
|
|
name: "root-file.txt",
|
|
remotePath: Self.account.davFilesUrl + "/root-file.txt",
|
|
account: Self.account.ncKitAccount,
|
|
username: Self.account.username,
|
|
userId: Self.account.id,
|
|
serverUrl: Self.account.davFilesUrl
|
|
)
|
|
rootItem.children.append(serverRootFile)
|
|
|
|
// Update FolderA on the server and modify its contents.
|
|
let serverFolderA = MockRemoteItem(
|
|
identifier: "folderA",
|
|
versionIdentifier: "ETAG_NEW_FOLDERA",
|
|
name: "FolderA",
|
|
remotePath: Self.account.davFilesUrl + "/FolderA",
|
|
directory: true,
|
|
account: Self.account.ncKitAccount,
|
|
username: Self.account.username,
|
|
userId: Self.account.id,
|
|
serverUrl: Self.account.davFilesUrl
|
|
)
|
|
rootItem.children.append(serverFolderA)
|
|
|
|
// Add a new file inside FolderA on the server.
|
|
let newFileInA = MockRemoteItem(
|
|
identifier: "newFileInA",
|
|
name: "new-file.txt",
|
|
remotePath: serverFolderA.remotePath + "/new-file.txt",
|
|
account: Self.account.ncKitAccount,
|
|
username: Self.account.username,
|
|
userId: Self.account.id,
|
|
serverUrl: serverFolderA.remotePath
|
|
)
|
|
serverFolderA.children.append(newFileInA)
|
|
|
|
let authExpectation =
|
|
XCTNSNotificationExpectation(name: NotifyPushAuthenticatedNotificationName)
|
|
let changeNotifiedExpectation = XCTestExpectation(description: "Change Notified")
|
|
|
|
let notificationInterface = MockChangeNotificationInterface {
|
|
changeNotifiedExpectation.fulfill()
|
|
}
|
|
|
|
remoteChangeObserver = RemoteChangeObserver(
|
|
account: Self.account,
|
|
remoteInterface: remoteInterface,
|
|
changeNotificationInterface: notificationInterface,
|
|
domain: nil,
|
|
dbManager: Self.dbManager,
|
|
log: FileProviderLogMock()
|
|
)
|
|
|
|
// 2. Act & Assert
|
|
await wait(for: authExpectation, description: "authentication")
|
|
|
|
Self.notifyPushServer.send(message: "notify_file")
|
|
|
|
await wait(for: changeNotifiedExpectation, description: "change notification")
|
|
|
|
// 3. Assert Database State
|
|
// Check updated items
|
|
let finalRootFile = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "rootFile"))
|
|
XCTAssertEqual(finalRootFile.etag, "ETAG_NEW_ROOTFILE")
|
|
XCTAssertTrue(finalRootFile.syncTime >= testStartDate)
|
|
|
|
let finalFolderA = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "folderA"))
|
|
XCTAssertEqual(finalFolderA.etag, "ETAG_NEW_FOLDERA")
|
|
XCTAssertTrue(finalFolderA.syncTime >= testStartDate)
|
|
|
|
// Check new item
|
|
let finalNewFile = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "newFileInA"))
|
|
XCTAssertEqual(finalNewFile.fileName, "new-file.txt")
|
|
XCTAssertNotNil(finalNewFile.syncTime)
|
|
|
|
// Check deleted items
|
|
let deletedFileInA = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "fileInA"))
|
|
XCTAssertTrue(
|
|
deletedFileInA.deleted, "File inside updated folder should be marked as deleted."
|
|
)
|
|
XCTAssertTrue(deletedFileInA.syncTime >= testStartDate,
|
|
"Deleted file's sync time should be updated to current time")
|
|
XCTAssertGreaterThan(deletedFileInA.syncTime, originalFileInASyncTime,
|
|
"Deleted file's sync time should be newer than original sync time")
|
|
|
|
let deletedFolderB = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "folderB"))
|
|
XCTAssertTrue(deletedFolderB.deleted, "The entire folder should be marked as deleted.")
|
|
XCTAssertTrue(deletedFolderB.syncTime >= testStartDate,
|
|
"Deleted folder's sync time should be updated to current time")
|
|
XCTAssertGreaterThan(deletedFolderB.syncTime, originalFolderBSyncTime,
|
|
"Deleted folder's sync time should be newer than original sync time")
|
|
}
|
|
|
|
func testIgnoreNonFileNotifications() async throws {
|
|
let remoteInterface = MockRemoteInterface(account: Self.account)
|
|
remoteInterface.capabilities = mockCapabilities
|
|
|
|
let authenticated = expectation(description: "authenticated")
|
|
authenticated.assertForOverFulfill = false
|
|
|
|
NotificationCenter.default.addObserver(forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil) { _ in
|
|
authenticated.fulfill()
|
|
}
|
|
|
|
let notificationInterface = MockChangeNotificationInterface {
|
|
XCTFail("This notification should not happen!")
|
|
}
|
|
|
|
remoteChangeObserver = RemoteChangeObserver(
|
|
account: Self.account,
|
|
remoteInterface: remoteInterface,
|
|
changeNotificationInterface: notificationInterface,
|
|
domain: nil,
|
|
dbManager: Self.dbManager,
|
|
log: FileProviderLogMock()
|
|
)
|
|
|
|
await fulfillment(of: [authenticated])
|
|
|
|
Self.notifyPushServer.send(message: "random")
|
|
Self.notifyPushServer.send(message: "notify_activity")
|
|
Self.notifyPushServer.send(message: "notify_notification")
|
|
}
|
|
|
|
func testPolling() async throws {
|
|
// 1. Arrange
|
|
let db = Self.dbManager.ncDatabase()
|
|
debugPrint(db)
|
|
|
|
let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account))
|
|
// No capabilities -> will force polling.
|
|
remoteInterface.capabilities = ""
|
|
|
|
// DB State: A materialized file with an old ETag.
|
|
var fileToUpdate = SendableItemMetadata(ocId: "item1", fileName: "file.txt", account: Self.account)
|
|
fileToUpdate.downloaded = true
|
|
fileToUpdate.etag = "ETAG_OLD"
|
|
Self.dbManager.addItemMetadata(fileToUpdate)
|
|
|
|
// Server State: The same file now has a new ETag.
|
|
let serverItem = MockRemoteItem(identifier: "item1", versionIdentifier: "ETAG_NEW", name: "file.txt", remotePath: Self.account.davFilesUrl + "/file.txt", account: Self.account.ncKitAccount, username: Self.account.username, userId: Self.account.id, serverUrl: Self.account.serverUrl)
|
|
remoteInterface.rootItem?.children = [serverItem]
|
|
|
|
let changeNotifiedExpectation = XCTestExpectation(description: "Change Notified via Polling")
|
|
|
|
let notificationInterface = MockChangeNotificationInterface {
|
|
changeNotifiedExpectation.fulfill()
|
|
}
|
|
|
|
remoteChangeObserver = RemoteChangeObserver(
|
|
account: Self.account,
|
|
remoteInterface: remoteInterface,
|
|
changeNotificationInterface: notificationInterface,
|
|
domain: nil,
|
|
dbManager: Self.dbManager,
|
|
pollInterval: 0.5,
|
|
log: FileProviderLogMock()
|
|
)
|
|
|
|
// 2. Act & Assert
|
|
// The observer will fail to connect to websocket and start polling.
|
|
// We just need to wait for the poll to fire and detect the change.
|
|
await wait(for: changeNotifiedExpectation, description: "polling to trigger change")
|
|
|
|
let pollingActive = remoteChangeObserver?.pollingActive ?? false
|
|
XCTAssertTrue(pollingActive, "Polling should be active.")
|
|
}
|
|
|
|
func testRetryOnRemoteClose() async throws {
|
|
let remoteInterface = MockRemoteInterface(account: Self.account)
|
|
remoteInterface.capabilities = mockCapabilities
|
|
|
|
let authenticated = expectation(description: "authenticated")
|
|
let reauthenticated = expectation(description: "reauthenticated")
|
|
let fulfillments = ExpectationFulfillmentCounter(authenticated, reauthenticated)
|
|
|
|
NotificationCenter.default.addObserver(
|
|
forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil
|
|
) { _ in
|
|
Task {
|
|
await fulfillments.next()
|
|
}
|
|
}
|
|
|
|
remoteChangeObserver = RemoteChangeObserver(
|
|
account: Self.account,
|
|
remoteInterface: remoteInterface,
|
|
changeNotificationInterface: MockChangeNotificationInterface(),
|
|
domain: nil,
|
|
dbManager: Self.dbManager,
|
|
log: FileProviderLogMock()
|
|
)
|
|
|
|
await fulfillment(of: [authenticated])
|
|
|
|
Self.notifyPushServer.resetCredentialsState()
|
|
Self.notifyPushServer.closeConnections()
|
|
|
|
await fulfillment(of: [reauthenticated])
|
|
}
|
|
|
|
func testPinging() async throws {
|
|
let authentication = expectation(description: "authentication")
|
|
authentication.assertForOverFulfill = false
|
|
|
|
let remoteInterface = MockRemoteInterface(account: Self.account)
|
|
remoteInterface.capabilities = mockCapabilities
|
|
|
|
NotificationCenter.default.addObserver(forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil) { _ in
|
|
authentication.fulfill()
|
|
}
|
|
|
|
remoteChangeObserver = RemoteChangeObserver(
|
|
account: Self.account,
|
|
remoteInterface: remoteInterface,
|
|
changeNotificationInterface: MockChangeNotificationInterface(),
|
|
domain: nil,
|
|
dbManager: Self.dbManager,
|
|
log: FileProviderLogMock()
|
|
)
|
|
|
|
let pingIntervalNsecs = 500_000_000
|
|
remoteChangeObserver?.setWebSocketPingInterval(to: UInt64(pingIntervalNsecs))
|
|
await wait(for: authentication, description: "authentication")
|
|
|
|
let measurementStart = Date()
|
|
let firstPing = expectation(description: "First Ping")
|
|
let secondPing = expectation(description: "Second Ping")
|
|
let thirdPing = expectation(description: "Third Ping")
|
|
let pings = ExpectationFulfillmentCounter(firstPing, secondPing, thirdPing)
|
|
|
|
Self.notifyPushServer.pingHandler = {
|
|
Task {
|
|
await pings.next()
|
|
}
|
|
}
|
|
|
|
await fulfillment(of: [firstPing, secondPing, thirdPing])
|
|
let measurementEnd = Date()
|
|
let pingTimeInterval = measurementEnd.timeIntervalSince(measurementStart)
|
|
|
|
XCTAssertGreaterThan(pingTimeInterval, Double(pingIntervalNsecs / 1_000_000_000) * 3)
|
|
}
|
|
|
|
func testRetryOnConnectionLoss() async throws {
|
|
// 1. Arrange
|
|
let db = Self.dbManager.ncDatabase()
|
|
debugPrint(db)
|
|
|
|
let remoteInterface =
|
|
MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account))
|
|
remoteInterface.capabilities = mockCapabilities
|
|
|
|
// Setup a change scenario
|
|
var fileToUpdate =
|
|
SendableItemMetadata(ocId: "item1", fileName: "file.txt", account: Self.account)
|
|
fileToUpdate.downloaded = true
|
|
fileToUpdate.etag = "ETAG_OLD"
|
|
Self.dbManager.addItemMetadata(fileToUpdate)
|
|
let serverItem = MockRemoteItem(
|
|
identifier: "item1",
|
|
versionIdentifier: "ETAG_NEW",
|
|
name: "file.txt",
|
|
remotePath: Self.account.davFilesUrl + "/file.txt",
|
|
account: Self.account.ncKitAccount,
|
|
username: Self.account.username,
|
|
userId: Self.account.id,
|
|
serverUrl: Self.account.serverUrl
|
|
)
|
|
remoteInterface.rootItem?.children = [serverItem]
|
|
|
|
let change1 = XCTestExpectation(description: "First change notification")
|
|
let change2 = XCTestExpectation(description: "Second change notification")
|
|
|
|
let fulfillments = ExpectationFulfillmentCounter(change1, change2)
|
|
|
|
let notificationInterface = MockChangeNotificationInterface {
|
|
Task {
|
|
await fulfillments.next()
|
|
}
|
|
}
|
|
|
|
let remoteChangeObserver = RemoteChangeObserver(
|
|
account: Self.account,
|
|
remoteInterface: remoteInterface,
|
|
changeNotificationInterface: notificationInterface,
|
|
domain: nil,
|
|
dbManager: Self.dbManager,
|
|
log: FileProviderLogMock()
|
|
)
|
|
self.remoteChangeObserver = remoteChangeObserver
|
|
|
|
// --- Phase 1: Test connection and change notification ---
|
|
let authExpectation = XCTNSNotificationExpectation(name: NotifyPushAuthenticatedNotificationName)
|
|
remoteChangeObserver.networkReachabilityObserver(.reachableEthernetOrWiFi)
|
|
await wait(for: authExpectation, description: "initial authentication")
|
|
|
|
Self.notifyPushServer.send(message: "notify_file")
|
|
await wait(for: change1, description: "first change")
|
|
|
|
// --- Phase 2: Test connection loss ---
|
|
remoteChangeObserver.networkReachabilityObserver(.notReachable)
|
|
// Give it a moment to process the disconnection
|
|
try await Task.sleep(nanoseconds: 200_000_000)
|
|
let webSocketTaskActive = remoteChangeObserver.webSocketTaskActive
|
|
|
|
XCTAssertFalse(webSocketTaskActive, "Websocket should be inactive after connection loss.")
|
|
Self.notifyPushServer.reset()
|
|
|
|
// --- Phase 3: Test reconnection and change notification ---
|
|
let reauthExpectation = XCTNSNotificationExpectation(name: NotifyPushAuthenticatedNotificationName)
|
|
|
|
// Trigger the reconnection logic.
|
|
remoteChangeObserver.networkReachabilityObserver(.reachableEthernetOrWiFi)
|
|
|
|
// Now, wait for the expectation to be fulfilled.
|
|
await wait(for: reauthExpectation, description: "re-authentication")
|
|
let webSocketTaskActiveAfterReconnect = remoteChangeObserver.webSocketTaskActive
|
|
|
|
XCTAssertTrue(webSocketTaskActiveAfterReconnect, "Websocket should be active again after reconnection.")
|
|
|
|
Self.notifyPushServer.send(message: "notify_file")
|
|
await wait(for: change2, description: "second change")
|
|
}
|
|
}
|