www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | Submodules | README | LICENSE

storageLocalTest.js (23884B)


      1 "use strict";
      2 
      3 describe("Zotero.Sync.Storage.Local", function () {
      4 	beforeEach(function* () {
      5 		yield resetDB({
      6 			thisArg: this,
      7 			skipBundledFiles: true
      8 		})
      9 	})
     10 	
     11 	
     12 	describe("#checkForUpdatedFiles()", function () {
     13 		it("should flag modified file for upload and return it", function* () {
     14 			// Create attachment
     15 			let item = yield importTextAttachment();
     16 			var hash = yield item.attachmentHash;
     17 			// Set file mtime to the past (without milliseconds, which aren't used on OS X)
     18 			var mtime = (Math.floor(new Date().getTime() / 1000) * 1000) - 1000;
     19 			yield OS.File.setDates((yield item.getFilePathAsync()), null, mtime);
     20 			
     21 			// Mark as synced, so it will be checked
     22 			item.attachmentSyncedModificationTime = mtime;
     23 			item.attachmentSyncedHash = hash;
     24 			item.attachmentSyncState = "in_sync";
     25 			yield item.saveTx({ skipAll: true });
     26 			
     27 			// Update mtime and contents
     28 			var path = yield item.getFilePathAsync();
     29 			yield OS.File.setDates(path);
     30 			yield Zotero.File.putContentsAsync(path, Zotero.Utilities.randomString());
     31 			
     32 			// File should be returned
     33 			var libraryID = Zotero.Libraries.userLibraryID;
     34 			var changed = yield Zotero.Sync.Storage.Local.checkForUpdatedFiles(libraryID);
     35 			
     36 			yield item.eraseTx();
     37 			
     38 			assert.equal(changed, true);
     39 			assert.equal(item.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_TO_UPLOAD);
     40 		})
     41 		
     42 		it("should skip a file if mod time hasn't changed", function* () {
     43 			// Create attachment
     44 			let item = yield importTextAttachment();
     45 			var hash = yield item.attachmentHash;
     46 			var mtime = yield item.attachmentModificationTime;
     47 			
     48 			// Mark as synced, so it will be checked
     49 			item.attachmentSyncedModificationTime = mtime;
     50 			item.attachmentSyncedHash = hash;
     51 			item.attachmentSyncState = "in_sync";
     52 			yield item.saveTx({ skipAll: true });
     53 			
     54 			var libraryID = Zotero.Libraries.userLibraryID;
     55 			var changed = yield Zotero.Sync.Storage.Local.checkForUpdatedFiles(libraryID);
     56 			var syncState = item.attachmentSyncState;
     57 			
     58 			yield item.eraseTx();
     59 			
     60 			assert.isFalse(changed);
     61 			assert.equal(syncState, Zotero.Sync.Storage.Local.SYNC_STATE_IN_SYNC);
     62 		})
     63 		
     64 		it("should skip a file if mod time has changed but contents haven't", function* () {
     65 			// Create attachment
     66 			let item = yield importTextAttachment();
     67 			var hash = yield item.attachmentHash;
     68 			// Set file mtime to the past (without milliseconds, which aren't used on OS X)
     69 			var mtime = (Math.floor(new Date().getTime() / 1000) * 1000) - 1000;
     70 			yield OS.File.setDates((yield item.getFilePathAsync()), null, mtime);
     71 			
     72 			// Mark as synced, so it will be checked
     73 			item.attachmentSyncedModificationTime = mtime;
     74 			item.attachmentSyncedHash = hash;
     75 			item.attachmentSyncState = "in_sync";
     76 			yield item.saveTx({ skipAll: true });
     77 			
     78 			// Update mtime, but not contents
     79 			var path = yield item.getFilePathAsync();
     80 			yield OS.File.setDates(path);
     81 			
     82 			var libraryID = Zotero.Libraries.userLibraryID;
     83 			var changed = yield Zotero.Sync.Storage.Local.checkForUpdatedFiles(libraryID);
     84 			var syncState = item.attachmentSyncState;
     85 			var syncedModTime = item.attachmentSyncedModificationTime;
     86 			var newModTime = yield item.attachmentModificationTime;
     87 			
     88 			yield item.eraseTx();
     89 			
     90 			assert.isFalse(changed);
     91 			assert.equal(syncState, Zotero.Sync.Storage.Local.SYNC_STATE_IN_SYNC);
     92 			assert.equal(syncedModTime, newModTime);
     93 		})
     94 	})
     95 	
     96 	describe("#updateSyncStates()", function () {
     97 		it("should update attachment sync states to 'to_upload'", function* () {
     98 			var attachment1 = yield importFileAttachment('test.png');
     99 			attachment1.attachmentSyncState = 'in_sync';
    100 			yield attachment1.saveTx();
    101 			var attachment2 = yield importFileAttachment('test.png');
    102 			attachment2.attachmentSyncState = 'in_sync';
    103 			yield attachment2.saveTx();
    104 			
    105 			var local = Zotero.Sync.Storage.Local;
    106 			yield local.updateSyncStates([attachment1, attachment2], 'to_upload');
    107 			
    108 			for (let attachment of [attachment1, attachment2]) {
    109 				assert.strictEqual(attachment.attachmentSyncState, local.SYNC_STATE_TO_UPLOAD);
    110 				let state = yield Zotero.DB.valueQueryAsync(
    111 					"SELECT syncState FROM itemAttachments WHERE itemID=?", attachment.id
    112 				);
    113 				assert.strictEqual(state, local.SYNC_STATE_TO_UPLOAD);
    114 			}
    115 		});
    116 	});
    117 	
    118 	describe("#resetAllSyncStates()", function () {
    119 		it("should reset attachment sync states to 'to_upload'", function* () {
    120 			var attachment = yield importFileAttachment('test.png');
    121 			attachment.attachmentSyncState = 'in_sync';
    122 			yield attachment.saveTx();
    123 			
    124 			var local = Zotero.Sync.Storage.Local;
    125 			yield local.resetAllSyncStates(attachment.libraryID)
    126 			assert.strictEqual(attachment.attachmentSyncState, local.SYNC_STATE_TO_UPLOAD);
    127 			var state = yield Zotero.DB.valueQueryAsync(
    128 				"SELECT syncState FROM itemAttachments WHERE itemID=?", attachment.id
    129 			);
    130 			assert.strictEqual(state, local.SYNC_STATE_TO_UPLOAD);
    131 		});
    132 	});
    133 	
    134 	describe("#processDownload()", function () {
    135 		describe("single file", function () {
    136 			it("should download a single file into the attachment directory", function* () {
    137 				var libraryID = Zotero.Libraries.userLibraryID;
    138 				var parentItem = yield createDataObject('item');
    139 				var key = Zotero.DataObjectUtilities.generateKey();
    140 				var fileContents = Zotero.Utilities.randomString();
    141 				
    142 				var oldFilename = "Old File";
    143 				var tmpDir = Zotero.getTempDirectory().path;
    144 				var tmpFile = OS.Path.join(tmpDir, key + '.tmp');
    145 				yield Zotero.File.putContentsAsync(tmpFile, fileContents);
    146 				
    147 				// Create an existing attachment directory to replace
    148 				var dir = Zotero.Attachments.getStorageDirectoryByLibraryAndKey(libraryID, key).path;
    149 				yield OS.File.makeDir(
    150 					dir,
    151 					{
    152 						unixMode: 0o755
    153 					}
    154 				);
    155 				yield Zotero.File.putContentsAsync(OS.Path.join(dir, oldFilename), '');
    156 				
    157 				var md5 = Zotero.Utilities.Internal.md5(Zotero.File.pathToFile(tmpFile));
    158 				var mtime = 1445667239000;
    159 				
    160 				var json = {
    161 					key,
    162 					version: 10,
    163 					itemType: 'attachment',
    164 					linkMode: 'imported_url',
    165 					url: 'https://example.com/foo.txt',
    166 					filename: 'foo.txt',
    167 					contentType: 'text/plain',
    168 					charset: 'utf-8',
    169 					md5,
    170 					mtime
    171 				};
    172 				yield Zotero.Sync.Data.Local.processObjectsFromJSON('item', libraryID, [json]);
    173 				
    174 				var item = yield Zotero.Items.getByLibraryAndKeyAsync(libraryID, key);
    175 				yield Zotero.Sync.Storage.Local.processDownload({
    176 					item,
    177 					md5,
    178 					mtime
    179 				});
    180 				yield OS.File.remove(tmpFile);
    181 				
    182 				var storageDir = Zotero.Attachments.getStorageDirectory(item).path;
    183 				
    184 				// Make sure previous files don't exist
    185 				assert.isFalse(yield OS.File.exists(OS.Path.join(storageDir, oldFilename)));
    186 				
    187 				// Make sure main file matches attachment hash and mtime
    188 				yield assert.eventually.equal(
    189 					item.attachmentHash, Zotero.Utilities.Internal.md5(fileContents)
    190 				);
    191 				yield assert.eventually.equal(item.attachmentModificationTime, mtime);
    192 			});
    193 			
    194 			
    195 			it("should download and rename a single file with invalid filename into the attachment directory", function* () {
    196 				var libraryID = Zotero.Libraries.userLibraryID;
    197 				var parentItem = yield createDataObject('item');
    198 				var key = Zotero.DataObjectUtilities.generateKey();
    199 				var fileContents = Zotero.Utilities.randomString();
    200 				
    201 				var oldFilename = "Old File";
    202 				var newFilename = " ab — c \\:.txt.";
    203 				var filteredFilename = " ab — c .txt.";
    204 				var tmpDir = Zotero.getTempDirectory().path;
    205 				var tmpFile = OS.Path.join(tmpDir, key + '.tmp');
    206 				yield Zotero.File.putContentsAsync(tmpFile, fileContents);
    207 				
    208 				// Create an existing attachment directory to replace
    209 				var dir = Zotero.Attachments.getStorageDirectoryByLibraryAndKey(libraryID, key).path;
    210 				yield OS.File.makeDir(
    211 					dir,
    212 					{
    213 						unixMode: 0o755
    214 					}
    215 				);
    216 				yield Zotero.File.putContentsAsync(OS.Path.join(dir, oldFilename), '');
    217 				
    218 				var md5 = Zotero.Utilities.Internal.md5(Zotero.File.pathToFile(tmpFile));
    219 				var mtime = 1445667239000;
    220 				
    221 				var json = {
    222 					key,
    223 					version: 10,
    224 					itemType: 'attachment',
    225 					linkMode: 'imported_url',
    226 					url: 'https://example.com/foo.txt',
    227 					filename: newFilename,
    228 					contentType: 'text/plain',
    229 					charset: 'utf-8',
    230 					md5,
    231 					mtime
    232 				};
    233 				yield Zotero.Sync.Data.Local.processObjectsFromJSON('item', libraryID, [json]);
    234 				
    235 				var item = yield Zotero.Items.getByLibraryAndKeyAsync(libraryID, key);
    236 				yield Zotero.Sync.Storage.Local.processDownload({
    237 					item,
    238 					md5,
    239 					mtime
    240 				});
    241 				yield OS.File.remove(tmpFile);
    242 				
    243 				var storageDir = Zotero.Attachments.getStorageDirectory(item).path;
    244 				
    245 				// Make sure previous file doesn't exist
    246 				assert.isFalse(yield OS.File.exists(OS.Path.join(storageDir, oldFilename)));
    247 				// And new one does
    248 				assert.isTrue(yield OS.File.exists(OS.Path.join(storageDir, filteredFilename)));
    249 				
    250 				// Make sure main file matches attachment hash and mtime
    251 				yield assert.eventually.equal(
    252 					item.attachmentHash, Zotero.Utilities.Internal.md5(fileContents)
    253 				);
    254 				yield assert.eventually.equal(item.attachmentModificationTime, mtime);
    255 			});
    256 			
    257 			
    258 			it("should download and rename a single file with invalid filename using Windows parsing rules into the attachment directory", function* () {
    259 				var libraryID = Zotero.Libraries.userLibraryID;
    260 				var parentItem = yield createDataObject('item');
    261 				var key = Zotero.DataObjectUtilities.generateKey();
    262 				var fileContents = Zotero.Utilities.randomString();
    263 				
    264 				var oldFilename = "Old File";
    265 				var newFilename = "a:b.txt";
    266 				var filteredFilename = "ab.txt";
    267 				var tmpDir = Zotero.getTempDirectory().path;
    268 				var tmpFile = OS.Path.join(tmpDir, key + '.tmp');
    269 				yield Zotero.File.putContentsAsync(tmpFile, fileContents);
    270 				
    271 				// Create an existing attachment directory to replace
    272 				var dir = Zotero.Attachments.getStorageDirectoryByLibraryAndKey(libraryID, key).path;
    273 				yield OS.File.makeDir(
    274 					dir,
    275 					{
    276 						unixMode: 0o755
    277 					}
    278 				);
    279 				yield Zotero.File.putContentsAsync(OS.Path.join(dir, oldFilename), '');
    280 				
    281 				var md5 = Zotero.Utilities.Internal.md5(Zotero.File.pathToFile(tmpFile));
    282 				var mtime = 1445667239000;
    283 				
    284 				var json = {
    285 					key,
    286 					version: 10,
    287 					itemType: 'attachment',
    288 					linkMode: 'imported_url',
    289 					url: 'https://example.com/foo.txt',
    290 					filename: 'a:b.txt',
    291 					contentType: 'text/plain',
    292 					charset: 'utf-8',
    293 					md5,
    294 					mtime
    295 				};
    296 				yield Zotero.Sync.Data.Local.processObjectsFromJSON('item', libraryID, [json]);
    297 				
    298 				var item = yield Zotero.Items.getByLibraryAndKeyAsync(libraryID, key);
    299 				
    300 				// Stub functions to simulate OS.Path.basename() behavior on Windows
    301 				var basenameOrigFunc = OS.Path.basename.bind(OS.Path);
    302 				var basenameStub = sinon.stub(OS.Path, "basename").callsFake((path) => {
    303 					// Split on colon
    304 					if (path.endsWith("a:b.txt")) {
    305 						return "b.txt";
    306 					}
    307 					return basenameOrigFunc(path);
    308 				});
    309 				var pathToFileOrigFunc = Zotero.File.pathToFile.bind(Zotero.File);
    310 				var pathToFileStub = sinon.stub(Zotero.File, "pathToFile").callsFake((path) => {
    311 					if (path.includes(":")) {
    312 						throw new Error("Path contains colon");
    313 					}
    314 					return pathToFileOrigFunc(path);
    315 				});
    316 				
    317 				yield Zotero.Sync.Storage.Local.processDownload({
    318 					item,
    319 					md5,
    320 					mtime
    321 				});
    322 				yield OS.File.remove(tmpFile);
    323 				
    324 				var storageDir = Zotero.Attachments.getStorageDirectory(item).path;
    325 				
    326 				basenameStub.restore();
    327 				pathToFileStub.restore();
    328 				
    329 				// Make sure path is set correctly
    330 				assert.equal(item.getFilePath(), OS.Path.join(storageDir, filteredFilename));
    331 				// Make sure previous files don't exist
    332 				assert.isFalse(yield OS.File.exists(OS.Path.join(storageDir, oldFilename)));
    333 				// And new one does
    334 				assert.isTrue(yield OS.File.exists(OS.Path.join(storageDir, filteredFilename)));
    335 				
    336 				// Make sure main file matches attachment hash and mtime
    337 				yield assert.eventually.equal(
    338 					item.attachmentHash, Zotero.Utilities.Internal.md5(fileContents)
    339 				);
    340 				yield assert.eventually.equal(item.attachmentModificationTime, mtime);
    341 			});
    342 		});
    343 		
    344 		describe("ZIP", function () {
    345 			it("should download and extract a ZIP file into the attachment directory", function* () {
    346 				var file1Name = 'index.html';
    347 				var file1Contents = '<html><body>Test</body></html>';
    348 				var file2Name = 'aux1.txt';
    349 				var file2Contents = 'Test 1';
    350 				var subDirName = 'sub';
    351 				var file3Name = 'aux2';
    352 				var file3Contents = 'Test 2';
    353 				
    354 				var libraryID = Zotero.Libraries.userLibraryID;
    355 				var parentItem = yield createDataObject('item');
    356 				var key = Zotero.DataObjectUtilities.generateKey();
    357 				
    358 				var tmpDir = Zotero.getTempDirectory().path;
    359 				var zipFile = OS.Path.join(tmpDir, key + '.tmp');
    360 				
    361 				// Create ZIP file with subdirectory
    362 				var tmpDir = Zotero.getTempDirectory().path;
    363 				var zipDir = yield getTempDirectory();
    364 				yield Zotero.File.putContentsAsync(OS.Path.join(zipDir, file1Name), file1Contents);
    365 				yield Zotero.File.putContentsAsync(OS.Path.join(zipDir, file2Name), file2Contents);
    366 				var subDir = OS.Path.join(zipDir, subDirName);
    367 				yield OS.File.makeDir(subDir);
    368 				yield Zotero.File.putContentsAsync(OS.Path.join(subDir, file3Name), file3Contents);
    369 				yield Zotero.File.zipDirectory(zipDir, zipFile);
    370 				yield removeDir(zipDir);
    371 				
    372 				// Create an existing attachment directory (and subdirectory) to replace
    373 				var dir = Zotero.Attachments.getStorageDirectoryByLibraryAndKey(libraryID, key).path;
    374 				yield OS.File.makeDir(
    375 					OS.Path.join(dir, 'subdir'),
    376 					{
    377 						from: Zotero.DataDirectory.dir,
    378 						unixMode: 0o755
    379 					}
    380 				);
    381 				yield Zotero.File.putContentsAsync(OS.Path.join(dir, 'A'), '');
    382 				yield Zotero.File.putContentsAsync(OS.Path.join(dir, 'subdir', 'B'), '');
    383 				
    384 				var md5 = Zotero.Utilities.Internal.md5(Zotero.File.pathToFile(zipFile));
    385 				var mtime = 1445667239000;
    386 				
    387 				var json = {
    388 					key,
    389 					version: 10,
    390 					itemType: 'attachment',
    391 					linkMode: 'imported_url',
    392 					url: 'https://example.com',
    393 					filename: file1Name,
    394 					contentType: 'text/html',
    395 					charset: 'utf-8',
    396 					md5,
    397 					mtime
    398 				};
    399 				yield Zotero.Sync.Data.Local.processObjectsFromJSON('item', libraryID, [json]);
    400 				
    401 				var item = yield Zotero.Items.getByLibraryAndKeyAsync(libraryID, key);
    402 				yield Zotero.Sync.Storage.Local.processDownload({
    403 					item,
    404 					md5,
    405 					mtime,
    406 					compressed: true
    407 				});
    408 				yield OS.File.remove(zipFile);
    409 				
    410 				var storageDir = Zotero.Attachments.getStorageDirectory(item).path;
    411 				
    412 				// Make sure previous files don't exist
    413 				assert.isFalse(yield OS.File.exists(OS.Path.join(storageDir, 'A')));
    414 				assert.isFalse(yield OS.File.exists(OS.Path.join(storageDir, 'subdir')));
    415 				assert.isFalse(yield OS.File.exists(OS.Path.join(storageDir, 'subdir', 'B')));
    416 				
    417 				// Make sure main file matches attachment hash and mtime
    418 				yield assert.eventually.equal(
    419 					item.attachmentHash, Zotero.Utilities.Internal.md5(file1Contents)
    420 				);
    421 				yield assert.eventually.equal(item.attachmentModificationTime, mtime);
    422 				
    423 				// Check second file
    424 				yield assert.eventually.equal(
    425 					Zotero.File.getContentsAsync(OS.Path.join(storageDir, file2Name)),
    426 					file2Contents
    427 				);
    428 				
    429 				// Check subdirectory and file
    430 				assert.isTrue((yield OS.File.stat(OS.Path.join(storageDir, subDirName))).isDir);
    431 				yield assert.eventually.equal(
    432 					Zotero.File.getContentsAsync(OS.Path.join(storageDir, subDirName, file3Name)),
    433 					file3Contents
    434 				);
    435 			});
    436 			
    437 			
    438 			it("should download and rename a ZIP file with invalid filename using Windows parsing rules into the attachment directory", function* () {
    439 				var libraryID = Zotero.Libraries.userLibraryID;
    440 				var parentItem = yield createDataObject('item');
    441 				var key = Zotero.DataObjectUtilities.generateKey();
    442 				
    443 				var oldFilename = "Old File";
    444 				var oldAuxFilename = "a.gif";
    445 				var newFilename = "a:b.html";
    446 				var fileContents = Zotero.Utilities.randomString();
    447 				var newAuxFilename = "b.gif";
    448 				var filteredFilename = "ab.html";
    449 				var tmpDir = Zotero.getTempDirectory().path;
    450 				var zipFile = OS.Path.join(tmpDir, key + '.tmp');
    451 				
    452 				// Create ZIP file
    453 				var tmpDir = Zotero.getTempDirectory().path;
    454 				var zipDir = yield getTempDirectory();
    455 				yield Zotero.File.putContentsAsync(OS.Path.join(zipDir, newFilename), fileContents);
    456 				yield Zotero.File.putContentsAsync(OS.Path.join(zipDir, newAuxFilename), '');
    457 				yield Zotero.File.zipDirectory(zipDir, zipFile);
    458 				yield removeDir(zipDir);
    459 				
    460 				// Create an existing attachment directory to replace
    461 				var dir = Zotero.Attachments.getStorageDirectoryByLibraryAndKey(libraryID, key).path;
    462 				yield OS.File.makeDir(
    463 					dir,
    464 					{
    465 						unixMode: 0o755
    466 					}
    467 				);
    468 				yield Zotero.File.putContentsAsync(OS.Path.join(dir, oldFilename), '');
    469 				yield Zotero.File.putContentsAsync(OS.Path.join(dir, oldAuxFilename), '');
    470 				
    471 				var md5 = Zotero.Utilities.Internal.md5(Zotero.File.pathToFile(zipFile));
    472 				var mtime = 1445667239000;
    473 				
    474 				var json = {
    475 					key,
    476 					version: 10,
    477 					itemType: 'attachment',
    478 					linkMode: 'imported_url',
    479 					url: 'https://example.com/foo.html',
    480 					filename: 'a:b.html',
    481 					contentType: 'text/plain',
    482 					charset: 'utf-8',
    483 					md5,
    484 					mtime
    485 				};
    486 				yield Zotero.Sync.Data.Local.processObjectsFromJSON('item', libraryID, [json]);
    487 				
    488 				var item = yield Zotero.Items.getByLibraryAndKeyAsync(libraryID, key);
    489 				
    490 				// Stub functions to simulate OS.Path.basename() behavior on Windows
    491 				var basenameOrigFunc = OS.Path.basename.bind(OS.Path);
    492 				var basenameStub = sinon.stub(OS.Path, "basename").callsFake((path) => {
    493 					// Split on colon
    494 					if (path.endsWith("a:b.html")) {
    495 						return "b.html";
    496 					}
    497 					return basenameOrigFunc(path);
    498 				});
    499 				var pathToFileOrigFunc = Zotero.File.pathToFile.bind(Zotero.File);
    500 				var pathToFileStub = sinon.stub(Zotero.File, "pathToFile").callsFake((path) => {
    501 					if (path.includes(":")) {
    502 						throw new Error("Path contains colon");
    503 					}
    504 					return pathToFileOrigFunc(path);
    505 				});
    506 				
    507 				yield Zotero.Sync.Storage.Local.processDownload({
    508 					item,
    509 					md5,
    510 					mtime,
    511 					compressed: true
    512 				});
    513 				yield OS.File.remove(zipFile);
    514 				
    515 				var storageDir = Zotero.Attachments.getStorageDirectory(item).path;
    516 				
    517 				basenameStub.restore();
    518 				pathToFileStub.restore();
    519 				
    520 				// Make sure path is set correctly
    521 				assert.equal(item.getFilePath(), OS.Path.join(storageDir, filteredFilename));
    522 				// Make sure previous files don't exist
    523 				assert.isFalse(yield OS.File.exists(OS.Path.join(storageDir, oldFilename)));
    524 				assert.isFalse(yield OS.File.exists(OS.Path.join(storageDir, oldAuxFilename)));
    525 				// And new ones do
    526 				assert.isTrue(yield OS.File.exists(OS.Path.join(storageDir, filteredFilename)));
    527 				assert.isTrue(yield OS.File.exists(OS.Path.join(storageDir, newAuxFilename)));
    528 				
    529 				// Make sure main file matches attachment hash and mtime
    530 				yield assert.eventually.equal(
    531 					item.attachmentHash, Zotero.Utilities.Internal.md5(fileContents)
    532 				);
    533 				yield assert.eventually.equal(item.attachmentModificationTime, mtime);
    534 			});
    535 		});
    536 	})
    537 	
    538 	describe("#getConflicts()", function () {
    539 		it("should return an array of objects for attachments in conflict", function* () {
    540 			var libraryID = Zotero.Libraries.userLibraryID;
    541 			
    542 			var item1 = yield importFileAttachment('test.png');
    543 			item1.version = 10;
    544 			yield item1.saveTx();
    545 			var item2 = yield importTextAttachment();
    546 			var item3 = yield importHTMLAttachment();
    547 			item3.version = 11;
    548 			yield item3.saveTx();
    549 			
    550 			var json1 = item1.toJSON();
    551 			var json3 = item3.toJSON();
    552 			// Change remote mtimes
    553 			// Round to nearest second because OS X doesn't support ms resolution
    554 			var now = Math.round(new Date().getTime() / 1000) * 1000;
    555 			json1.mtime = now - 10000;
    556 			json3.mtime = now - 20000;
    557 			yield Zotero.Sync.Data.Local.saveCacheObjects('item', libraryID, [json1, json3]);
    558 			
    559 			item1.attachmentSyncState = "in_conflict";
    560 			yield item1.saveTx({ skipAll: true });
    561 			item3.attachmentSyncState = "in_conflict";
    562 			yield item3.saveTx({ skipAll: true });
    563 			
    564 			var conflicts = yield Zotero.Sync.Storage.Local.getConflicts(libraryID);
    565 			assert.lengthOf(conflicts, 2);
    566 			
    567 			var item1Conflict = conflicts.find(x => x.left.key == item1.key);
    568 			assert.equal(
    569 				item1Conflict.left.dateModified,
    570 				Zotero.Date.dateToISO(new Date(yield item1.attachmentModificationTime))
    571 			);
    572 			assert.equal(
    573 				item1Conflict.right.dateModified,
    574 				Zotero.Date.dateToISO(new Date(json1.mtime))
    575 			);
    576 			
    577 			var item3Conflict = conflicts.find(x => x.left.key == item3.key);
    578 			assert.equal(
    579 				item3Conflict.left.dateModified,
    580 				Zotero.Date.dateToISO(new Date(yield item3.attachmentModificationTime))
    581 			);
    582 			assert.equal(
    583 				item3Conflict.right.dateModified,
    584 				Zotero.Date.dateToISO(new Date(json3.mtime))
    585 			);
    586 		})
    587 	})
    588 	
    589 	describe("#resolveConflicts()", function () {
    590 		var win;
    591 		
    592 		before(function* () {
    593 			win = yield loadBrowserWindow();
    594 		});
    595 		
    596 		after(function () {
    597 			if (win) {
    598 				win.close();
    599 			}
    600 		});
    601 		
    602 		
    603 		it("should show the conflict resolution window on attachment conflicts", function* () {
    604 			var libraryID = Zotero.Libraries.userLibraryID;
    605 			
    606 			var item1 = yield importFileAttachment('test.png');
    607 			item1.version = 10;
    608 			yield item1.saveTx();
    609 			var item2 = yield importTextAttachment();
    610 			var item3 = yield importHTMLAttachment();
    611 			item3.version = 11;
    612 			yield item3.saveTx();
    613 			
    614 			var json1 = item1.toJSON();
    615 			var json3 = item3.toJSON();
    616 			// Change remote mtimes and hashes
    617 			json1.mtime = new Date().getTime() + 10000;
    618 			json1.md5 = 'f4ce1167f3a854896c257a0cc1ac387f';
    619 			json3.mtime = new Date().getTime() - 10000;
    620 			json3.md5 = 'fcd080b1c2cad562237823ec27671bbd';
    621 			yield Zotero.Sync.Data.Local.saveCacheObjects('item', libraryID, [json1, json3]);
    622 			
    623 			item1.attachmentSyncState = "in_conflict";
    624 			yield item1.saveTx({ skipAll: true });
    625 			item3.attachmentSyncState = "in_conflict";
    626 			yield item3.saveTx({ skipAll: true });
    627 			
    628 			var promise = waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
    629 				var doc = dialog.document;
    630 				var wizard = doc.documentElement;
    631 				var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
    632 				
    633 				// 1 (remote)
    634 				// Later remote version should be selected
    635 				assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
    636 				
    637 				// Check checkbox text
    638 				assert.equal(
    639 					doc.getElementById('resolve-all').label,
    640 					Zotero.getString('sync.conflict.resolveAllRemote')
    641 				);
    642 				
    643 				// Select local object
    644 				mergeGroup.leftpane.click();
    645 				assert.equal(mergeGroup.leftpane.getAttribute('selected'), 'true');
    646 				
    647 				wizard.getButton('next').click();
    648 				
    649 				// 2 (local)
    650 				// Later local version should be selected
    651 				assert.equal(mergeGroup.leftpane.getAttribute('selected'), 'true');
    652 				// Select remote object
    653 				mergeGroup.rightpane.click();
    654 				assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
    655 				
    656 				if (Zotero.isMac) {
    657 					assert.isTrue(wizard.getButton('next').hidden);
    658 					assert.isFalse(wizard.getButton('finish').hidden);
    659 				}
    660 				else {
    661 					// TODO
    662 				}
    663 				wizard.getButton('finish').click();
    664 			})
    665 			yield Zotero.Sync.Storage.Local.resolveConflicts(libraryID);
    666 			yield promise;
    667 			
    668 			assert.equal(item1.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_FORCE_UPLOAD);
    669 			assert.equal(item1.attachmentSyncedModificationTime, json1.mtime);
    670 			assert.equal(item1.attachmentSyncedHash, json1.md5);
    671 			assert.equal(item3.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_FORCE_DOWNLOAD);
    672 			assert.isNull(item3.attachmentSyncedModificationTime);
    673 			assert.isNull(item3.attachmentSyncedHash);
    674 		})
    675 	})
    676 })