mirror of
https://repo.dactyloidae.xyz/Dactyloidae/UXP.git
synced 2026-09-07 16:28:38 +09:00
269 lines
10 KiB
Swift
269 lines
10 KiB
Swift
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
import Shared
|
|
import Storage
|
|
@testable import Sync
|
|
import XCGLogger
|
|
import Deferred
|
|
|
|
import XCTest
|
|
import SwiftyJSON
|
|
|
|
private let log = Logger.syncLogger
|
|
|
|
class MockSyncDelegate: SyncDelegate {
|
|
func displaySentTab(for url: URL, title: String, from deviceName: String?) {
|
|
}
|
|
}
|
|
|
|
class DBPlace: Place {
|
|
var isDeleted = false
|
|
var shouldUpload = false
|
|
var serverModified: Timestamp?
|
|
var localModified: Timestamp?
|
|
}
|
|
|
|
class MockSyncableHistory {
|
|
var wasReset: Bool = false
|
|
var places = [GUID: DBPlace]()
|
|
var remoteVisits = [GUID: Set<Visit>]()
|
|
var localVisits = [GUID: Set<Visit>]()
|
|
|
|
init() {
|
|
}
|
|
|
|
fileprivate func placeForURL(url: String) -> DBPlace? {
|
|
return findOneValue(places) { $0.url == url }
|
|
}
|
|
}
|
|
|
|
extension MockSyncableHistory: ResettableSyncStorage {
|
|
func resetClient() -> Success {
|
|
self.wasReset = true
|
|
return succeed()
|
|
}
|
|
}
|
|
|
|
extension MockSyncableHistory: SyncableHistory {
|
|
// TODO: consider comparing the timestamp to local visits, perhaps opting to
|
|
// not delete the local place (and instead to give it a new GUID) if the visits
|
|
// are newer than the deletion.
|
|
// Obviously this'll behave badly during reconciling on other devices:
|
|
// they might apply our new record first, renaming their local copy of
|
|
// the old record with that URL, and thus bring all the old visits back to life.
|
|
// Desktop just finds by GUID then deletes by URL.
|
|
func deleteByGUID(_ guid: GUID, deletedAt: Timestamp) -> Deferred<Maybe<()>> {
|
|
self.remoteVisits.removeValue(forKey: guid)
|
|
self.localVisits.removeValue(forKey: guid)
|
|
self.places.removeValue(forKey: guid)
|
|
|
|
return succeed()
|
|
}
|
|
|
|
func hasSyncedHistory() -> Deferred<Maybe<Bool>> {
|
|
let has = self.places.values.contains(where: { $0.serverModified != nil })
|
|
return deferMaybe(has)
|
|
}
|
|
|
|
/**
|
|
* This assumes that the provided GUID doesn't already map to a different URL!
|
|
*/
|
|
func ensurePlaceWithURL(_ url: String, hasGUID guid: GUID) -> Success {
|
|
// Find by URL.
|
|
if let existing = self.placeForURL(url: url) {
|
|
let p = DBPlace(guid: guid, url: url, title: existing.title)
|
|
p.isDeleted = existing.isDeleted
|
|
p.serverModified = existing.serverModified
|
|
p.localModified = existing.localModified
|
|
self.places.removeValue(forKey: existing.guid)
|
|
self.places[guid] = p
|
|
}
|
|
|
|
return succeed()
|
|
}
|
|
|
|
func storeRemoteVisits(_ visits: [Visit], forGUID guid: GUID) -> Success {
|
|
// Strip out existing local visits.
|
|
// We trust that an identical timestamp and type implies an identical visit.
|
|
var remote = Set<Visit>(visits)
|
|
if let local = self.localVisits[guid] {
|
|
remote.subtract(local)
|
|
}
|
|
|
|
// Visits are only ever added.
|
|
if var r = self.remoteVisits[guid] {
|
|
r.formUnion(remote)
|
|
} else {
|
|
self.remoteVisits[guid] = remote
|
|
}
|
|
return succeed()
|
|
}
|
|
|
|
func insertOrUpdatePlace(_ place: Place, modified: Timestamp) -> Deferred<Maybe<GUID>> {
|
|
// See if we've already applied this one.
|
|
if let existingModified = self.places[place.guid]?.serverModified {
|
|
if existingModified == modified {
|
|
log.debug("Already seen unchanged record \(place.guid).")
|
|
return deferMaybe(place.guid)
|
|
}
|
|
}
|
|
|
|
// Make sure that we collide with any matching URLs -- whether locally
|
|
// modified or not. Then overwrite the upstream and merge any local changes.
|
|
return self.ensurePlaceWithURL(place.url, hasGUID: place.guid)
|
|
>>> {
|
|
if let existingLocal = self.places[place.guid] {
|
|
if existingLocal.shouldUpload {
|
|
log.debug("Record \(existingLocal.guid) modified locally and remotely.")
|
|
log.debug("Local modified: \(existingLocal.localModified ??? "nil"); remote: \(modified).")
|
|
|
|
// Should always be a value if marked as changed.
|
|
if let localModified = existingLocal.localModified, localModified > modified {
|
|
// Nothing to do: it's marked as changed.
|
|
log.debug("Discarding remote non-visit changes!")
|
|
self.places[place.guid]?.serverModified = modified
|
|
return deferMaybe(place.guid)
|
|
} else {
|
|
log.debug("Discarding local non-visit changes!")
|
|
self.places[place.guid]?.shouldUpload = false
|
|
}
|
|
} else {
|
|
log.debug("Remote record exists, but has no local changes.")
|
|
}
|
|
} else {
|
|
log.debug("Remote record doesn't exist locally.")
|
|
}
|
|
|
|
// Apply the new remote record.
|
|
let p = DBPlace(guid: place.guid, url: place.url, title: place.title)
|
|
p.localModified = Date.now()
|
|
p.serverModified = modified
|
|
p.isDeleted = false
|
|
self.places[place.guid] = p
|
|
return deferMaybe(place.guid)
|
|
}
|
|
}
|
|
|
|
func getModifiedHistoryToUpload() -> Deferred<Maybe<[(Place, [Visit])]>> {
|
|
// TODO.
|
|
return deferMaybe([])
|
|
}
|
|
|
|
func getDeletedHistoryToUpload() -> Deferred<Maybe<[GUID]>> {
|
|
// TODO.
|
|
return deferMaybe([])
|
|
}
|
|
|
|
func markAsSynchronized(_: [GUID], modified: Timestamp) -> Deferred<Maybe<Timestamp>> {
|
|
// TODO
|
|
return deferMaybe(0)
|
|
}
|
|
|
|
func markAsDeleted(_: [GUID]) -> Success {
|
|
// TODO
|
|
return succeed()
|
|
}
|
|
|
|
func onRemovedAccount() -> Success {
|
|
// TODO
|
|
return succeed()
|
|
}
|
|
|
|
func doneApplyingRecordsAfterDownload() -> Success {
|
|
return succeed()
|
|
}
|
|
|
|
func doneUpdatingMetadataAfterUpload() -> Success {
|
|
return succeed()
|
|
}
|
|
}
|
|
|
|
class HistorySynchronizerTests: XCTestCase {
|
|
private func applyRecords(records: [Record<HistoryPayload>], toStorage storage: SyncableHistory & ResettableSyncStorage) -> (synchronizer: HistorySynchronizer, prefs: Prefs, scratchpad: Scratchpad) {
|
|
let delegate = MockSyncDelegate()
|
|
|
|
// We can use these useless values because we're directly injecting decrypted
|
|
// payloads; no need for real keys etc.
|
|
let prefs = MockProfilePrefs()
|
|
let scratchpad = Scratchpad(b: KeyBundle.random(), persistingTo: prefs)
|
|
|
|
let synchronizer = HistorySynchronizer(scratchpad: scratchpad, delegate: delegate, basePrefs: prefs, why: .scheduled)
|
|
|
|
let expectation = self.expectation(description: "Waiting for application.")
|
|
var succeeded = false
|
|
synchronizer.applyIncomingToStorage(storage, records: records)
|
|
.upon({ result in
|
|
succeeded = result.isSuccess
|
|
expectation.fulfill()
|
|
})
|
|
|
|
waitForExpectations(timeout: 10, handler: nil)
|
|
XCTAssertTrue(succeeded, "Application succeeded.")
|
|
return (synchronizer, prefs, scratchpad)
|
|
}
|
|
|
|
func testRecordSerialization() {
|
|
let id = "abcdefghi"
|
|
let modified: Timestamp = 0 // Ignored in upload serialization.
|
|
let sortindex = 1
|
|
let ttl = 12345
|
|
let json: JSON = JSON([
|
|
"id": id,
|
|
"visits": [],
|
|
"histUri": "http://www.slideshare.net/swadpasc/bpm-edu-netseminarscepwithreactionrulemlprova",
|
|
"title": "Semantic Complex Event Processing with \(Character(UnicodeScalar(11)))Reaction RuleML 1.0 and Prova",
|
|
])
|
|
let payload = HistoryPayload(json)
|
|
let record = Record<HistoryPayload>(id: id, payload: payload, modified: modified, sortindex: sortindex, ttl: ttl)
|
|
let k = KeyBundle.random()
|
|
let s = k.serializer({ (x: HistoryPayload) -> JSON in x.json })
|
|
let converter = { (x: JSON) -> HistoryPayload in HistoryPayload(x) }
|
|
let f = k.factory(converter)
|
|
let serialized = s(record)!
|
|
let envelope = EnvelopeJSON(serialized)
|
|
|
|
// With a badly serialized payload, we get null JSON!
|
|
let p = f(envelope.payload)
|
|
XCTAssertFalse(p!.json.isNull())
|
|
|
|
// When we round-trip, the payload should be valid, and we'll get a record here.
|
|
let roundtripped = Record<HistoryPayload>.fromEnvelope(envelope, payloadFactory: f)
|
|
XCTAssertNotNil(roundtripped)
|
|
}
|
|
|
|
func testApplyRecords() {
|
|
let earliest = Date.now()
|
|
|
|
let empty = MockSyncableHistory()
|
|
let noRecords = [Record<HistoryPayload>]()
|
|
|
|
// Apply no records.
|
|
let _ = self.applyRecords(records: noRecords, toStorage: empty)
|
|
|
|
// Hey look! Nothing changed.
|
|
XCTAssertTrue(empty.places.isEmpty)
|
|
XCTAssertTrue(empty.remoteVisits.isEmpty)
|
|
XCTAssertTrue(empty.localVisits.isEmpty)
|
|
|
|
// Apply one remote record.
|
|
let jA = "{\"id\":\"aaaaaa\",\"histUri\":\"http://foo.com/\",\"title\": \"ñ\",\"visits\":[{\"date\":1222222222222222,\"type\":1}]}"
|
|
let pA = HistoryPayload.fromJSON(JSON(parseJSON: jA))!
|
|
let rA = Record<HistoryPayload>(id: "aaaaaa", payload: pA, modified: earliest + 10000, sortindex: 123, ttl: 1000000)
|
|
|
|
let (_, prefs, _) = self.applyRecords(records: [rA], toStorage: empty)
|
|
|
|
// The record was stored. This is checking our mock implementation, but real storage should work, too!
|
|
|
|
XCTAssertEqual(1, empty.places.count)
|
|
XCTAssertEqual(1, empty.remoteVisits.count)
|
|
XCTAssertEqual(1, empty.remoteVisits["aaaaaa"]!.count)
|
|
XCTAssertTrue(empty.localVisits.isEmpty)
|
|
|
|
// Test resetting now that we have a timestamp.
|
|
XCTAssertFalse(empty.wasReset)
|
|
XCTAssertTrue(HistorySynchronizer.resetSynchronizerWithStorage(empty, basePrefs: prefs, collection: "history").value.isSuccess)
|
|
XCTAssertTrue(empty.wasReset)
|
|
}
|
|
}
|