-
Notifications
You must be signed in to change notification settings - Fork 27
/
HistorySynchronizerTests.swift
240 lines (198 loc) · 8.62 KB
/
HistorySynchronizerTests.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
/* 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
private let log = Logger.syncLogger
class MockSyncDelegate: SyncDelegate {
func displaySentTabForURL(URL: URL, title: String) {
}
}
class DBPlace: Place {
var isDeleted = false
var shouldUpload = false
var serverModified: Timestamp? = nil
var localModified: Timestamp? = nil
}
class MockSyncableHistory {
var wasReset: Bool = false
var places = [GUID: DBPlace]()
var remoteVisits = [GUID: Set<Visit>]()
var localVisits = [GUID: Set<Visit>]()
init() {
}
private 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.removeValueForKey(guid)
self.localVisits.removeValueForKey(guid)
self.places.removeValueForKey(guid)
return succeed()
}
func hasSyncedHistory() -> Deferred<Maybe<Bool>> {
let has = self.places.values.contains({ $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) {
let p = DBPlace(guid: guid, url: url, title: existing.title)
p.isDeleted = existing.isDeleted
p.serverModified = existing.serverModified
p.localModified = existing.localModified
self.places.removeValueForKey(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.subtractInPlace(local)
}
// Visits are only ever added.
if var r = self.remoteVisits[guid] {
r.unionInPlace(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); remote: \(modified).")
// Should always be a value if marked as changed.
if existingLocal.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 = NSDate.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: protocol<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)
let expectation = expectationWithDescription("Waiting for application.")
var succeeded = false
synchronizer.applyIncomingToStorage(storage, records: records)
.upon({ result in
succeeded = result.isSuccess
expectation.fulfill()
})
waitForExpectationsWithTimeout(10, handler: nil)
XCTAssertTrue(succeeded, "Application succeeded.")
return (synchronizer, prefs, scratchpad)
}
func testApplyRecords() {
let earliest = NSDate.now()
let empty = MockSyncableHistory()
let noRecords = [Record<HistoryPayload>]()
// Apply no records.
self.applyRecords(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.parse(jA))!
let rA = Record<HistoryPayload>(id: "aaaaaa", payload: pA, modified: earliest + 10000, sortindex: 123, ttl: 1000000)
let (_, prefs, _) = self.applyRecords([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)
}
}