www

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

itemTest.js (56703B)


      1 "use strict";
      2 
      3 describe("Zotero.Item", function () {
      4 	describe("#getField()", function () {
      5 		it("should return an empty string for valid unset fields on unsaved items", function () {
      6 			var item = new Zotero.Item('book');
      7 			assert.strictEqual(item.getField('rights'), "");
      8 		});
      9 		
     10 		it("should return an empty string for valid unset fields on unsaved items after setting on another field", function () {
     11 			var item = new Zotero.Item('book');
     12 			item.setField('title', 'foo');
     13 			assert.strictEqual(item.getField('rights'), "");
     14 		});
     15 		
     16 		it("should return an empty string for invalid unset fields on unsaved items after setting on another field", function () {
     17 			var item = new Zotero.Item('book');
     18 			item.setField('title', 'foo');
     19 			assert.strictEqual(item.getField('invalid'), "");
     20 		});
     21 		
     22 		it("should return a firstCreator for an unsaved item", function* () {
     23 			var item = createUnsavedDataObject('item');
     24 			item.setCreators([
     25 				{
     26 					firstName: "A",
     27 					lastName: "B",
     28 					creatorType: "author"
     29 				},
     30 				{
     31 					firstName: "C",
     32 					lastName: "D",
     33 					creatorType: "editor"
     34 				}
     35 			]);
     36 			assert.equal(item.getField('firstCreator'), "B");
     37 		});
     38 	});
     39 	
     40 	describe("#setField", function () {
     41 		it("should throw an error if item type isn't set", function () {
     42 			var item = new Zotero.Item;
     43 			assert.throws(() => item.setField('title', 'test'), "Item type must be set before setting field data");
     44 		})
     45 		
     46 		it("should mark a field as changed", function () {
     47 			var item = new Zotero.Item('book');
     48 			item.setField('title', 'Foo');
     49 			assert.isTrue(item._changed.itemData[Zotero.ItemFields.getID('title')]);
     50 			assert.isTrue(item.hasChanged());
     51 		})
     52 		
     53 		it("should save an integer as a string", function* () {
     54 			var val = 1234;
     55 			var item = new Zotero.Item('book');
     56 			item.setField('numPages', val);
     57 			yield item.saveTx();
     58 			assert.strictEqual(item.getField('numPages'), "" + val);
     59 			// Setting again as string shouldn't register a change
     60 			assert.isFalse(item.setField('numPages', "" + val));
     61 			
     62 			// Value should be TEXT in the DB
     63 			var sql = "SELECT TYPEOF(value) FROM itemData JOIN itemDataValues USING (valueID) "
     64 				+ "WHERE itemID=? AND fieldID=?";
     65 			var type = yield Zotero.DB.valueQueryAsync(sql, [item.id, Zotero.ItemFields.getID('numPages')]);
     66 			assert.equal(type, 'text');
     67 		});
     68 		
     69 		it("should save integer 0 as a string", function* () {
     70 			var val = 0;
     71 			var item = new Zotero.Item('book');
     72 			item.setField('numPages', val);
     73 			yield item.saveTx();
     74 			assert.strictEqual(item.getField('numPages'), "" + val);
     75 			// Setting again as string shouldn't register a change
     76 			assert.isFalse(item.setField('numPages', "" + val));
     77 		});
     78 		
     79 		it('should clear an existing field when ""/null/false is passed', function* () {
     80 			var field = 'title';
     81 			var val = 'foo';
     82 			var fieldID = Zotero.ItemFields.getID(field);
     83 			var item = new Zotero.Item('book');
     84 			item.setField(field, val);
     85 			yield item.saveTx();
     86 			
     87 			item.setField(field, "");
     88 			assert.ok(item._changed.itemData[fieldID]);
     89 			assert.isTrue(item.hasChanged());
     90 			
     91 			// Reset to original value
     92 			yield item.reload();
     93 			assert.isFalse(item.hasChanged());
     94 			assert.equal(item.getField(field), val);
     95 			
     96 			// false
     97 			item.setField(field, false);
     98 			assert.ok(item._changed.itemData[fieldID]);
     99 			assert.isTrue(item.hasChanged());
    100 			
    101 			// Reset to original value
    102 			yield item.reload();
    103 			assert.isFalse(item.hasChanged());
    104 			assert.equal(item.getField(field), val);
    105 			
    106 			// null
    107 			item.setField(field, null);
    108 			assert.ok(item._changed.itemData[fieldID]);
    109 			assert.isTrue(item.hasChanged());
    110 			
    111 			yield item.saveTx();
    112 			assert.equal(item.getField(field), "");
    113 		})
    114 		
    115 		it('should clear a field set to "0" when a ""/null/false is passed', function* () {
    116 			var field = 'title';
    117 			var val = "0";
    118 			var fieldID = Zotero.ItemFields.getID(field);
    119 			var item = new Zotero.Item('book');
    120 			item.setField(field, val);
    121 			yield item.saveTx();
    122 			
    123 			assert.strictEqual(item.getField(field), val);
    124 			
    125 			// ""
    126 			item.setField(field, "");
    127 			assert.ok(item._changed.itemData[fieldID]);
    128 			assert.isTrue(item.hasChanged());
    129 			
    130 			// Reset to original value
    131 			yield item.reload();
    132 			assert.isFalse(item.hasChanged());
    133 			assert.strictEqual(item.getField(field), val);
    134 			
    135 			// False
    136 			item.setField(field, false);
    137 			assert.ok(item._changed.itemData[fieldID]);
    138 			assert.isTrue(item.hasChanged());
    139 			
    140 			// Reset to original value
    141 			yield item.reload();
    142 			assert.isFalse(item.hasChanged());
    143 			assert.strictEqual(item.getField(field), val);
    144 			
    145 			// null
    146 			item.setField(field, null);
    147 			assert.ok(item._changed.itemData[fieldID]);
    148 			assert.isTrue(item.hasChanged());
    149 			
    150 			yield item.saveTx();
    151 			assert.strictEqual(item.getField(field), "");
    152 		})
    153 		
    154 		it("should throw if value is undefined", function () {
    155 			var item = new Zotero.Item('book');
    156 			assert.throws(() => item.setField('title'), "'title' value cannot be undefined");
    157 		})
    158 		
    159 		it("should not mark an empty field set to an empty string as changed", function () {
    160 			var item = new Zotero.Item('book');
    161 			item.setField('url', '');
    162 			assert.isUndefined(item._changed.itemData);
    163 		})
    164 		
    165 		it("should save version as object version", function* () {
    166 			var item = new Zotero.Item('book');
    167 			item.setField("version", 1);
    168 			var id = yield item.saveTx();
    169 			item = yield Zotero.Items.getAsync(id);
    170 			assert.equal(item.getField("version"), 1);
    171 			assert.equal(item.version, 1);
    172 		});
    173 		
    174 		it("should save versionNumber for computerProgram", function* () {
    175 			var item = new Zotero.Item('computerProgram');
    176 			item.setField("versionNumber", "1.0");
    177 			var id = yield item.saveTx();
    178 			item = yield Zotero.Items.getAsync(id);
    179 			assert.equal(item.getField("versionNumber"), "1.0");
    180 		});
    181 		
    182 		it("should accept ISO 8601 dates", function* () {
    183 			var fields = {
    184 				accessDate: "2015-06-07T20:56:00Z",
    185 				dateAdded: "2015-06-07T20:57:00Z",
    186 				dateModified: "2015-06-07T20:58:00Z",
    187 			};
    188 			var item = createUnsavedDataObject('item');
    189 			for (let i in fields) {
    190 				item.setField(i, fields[i]);
    191 			}
    192 			assert.equal(item.getField('accessDate'), '2015-06-07 20:56:00');
    193 			assert.equal(item.dateAdded, '2015-06-07 20:57:00');
    194 			assert.equal(item.dateModified, '2015-06-07 20:58:00');
    195 		})
    196 		
    197 		it("should accept SQL dates", function* () {
    198 			var fields = {
    199 				accessDate: "2015-06-07 20:56:00",
    200 				dateAdded: "2015-06-07 20:57:00",
    201 				dateModified: "2015-06-07 20:58:00",
    202 			};
    203 			var item = createUnsavedDataObject('item');
    204 			for (let i in fields) {
    205 				item.setField(i, fields[i]);
    206 				item.getField(i, fields[i]);
    207 			}
    208 		})
    209 		
    210 		it("should accept SQL accessDate without time", function* () {
    211 			var item = createUnsavedDataObject('item');
    212 			var date = "2017-04-05";
    213 			item.setField("accessDate", date);
    214 			assert.strictEqual(item.getField('accessDate'), date);
    215 		});
    216 		
    217 		it("should ignore unknown accessDate values", function* () {
    218 			var fields = {
    219 				accessDate: "foo"
    220 			};
    221 			var item = createUnsavedDataObject('item');
    222 			for (let i in fields) {
    223 				item.setField(i, fields[i]);
    224 			}
    225 			assert.strictEqual(item.getField('accessDate'), '');
    226 		})
    227 	})
    228 	
    229 	describe("#dateAdded", function () {
    230 		it("should use current time if value was not given for a new item", function* () {
    231 			var item = new Zotero.Item('book');
    232 			var id = yield item.saveTx();
    233 			item = Zotero.Items.get(id);
    234 			
    235 			assert.closeTo(Zotero.Date.sqlToDate(item.dateAdded, true).getTime(), Date.now(), 2000);
    236 		})
    237 		
    238 		it("should use given value for a new item", function* () {
    239 			var dateAdded = "2015-05-05 17:18:12";
    240 			var item = new Zotero.Item('book');
    241 			item.dateAdded = dateAdded;
    242 			var id = yield item.saveTx();
    243 			item = yield Zotero.Items.getAsync(id);
    244 			assert.equal(item.dateAdded, dateAdded);
    245 		})
    246 	})
    247 	
    248 	describe("#dateModified", function () {
    249 		it("should use given value for a new item", function* () {
    250 			var dateModified = "2015-05-05 17:18:12";
    251 			var item = new Zotero.Item('book');
    252 			item.dateModified = dateModified;
    253 			var id = yield item.saveTx();
    254 			assert.equal(item.dateModified, dateModified);
    255 			item = yield Zotero.Items.getAsync(id);
    256 			assert.equal(item.dateModified, dateModified);
    257 		})
    258 		
    259 		it("should use given value when skipDateModifiedUpdate is set for a new item", function* () {
    260 			var dateModified = "2015-05-05 17:18:12";
    261 			var item = new Zotero.Item('book');
    262 			item.dateModified = dateModified;
    263 			var id = yield item.saveTx({
    264 				skipDateModifiedUpdate: true
    265 			});
    266 			assert.equal(item.dateModified, dateModified);
    267 			item = yield Zotero.Items.getAsync(id);
    268 			assert.equal(item.dateModified, dateModified);
    269 		})
    270 		
    271 		it("should use current time if value was not given for an existing item", function* () {
    272 			var dateModified = "2015-05-05 17:18:12";
    273 			var item = new Zotero.Item('book');
    274 			item.dateModified = dateModified;
    275 			var id = yield item.saveTx();
    276 			item = Zotero.Items.get(id);
    277 			
    278 			// Save again without changing Date Modified
    279 			item.setField('title', 'Test');
    280 			yield item.saveTx()
    281 			
    282 			assert.closeTo(Zotero.Date.sqlToDate(item.dateModified, true).getTime(), Date.now(), 2000);
    283 		})
    284 		
    285 		it("should use current time if the existing value was given for an existing item", function* () {
    286 			var dateModified = "2015-05-05 17:18:12";
    287 			var item = new Zotero.Item('book');
    288 			item.dateModified = dateModified;
    289 			var id = yield item.saveTx();
    290 			item = Zotero.Items.get(id);
    291 			
    292 			// Set Date Modified to existing value
    293 			item.setField('title', 'Test');
    294 			item.dateModified = dateModified;
    295 			yield item.saveTx()
    296 			assert.closeTo(Zotero.Date.sqlToDate(item.dateModified, true).getTime(), Date.now(), 2000);
    297 		})
    298 		
    299 		it("should use current time if value is not given when skipDateModifiedUpdate is set for a new item", function* () {
    300 			var item = new Zotero.Item('book');
    301 			var id = yield item.saveTx({
    302 				skipDateModifiedUpdate: true
    303 			});
    304 			item = yield Zotero.Items.getAsync(id);
    305 			assert.closeTo(Zotero.Date.sqlToDate(item.dateModified, true).getTime(), Date.now(), 2000);
    306 		})
    307 		
    308 		it("should keep original value when skipDateModifiedUpdate is set for an existing item", function* () {
    309 			var dateModified = "2015-05-05 17:18:12";
    310 			var item = new Zotero.Item('book');
    311 			item.dateModified = dateModified;
    312 			var id = yield item.saveTx();
    313 			item = Zotero.Items.get(id);
    314 			
    315 			// Resave with skipDateModifiedUpdate
    316 			item.setField('title', 'Test');
    317 			yield item.saveTx({
    318 				skipDateModifiedUpdate: true
    319 			})
    320 			assert.equal(item.dateModified, dateModified);
    321 		})
    322 	})
    323 	
    324 	describe("#deleted", function () {
    325 		it("should be set to true after save", function* () {
    326 			var item = yield createDataObject('item');
    327 			item.deleted = true;
    328 			// Sanity check for itemsTest#trash()
    329 			assert.isTrue(item._changed.deleted);
    330 			yield item.saveTx();
    331 			assert.ok(item.deleted);
    332 		})
    333 		
    334 		it("should be set to false after save", function* () {
    335 			var collection = yield createDataObject('collection');
    336 			var item = createUnsavedDataObject('item');
    337 			item.deleted = true;
    338 			yield item.saveTx();
    339 			
    340 			item.deleted = false;
    341 			yield item.saveTx();
    342 			assert.isFalse(item.deleted);
    343 		})
    344 	})
    345 	
    346 	describe("#inPublications", function () {
    347 		it("should add item to publications table", function* () {
    348 			var item = yield createDataObject('item');
    349 			item.inPublications = true;
    350 			yield item.saveTx();
    351 			assert.ok(item.inPublications);
    352 			assert.equal(
    353 				(yield Zotero.DB.valueQueryAsync(
    354 					"SELECT COUNT(*) FROM publicationsItems WHERE itemID=?", item.id)),
    355 				1
    356 			);
    357 		})
    358 		
    359 		it("should be set to false after save", function* () {
    360 			var collection = yield createDataObject('collection');
    361 			var item = createUnsavedDataObject('item');
    362 			item.inPublications = false;
    363 			yield item.saveTx();
    364 			
    365 			item.inPublications = false;
    366 			yield item.saveTx();
    367 			assert.isFalse(item.inPublications);
    368 			assert.equal(
    369 				(yield Zotero.DB.valueQueryAsync(
    370 					"SELECT COUNT(*) FROM publicationsItems WHERE itemID=?", item.id)),
    371 				0
    372 			);
    373 		});
    374 		
    375 		it("should be invalid for linked-file attachments", function* () {
    376 			var item = yield createDataObject('item', { inPublications: true });
    377 			var attachment = yield Zotero.Attachments.linkFromFile({
    378 				file: OS.Path.join(getTestDataDirectory().path, 'test.png'),
    379 				parentItemID: item.id
    380 			});
    381 			attachment.inPublications = true;
    382 			var e = yield getPromiseError(attachment.saveTx());
    383 			assert.ok(e);
    384 			assert.include(e.message, "Linked-file attachments cannot be added to My Publications");
    385 		});
    386 		
    387 		it("should be invalid for group library items", function* () {
    388 			var group = yield getGroup();
    389 			var item = yield createDataObject('item', { libraryID: group.libraryID });
    390 			item.inPublications = true;
    391 			var e = yield getPromiseError(item.saveTx());
    392 			assert.ok(e);
    393 			assert.equal(e.message, "Only items in user libraries can be added to My Publications");
    394 		});
    395 	});
    396 	
    397 	describe("#parentID", function () {
    398 		it("should create a child note", function* () {
    399 			var item = new Zotero.Item('book');
    400 			var parentItemID = yield item.saveTx();
    401 			
    402 			item = new Zotero.Item('note');
    403 			item.parentID = parentItemID;
    404 			var childItemID = yield item.saveTx();
    405 			
    406 			item = yield Zotero.Items.getAsync(childItemID);
    407 			assert.ok(item.parentID);
    408 			assert.equal(item.parentID, parentItemID);
    409 		});
    410 	});
    411 	
    412 	describe("#parentKey", function () {
    413 		it("should be false for an unsaved attachment", function () {
    414 			var item = new Zotero.Item('attachment');
    415 			assert.isFalse(item.parentKey);
    416 		});
    417 		
    418 		it("should be false on an unsaved non-attachment item", function () {
    419 			var item = new Zotero.Item('book');
    420 			assert.isFalse(item.parentKey);
    421 		});
    422 		
    423 		it("should not be marked as changed setting to false on an unsaved item", function () {
    424 			var item = new Zotero.Item('attachment');
    425 			item.attachmentLinkMode = 'linked_url';
    426 			item.parentKey = false;
    427 			assert.isUndefined(item._changed.parentKey);
    428 		});
    429 		
    430 		it("should not mark item as changed if false and no existing parent", function* () {
    431 			var item = new Zotero.Item('attachment');
    432 			item.attachmentLinkMode = 'linked_url';
    433 			item.url = "https://www.zotero.org/";
    434 			var id = yield item.saveTx();
    435 			item = yield Zotero.Items.getAsync(id);
    436 			
    437 			item.parentKey = false;
    438 			assert.isFalse(item.hasChanged());
    439 		});
    440 		
    441 		it("should not be marked as changed after a save", async function () {
    442 			var item = await createDataObject('item');
    443 			var attachment = new Zotero.Item('attachment');
    444 			attachment.attachmentLinkMode = 'linked_url';
    445 			await attachment.saveTx();
    446 			
    447 			attachment.parentKey = item.key;
    448 			assert.isTrue(attachment._changed.parentKey);
    449 			await attachment.saveTx();
    450 			assert.isUndefined(attachment._changed.parentKey);
    451 		});
    452 		
    453 		it("should move a top-level note under another item", function* () {
    454 			var noteItem = new Zotero.Item('note');
    455 			var id = yield noteItem.saveTx()
    456 			noteItem = yield Zotero.Items.getAsync(id);
    457 			
    458 			var item = new Zotero.Item('book');
    459 			id = yield item.saveTx();
    460 			var { libraryID, key } = Zotero.Items.getLibraryAndKeyFromID(id);
    461 			
    462 			noteItem.parentKey = key;
    463 			yield noteItem.saveTx();
    464 			
    465 			assert.isFalse(noteItem.isTopLevelItem());
    466 		})
    467 		
    468 		it("should remove top-level item from collections when moving it under another item", function* () {
    469 			// Create a collection
    470 			var collection = new Zotero.Collection;
    471 			collection.name = "Test";
    472 			var collectionID = yield collection.saveTx();
    473 			
    474 			// Create a top-level note and add it to a collection
    475 			var noteItem = new Zotero.Item('note');
    476 			noteItem.addToCollection(collectionID);
    477 			var id = yield noteItem.saveTx()
    478 			noteItem = yield Zotero.Items.getAsync(id);
    479 			
    480 			var item = new Zotero.Item('book');
    481 			id = yield item.saveTx();
    482 			var { libraryID, key } = Zotero.Items.getLibraryAndKeyFromID(id);
    483 			noteItem.parentKey = key;
    484 			yield noteItem.saveTx();
    485 			
    486 			assert.isFalse(noteItem.isTopLevelItem());
    487 		})
    488 	});
    489 	
    490 	describe("#getCreators()", function () {
    491 		it("should update after creators are removed", function* () {
    492 			var item = createUnsavedDataObject('item');
    493 			item.setCreators([
    494 				{
    495 					creatorType: "author",
    496 					name: "A"
    497 				}
    498 			]);
    499 			yield item.saveTx();
    500 			
    501 			assert.lengthOf(item.getCreators(), 1);
    502 			
    503 			item.setCreators([]);
    504 			yield item.saveTx();
    505 			
    506 			assert.lengthOf(item.getCreators(), 0);
    507 		});
    508 	});
    509 	
    510 	describe("#setCreators", function () {
    511 		it("should accept an array of creators in API JSON format", function* () {
    512 			var creators = [
    513 				{
    514 					firstName: "First",
    515 					lastName: "Last",
    516 					creatorType: "author"
    517 				},
    518 				{
    519 					name: "Test Name",
    520 					creatorType: "editor"
    521 				}
    522 			];
    523 			
    524 			var item = new Zotero.Item("journalArticle");
    525 			item.setCreators(creators);
    526 			var id = yield item.saveTx();
    527 			item = Zotero.Items.get(id);
    528 			assert.sameDeepMembers(item.getCreatorsJSON(), creators);
    529 		})
    530 		
    531 		it("should accept an array of creators in internal format", function* () {
    532 			var creators = [
    533 				{
    534 					firstName: "First",
    535 					lastName: "Last",
    536 					fieldMode: 0,
    537 					creatorTypeID: 1
    538 				},
    539 				{
    540 					firstName: "",
    541 					lastName: "Test Name",
    542 					fieldMode: 1,
    543 					creatorTypeID: 2
    544 				}
    545 			];
    546 			
    547 			var item = new Zotero.Item("journalArticle");
    548 			item.setCreators(creators);
    549 			var id = yield item.saveTx();
    550 			item = Zotero.Items.get(id);
    551 			assert.sameDeepMembers(item.getCreators(), creators);
    552 		})
    553 		
    554 		it("should clear creators if empty array passed", function () {
    555 			var item = createUnsavedDataObject('item');
    556 			item.setCreators([
    557 				{
    558 					firstName: "First",
    559 					lastName: "Last",
    560 					fieldMode: 0,
    561 					creatorTypeID: 1
    562 				}
    563 			]);
    564 			assert.lengthOf(item.getCreators(), 1);
    565 			item.setCreators([]);
    566 			assert.lengthOf(item.getCreators(), 0);
    567 		});
    568 	})
    569 	
    570 	
    571 	describe("#numAttachments()", function () {
    572 		it("should include child attachments", function* () {
    573 			var item = yield createDataObject('item');
    574 			var attachment = yield importFileAttachment('test.png', { parentID: item.id });
    575 			assert.equal(item.numAttachments(), 1);
    576 		});
    577 		
    578 		it("shouldn't include trashed child attachments by default", function* () {
    579 			var item = yield createDataObject('item');
    580 			yield importFileAttachment('test.png', { parentID: item.id });
    581 			var attachment = yield importFileAttachment('test.png', { parentID: item.id });
    582 			attachment.deleted = true;
    583 			yield attachment.saveTx();
    584 			assert.equal(item.numAttachments(), 1);
    585 		});
    586 		
    587 		it("should include trashed child attachments if includeTrashed=true", function* () {
    588 			var item = yield createDataObject('item');
    589 			yield importFileAttachment('test.png', { parentID: item.id });
    590 			var attachment = yield importFileAttachment('test.png', { parentID: item.id });
    591 			attachment.deleted = true;
    592 			yield attachment.saveTx();
    593 			assert.equal(item.numAttachments(true), 2);
    594 		});
    595 	});
    596 	
    597 	
    598 	describe("#getAttachments()", function () {
    599 		it("#should return child attachments", function* () {
    600 			var item = yield createDataObject('item');
    601 			var attachment = new Zotero.Item("attachment");
    602 			attachment.parentID = item.id;
    603 			attachment.attachmentLinkMode = Zotero.Attachments.LINK_MODE_IMPORTED_FILE;
    604 			yield attachment.saveTx();
    605 			
    606 			var attachments = item.getAttachments();
    607 			assert.lengthOf(attachments, 1);
    608 			assert.equal(attachments[0], attachment.id);
    609 		})
    610 		
    611 		it("#should ignore trashed child attachments by default", function* () {
    612 			var item = yield createDataObject('item');
    613 			var attachment = new Zotero.Item("attachment");
    614 			attachment.parentID = item.id;
    615 			attachment.attachmentLinkMode = Zotero.Attachments.LINK_MODE_IMPORTED_FILE;
    616 			attachment.deleted = true;
    617 			yield attachment.saveTx();
    618 			
    619 			var attachments = item.getAttachments();
    620 			assert.lengthOf(attachments, 0);
    621 		})
    622 		
    623 		it("#should include trashed child attachments if includeTrashed=true", function* () {
    624 			var item = yield createDataObject('item');
    625 			var attachment = new Zotero.Item("attachment");
    626 			attachment.parentID = item.id;
    627 			attachment.attachmentLinkMode = Zotero.Attachments.LINK_MODE_IMPORTED_FILE;
    628 			attachment.deleted = true;
    629 			yield attachment.saveTx();
    630 			
    631 			var attachments = item.getAttachments(true);
    632 			assert.lengthOf(attachments, 1);
    633 			assert.equal(attachments[0], attachment.id);
    634 		})
    635 		
    636 		it("#should return an empty array for an item with no attachments", function* () {
    637 			var item = yield createDataObject('item');
    638 			assert.lengthOf(item.getAttachments(), 0);
    639 		})
    640 		
    641 		it("should update after an attachment is moved to another item", function* () {
    642 			var item1 = yield createDataObject('item');
    643 			var item2 = yield createDataObject('item');
    644 			var item3 = new Zotero.Item('attachment');
    645 			item3.parentID = item1.id;
    646 			item3.attachmentLinkMode = 'linked_url';
    647 			item3.setField('url', 'http://example.com');
    648 			yield item3.saveTx();
    649 			
    650 			assert.lengthOf(item1.getAttachments(), 1);
    651 			assert.lengthOf(item2.getAttachments(), 0);
    652 			
    653 			item3.parentID = item2.id;
    654 			yield item3.saveTx();
    655 			
    656 			assert.lengthOf(item1.getAttachments(), 0);
    657 			assert.lengthOf(item2.getAttachments(), 1);
    658 		});
    659 	})
    660 	
    661 	describe("#numNotes()", function () {
    662 		it("should include child notes", function* () {
    663 			var item = yield createDataObject('item');
    664 			yield createDataObject('item', { itemType: 'note', parentID: item.id });
    665 			yield createDataObject('item', { itemType: 'note', parentID: item.id });
    666 			assert.equal(item.numNotes(), 2);
    667 		});
    668 		
    669 		it("shouldn't include trashed child notes by default", function* () {
    670 			var item = yield createDataObject('item');
    671 			yield createDataObject('item', { itemType: 'note', parentID: item.id });
    672 			yield createDataObject('item', { itemType: 'note', parentID: item.id, deleted: true });
    673 			assert.equal(item.numNotes(), 1);
    674 		});
    675 		
    676 		it("should include trashed child notes with includeTrashed", function* () {
    677 			var item = yield createDataObject('item');
    678 			yield createDataObject('item', { itemType: 'note', parentID: item.id });
    679 			yield createDataObject('item', { itemType: 'note', parentID: item.id, deleted: true });
    680 			assert.equal(item.numNotes(true), 2);
    681 		});
    682 		
    683 		it("should include child attachment notes with includeEmbedded", function* () {
    684 			var item = yield createDataObject('item');
    685 			yield createDataObject('item', { itemType: 'note', parentID: item.id });
    686 			var attachment = yield importFileAttachment('test.png', { parentID: item.id });
    687 			attachment.setNote('test');
    688 			yield attachment.saveTx();
    689 			yield item.loadDataType('childItems');
    690 			assert.equal(item.numNotes(false, true), 2);
    691 		});
    692 		
    693 		it("shouldn't include empty child attachment notes with includeEmbedded", function* () {
    694 			var item = yield createDataObject('item');
    695 			yield createDataObject('item', { itemType: 'note', parentID: item.id });
    696 			var attachment = yield importFileAttachment('test.png', { parentID: item.id });
    697 			assert.equal(item.numNotes(false, true), 1);
    698 		});
    699 		
    700 		// TODO: Fix numNotes(false, true) updating after child attachment note is added or removed
    701 	});
    702 	
    703 	
    704 	describe("#getNotes()", function () {
    705 		it("#should return child notes", function* () {
    706 			var item = yield createDataObject('item');
    707 			var note = new Zotero.Item("note");
    708 			note.parentID = item.id;
    709 			yield note.saveTx();
    710 			
    711 			var notes = item.getNotes();
    712 			assert.lengthOf(notes, 1);
    713 			assert.equal(notes[0], note.id);
    714 		})
    715 		
    716 		it("#should ignore trashed child notes by default", function* () {
    717 			var item = yield createDataObject('item');
    718 			var note = new Zotero.Item("note");
    719 			note.parentID = item.id;
    720 			note.deleted = true;
    721 			yield note.saveTx();
    722 			
    723 			var notes = item.getNotes();
    724 			assert.lengthOf(notes, 0);
    725 		})
    726 		
    727 		it("#should include trashed child notes if includeTrashed=true", function* () {
    728 			var item = yield createDataObject('item');
    729 			var note = new Zotero.Item("note");
    730 			note.parentID = item.id;
    731 			note.deleted = true;
    732 			yield note.saveTx();
    733 			
    734 			var notes = item.getNotes(true);
    735 			assert.lengthOf(notes, 1);
    736 			assert.equal(notes[0], note.id);
    737 		})
    738 		
    739 		it("#should return an empty array for an item with no notes", function* () {
    740 			var item = yield createDataObject('item');
    741 			assert.lengthOf(item.getNotes(), 0);
    742 		});
    743 		
    744 		it("should update after a note is moved to another item", function* () {
    745 			var item1 = yield createDataObject('item');
    746 			var item2 = yield createDataObject('item');
    747 			var item3 = yield createDataObject('item', { itemType: 'note', parentID: item1.id });
    748 			
    749 			assert.lengthOf(item1.getNotes(), 1);
    750 			assert.lengthOf(item2.getNotes(), 0);
    751 			
    752 			item3.parentID = item2.id;
    753 			yield item3.saveTx();
    754 			
    755 			assert.lengthOf(item1.getNotes(), 0);
    756 			assert.lengthOf(item2.getNotes(), 1);
    757 		});
    758 	})
    759 	
    760 	describe("#attachmentCharset", function () {
    761 		it("should get and set a value", function* () {
    762 			var charset = 'utf-8';
    763 			var item = new Zotero.Item("attachment");
    764 			item.attachmentLinkMode = Zotero.Attachments.LINK_MODE_IMPORTED_FILE;
    765 			item.attachmentCharset = charset;
    766 			var itemID = yield item.saveTx();
    767 			assert.equal(item.attachmentCharset, charset);
    768 			item = yield Zotero.Items.getAsync(itemID);
    769 			assert.equal(item.attachmentCharset, charset);
    770 		})
    771 		
    772 		it("should not allow a numerical value", function* () {
    773 			var charset = 1;
    774 			var item = new Zotero.Item("attachment");
    775 			try {
    776 				item.attachmentCharset = charset;
    777 			}
    778 			catch (e) {
    779 				assert.equal(e.message, "Character set must be a string")
    780 				return;
    781 			}
    782 			assert.fail("Numerical charset was allowed");
    783 		})
    784 		
    785 		it("should not be marked as changed if not changed", function* () {
    786 			var charset = 'utf-8';
    787 			var item = new Zotero.Item("attachment");
    788 			item.attachmentLinkMode = Zotero.Attachments.LINK_MODE_IMPORTED_FILE;
    789 			item.attachmentCharset = charset;
    790 			var itemID = yield item.saveTx();
    791 			item = yield Zotero.Items.getAsync(itemID);
    792 			
    793 			// Set charset to same value
    794 			item.attachmentCharset = charset
    795 			assert.isFalse(item.hasChanged());
    796 		})
    797 	})
    798 	
    799 	describe("#attachmentFilename", function () {
    800 		it("should get and set a filename for a stored file", function* () {
    801 			var filename = "test.txt";
    802 			
    803 			// Create parent item
    804 			var item = new Zotero.Item("book");
    805 			var parentItemID = yield item.saveTx();
    806 			
    807 			// Create attachment item
    808 			var item = new Zotero.Item("attachment");
    809 			item.attachmentLinkMode = Zotero.Attachments.LINK_MODE_IMPORTED_FILE;
    810 			item.parentID = parentItemID;
    811 			var itemID = yield item.saveTx();
    812 			
    813 			// Should be empty when unset
    814 			assert.equal(item.attachmentFilename, '');
    815 			
    816 			// Set filename
    817 			item.attachmentFilename = filename;
    818 			yield item.saveTx();
    819 			item = yield Zotero.Items.getAsync(itemID);
    820 			
    821 			// Check filename
    822 			assert.equal(item.attachmentFilename, filename);
    823 			
    824 			// Check full path
    825 			var file = Zotero.Attachments.getStorageDirectory(item);
    826 			file.append(filename);
    827 			assert.equal(item.getFilePath(), file.path);
    828 		});
    829 		
    830 		it.skip("should get and set a filename for a base-dir-relative file", function* () {
    831 			
    832 		})
    833 	})
    834 	
    835 	describe("#attachmentPath", function () {
    836 		it("should return an absolute path for a linked attachment", function* () {
    837 			var file = getTestDataDirectory();
    838 			file.append('test.png');
    839 			var item = yield Zotero.Attachments.linkFromFile({ file });
    840 			assert.equal(item.attachmentPath, file.path);
    841 		})
    842 		
    843 		it("should return a prefixed path for an imported file", function* () {
    844 			var file = getTestDataDirectory();
    845 			file.append('test.png');
    846 			var item = yield Zotero.Attachments.importFromFile({ file });
    847 			
    848 			assert.equal(item.attachmentPath, "storage:test.png");
    849 		})
    850 		
    851 		it("should set a prefixed relative path for a path within the defined base directory", function* () {
    852 			var dir = getTestDataDirectory().path;
    853 			var dirname = OS.Path.basename(dir);
    854 			var baseDir = OS.Path.dirname(dir);
    855 			Zotero.Prefs.set('saveRelativeAttachmentPath', true)
    856 			Zotero.Prefs.set('baseAttachmentPath', baseDir)
    857 			
    858 			var file = OS.Path.join(dir, 'test.png');
    859 			
    860 			var item = new Zotero.Item('attachment');
    861 			item.attachmentLinkMode = 'linked_file';
    862 			item.attachmentPath = file;
    863 			
    864 			assert.equal(item.attachmentPath, "attachments:data/test.png");
    865 			
    866 			Zotero.Prefs.set('saveRelativeAttachmentPath', false)
    867 			Zotero.Prefs.clear('baseAttachmentPath')
    868 		})
    869 		
    870 		it("should return a prefixed path for a linked attachment within the defined base directory", function* () {
    871 			var dir = getTestDataDirectory().path;
    872 			var dirname = OS.Path.basename(dir);
    873 			var baseDir = OS.Path.dirname(dir);
    874 			Zotero.Prefs.set('saveRelativeAttachmentPath', true)
    875 			Zotero.Prefs.set('baseAttachmentPath', baseDir)
    876 			
    877 			var file = OS.Path.join(dir, 'test.png');
    878 			
    879 			var item = yield Zotero.Attachments.linkFromFile({
    880 				file: Zotero.File.pathToFile(file)
    881 			});
    882 			
    883 			assert.equal(item.attachmentPath, "attachments:data/test.png");
    884 			
    885 			Zotero.Prefs.set('saveRelativeAttachmentPath', false)
    886 			Zotero.Prefs.clear('baseAttachmentPath')
    887 		})
    888 	})
    889 	
    890 	describe("#renameAttachmentFile()", function () {
    891 		it("should rename an attached file", function* () {
    892 			var file = getTestDataDirectory();
    893 			file.append('test.png');
    894 			var item = yield Zotero.Attachments.importFromFile({
    895 				file: file
    896 			});
    897 			var newName = 'test2.png';
    898 			yield item.renameAttachmentFile(newName);
    899 			assert.equal(item.attachmentFilename, newName);
    900 			var path = yield item.getFilePathAsync();
    901 			assert.equal(OS.Path.basename(path), newName)
    902 			yield OS.File.exists(path);
    903 			
    904 			// File should be flagged for upload
    905 			// DEBUG: Is this necessary?
    906 			assert.equal(item.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_TO_UPLOAD);
    907 			assert.isNull(item.attachmentSyncedHash);
    908 		})
    909 		
    910 		it("should rename a linked file", function* () {
    911 			var filename = 'test.png';
    912 			var file = getTestDataDirectory();
    913 			file.append(filename);
    914 			var tmpDir = yield getTempDirectory();
    915 			var tmpFile = OS.Path.join(tmpDir, filename);
    916 			yield OS.File.copy(file.path, tmpFile);
    917 			
    918 			var item = yield Zotero.Attachments.linkFromFile({
    919 				file: tmpFile
    920 			});
    921 			var newName = 'test2.png';
    922 			yield assert.eventually.isTrue(item.renameAttachmentFile(newName));
    923 			assert.equal(item.attachmentFilename, newName);
    924 			var path = yield item.getFilePathAsync();
    925 			assert.equal(OS.Path.basename(path), newName)
    926 			yield OS.File.exists(path);
    927 		})
    928 	})
    929 	
    930 	
    931 	describe("#getBestAttachmentState()", function () {
    932 		it("should cache state for an existing file", function* () {
    933 			var parentItem = yield createDataObject('item');
    934 			var file = getTestDataDirectory();
    935 			file.append('test.png');
    936 			var childItem = yield Zotero.Attachments.importFromFile({
    937 				file,
    938 				parentItemID: parentItem.id
    939 			});
    940 			yield parentItem.getBestAttachmentState();
    941 			assert.equal(parentItem.getBestAttachmentStateCached(), 1);
    942 		})
    943 		
    944 		it("should cache state for a missing file", function* () {
    945 			var parentItem = yield createDataObject('item');
    946 			var file = getTestDataDirectory();
    947 			file.append('test.png');
    948 			var childItem = yield Zotero.Attachments.importFromFile({
    949 				file,
    950 				parentItemID: parentItem.id
    951 			});
    952 			let path = yield childItem.getFilePathAsync();
    953 			yield OS.File.remove(path);
    954 			yield parentItem.getBestAttachmentState();
    955 			assert.equal(parentItem.getBestAttachmentStateCached(), -1);
    956 		})
    957 	})
    958 	
    959 	
    960 	describe("#fileExists()", function () {
    961 		it("should cache state for an existing file", function* () {
    962 			var file = getTestDataDirectory();
    963 			file.append('test.png');
    964 			var item = yield Zotero.Attachments.importFromFile({ file });
    965 			yield item.fileExists();
    966 			assert.equal(item.fileExistsCached(), true);
    967 		})
    968 		
    969 		it("should cache state for a missing file", function* () {
    970 			var file = getTestDataDirectory();
    971 			file.append('test.png');
    972 			var item = yield Zotero.Attachments.importFromFile({ file });
    973 			let path = yield item.getFilePathAsync();
    974 			yield OS.File.remove(path);
    975 			yield item.fileExists();
    976 			assert.equal(item.fileExistsCached(), false);
    977 		})
    978 	})
    979 	
    980 	
    981 	describe("#relinkAttachmentFile", function () {
    982 		it("should copy a file elsewhere into the storage directory", function* () {
    983 			var filename = 'test.png';
    984 			var file = getTestDataDirectory();
    985 			file.append(filename);
    986 			var tmpDir = yield getTempDirectory();
    987 			var tmpFile = OS.Path.join(tmpDir, filename);
    988 			yield OS.File.copy(file.path, tmpFile);
    989 			file = OS.Path.join(tmpDir, filename);
    990 			
    991 			var item = yield Zotero.Attachments.importFromFile({ file });
    992 			let path = yield item.getFilePathAsync();
    993 			yield OS.File.remove(path);
    994 			yield OS.File.removeEmptyDir(OS.Path.dirname(path));
    995 			
    996 			assert.isFalse(yield item.fileExists());
    997 			yield item.relinkAttachmentFile(file);
    998 			assert.isTrue(yield item.fileExists());
    999 			
   1000 			assert.isTrue(yield OS.File.exists(tmpFile));
   1001 		});
   1002 		
   1003 		it("should handle normalized filenames", function* () {
   1004 			var item = yield importFileAttachment('test.png');
   1005 			var path = yield item.getFilePathAsync();
   1006 			var dir = OS.Path.dirname(path);
   1007 			var filename = 'tést.pdf'.normalize('NFKD');
   1008 			
   1009 			// Make sure we're actually testing something -- the test string should be differently
   1010 			// normalized from what's done in getValidFileName
   1011 			assert.notEqual(filename, Zotero.File.getValidFileName(filename));
   1012 			
   1013 			var newPath = OS.Path.join(dir, filename);
   1014 			yield OS.File.move(path, newPath);
   1015 			
   1016 			assert.isFalse(yield item.fileExists());
   1017 			yield item.relinkAttachmentFile(newPath);
   1018 			assert.isTrue(yield item.fileExists());
   1019 		});
   1020 	});
   1021 	
   1022 	
   1023 	describe("#setTags", function () {
   1024 		it("should save an array of tags in API JSON format", function* () {
   1025 			var tags = [
   1026 				{
   1027 					tag: "A"
   1028 				},
   1029 				{
   1030 					tag: "B"
   1031 				}
   1032 			];
   1033 			var item = new Zotero.Item('journalArticle');
   1034 			item.setTags(tags);
   1035 			var id = yield item.saveTx();
   1036 			item = Zotero.Items.get(id);
   1037 			assert.sameDeepMembers(item.getTags(tags), tags);
   1038 		})
   1039 		
   1040 		it("shouldn't mark item as changed if tags haven't changed", function* () {
   1041 			var tags = [
   1042 				{
   1043 					tag: "A"
   1044 				},
   1045 				{
   1046 					tag: "B"
   1047 				}
   1048 			];
   1049 			var item = new Zotero.Item('journalArticle');
   1050 			item.setTags(tags);
   1051 			var id = yield item.saveTx();
   1052 			item = Zotero.Items.get(id);
   1053 			item.setTags(tags);
   1054 			assert.isFalse(item.hasChanged());
   1055 		})
   1056 		
   1057 		it("should remove an existing tag", function* () {
   1058 			var tags = [
   1059 				{
   1060 					tag: "A"
   1061 				},
   1062 				{
   1063 					tag: "B"
   1064 				}
   1065 			];
   1066 			var item = new Zotero.Item('journalArticle');
   1067 			item.setTags(tags);
   1068 			var id = yield item.saveTx();
   1069 			item = Zotero.Items.get(id);
   1070 			item.setTags(tags.slice(0));
   1071 			yield item.saveTx();
   1072 			assert.sameDeepMembers(item.getTags(tags), tags.slice(0));
   1073 		})
   1074 	})
   1075 	
   1076 	describe("#addTag", function () {
   1077 		it("should add a tag", function* () {
   1078 			var item = createUnsavedDataObject('item');
   1079 			item.addTag('a');
   1080 			yield item.saveTx();
   1081 			var tags = item.getTags();
   1082 			assert.deepEqual(tags, [{ tag: 'a' }]);
   1083 		})
   1084 		
   1085 		it("should add two tags", function* () {
   1086 			var item = createUnsavedDataObject('item');
   1087 			item.addTag('a');
   1088 			item.addTag('b');
   1089 			yield item.saveTx();
   1090 			var tags = item.getTags();
   1091 			assert.sameDeepMembers(tags, [{ tag: 'a' }, { tag: 'b' }]);
   1092 		})
   1093 		
   1094 		it("should add two tags of different types", function* () {
   1095 			var item = createUnsavedDataObject('item');
   1096 			item.addTag('a');
   1097 			item.addTag('b', 1);
   1098 			yield item.saveTx();
   1099 			var tags = item.getTags();
   1100 			assert.sameDeepMembers(tags, [{ tag: 'a' }, { tag: 'b', type: 1 }]);
   1101 		})
   1102 		
   1103 		it("should add a tag to an existing item", function* () {
   1104 			var item = yield createDataObject('item');
   1105 			item.addTag('a');
   1106 			yield item.saveTx();
   1107 			var tags = item.getTags();
   1108 			assert.deepEqual(tags, [{ tag: 'a' }]);
   1109 		})
   1110 		
   1111 		it("should add two tags to an existing item", function* () {
   1112 			var item = yield createDataObject('item');
   1113 			item.addTag('a');
   1114 			item.addTag('b');
   1115 			yield item.saveTx();
   1116 			var tags = item.getTags();
   1117 			assert.sameDeepMembers(tags, [{ tag: 'a' }, { tag: 'b' }]);
   1118 		})
   1119 	})
   1120 	
   1121 	//
   1122 	// Relations and related items
   1123 	//
   1124 	describe("#addRelatedItem", function () {
   1125 		it("should add a dc:relation relation to an item", function* () {
   1126 			var item1 = yield createDataObject('item');
   1127 			var item2 = yield createDataObject('item');
   1128 			item1.addRelatedItem(item2);
   1129 			yield item1.saveTx();
   1130 			
   1131 			var rels = item1.getRelationsByPredicate(Zotero.Relations.relatedItemPredicate);
   1132 			assert.lengthOf(rels, 1);
   1133 			assert.equal(rels[0], Zotero.URI.getItemURI(item2));
   1134 		})
   1135 		
   1136 		it("should allow an unsaved item to be related to an item in the user library", function* () {
   1137 			var item1 = yield createDataObject('item');
   1138 			var item2 = createUnsavedDataObject('item');
   1139 			item2.addRelatedItem(item1);
   1140 			yield item2.saveTx();
   1141 			
   1142 			var rels = item2.getRelationsByPredicate(Zotero.Relations.relatedItemPredicate);
   1143 			assert.lengthOf(rels, 1);
   1144 			assert.equal(rels[0], Zotero.URI.getItemURI(item1));
   1145 		})
   1146 		
   1147 		it("should throw an error for a relation in a different library", function* () {
   1148 			var group = yield getGroup();
   1149 			var item1 = yield createDataObject('item');
   1150 			var item2 = yield createDataObject('item', { libraryID: group.libraryID });
   1151 			try {
   1152 				item1.addRelatedItem(item2)
   1153 			}
   1154 			catch (e) {
   1155 				assert.ok(e);
   1156 				assert.equal(e.message, "Cannot relate item to an item in a different library");
   1157 				return;
   1158 			}
   1159 			assert.fail("addRelatedItem() allowed for an item in a different library");
   1160 		})
   1161 	})
   1162 	
   1163 	describe("#save()", function () {
   1164 		it("should throw an error for an empty item without an item type", function* () {
   1165 			var item = new Zotero.Item;
   1166 			var e = yield getPromiseError(item.saveTx());
   1167 			assert.ok(e);
   1168 			assert.equal(e.message, "Item type must be set before saving");
   1169 		})
   1170 		
   1171 		it("should reload child items for parent items", function* () {
   1172 			var item = yield createDataObject('item');
   1173 			var attachment = yield importFileAttachment('test.png', { parentItemID: item.id });
   1174 			var note1 = new Zotero.Item('note');
   1175 			note1.parentItemID = item.id;
   1176 			yield note1.saveTx();
   1177 			var note2 = new Zotero.Item('note');
   1178 			note2.parentItemID = item.id;
   1179 			yield note2.saveTx();
   1180 			
   1181 			assert.lengthOf(item.getAttachments(), 1);
   1182 			assert.lengthOf(item.getNotes(), 2);
   1183 			
   1184 			note2.parentItemID = null;
   1185 			yield note2.saveTx();
   1186 			
   1187 			assert.lengthOf(item.getAttachments(), 1);
   1188 			assert.lengthOf(item.getNotes(), 1);
   1189 		});
   1190 	})
   1191 	
   1192 	
   1193 	describe("#_eraseData()", function () {
   1194 		it("should remove relations pointing to this item", function* () {
   1195 			var item1 = yield createDataObject('item');
   1196 			var item2 = yield createDataObject('item');
   1197 			item1.addRelatedItem(item2);
   1198 			yield item1.saveTx();
   1199 			item2.addRelatedItem(item1);
   1200 			yield item2.saveTx();
   1201 			
   1202 			yield item1.eraseTx();
   1203 			
   1204 			assert.lengthOf(item2.relatedItems, 0);
   1205 			yield assert.eventually.equal(
   1206 				Zotero.DB.valueQueryAsync("SELECT COUNT(*) FROM itemRelations WHERE itemID=?", item2.id),
   1207 				0
   1208 			);
   1209 		});
   1210 	});
   1211 	
   1212 	
   1213 	describe("#multiDiff", function () {
   1214 		it("should return set of alternatives for differing fields in other items", function* () {
   1215 			var type = 'item';
   1216 			
   1217 			var dates = ['2016-03-08 17:44:45'];
   1218 			var accessDates = ['2016-03-08T18:44:45Z'];
   1219 			var urls = ['http://www.example.com', 'http://example.net'];
   1220 			
   1221 			var obj1 = createUnsavedDataObject(type);
   1222 			obj1.setField('date', '2016-03-07 12:34:56'); // different in 1 and 3, not in 2
   1223 			obj1.setField('url', 'http://example.com'); // different in all three
   1224 			obj1.setField('title', 'Test'); // only in 1
   1225 			
   1226 			var obj2 = createUnsavedDataObject(type);
   1227 			obj2.setField('url', urls[0]);
   1228 			obj2.setField('accessDate', accessDates[0]); // only in 2
   1229 			
   1230 			var obj3 = createUnsavedDataObject(type);
   1231 			obj3.setField('date', dates[0]);
   1232 			obj3.setField('url', urls[1]);
   1233 			
   1234 			var alternatives = obj1.multiDiff([obj2, obj3]);
   1235 			
   1236 			assert.sameMembers(Object.keys(alternatives), ['url', 'date', 'accessDate']);
   1237 			assert.sameMembers(alternatives.url, urls);
   1238 			assert.sameMembers(alternatives.date, dates);
   1239 			assert.sameMembers(alternatives.accessDate, accessDates);
   1240 		});
   1241 	});
   1242 	
   1243 	
   1244 	describe("#clone()", function () {
   1245 		// TODO: Expand to other data
   1246 		it("should copy creators", function* () {
   1247 			var item = new Zotero.Item('book');
   1248 			item.setCreators([
   1249 				{
   1250 					firstName: "A",
   1251 					lastName: "Test",
   1252 					creatorType: 'author'
   1253 				}
   1254 			]);
   1255 			yield item.saveTx();
   1256 			var newItem = item.clone();
   1257 			assert.sameDeepMembers(item.getCreators(), newItem.getCreators());
   1258 		})
   1259 	})
   1260 	
   1261 	describe("#moveToLibrary()", function () {
   1262 		it("should move items from My Library to a filesEditable group", async function () {
   1263 			var group = await createGroup();
   1264 			
   1265 			var item = await createDataObject('item');
   1266 			var attachment1 = await importFileAttachment('test.png', { parentID: item.id });
   1267 			var file = getTestDataDirectory();
   1268 			file.append('test.png');
   1269 			var attachment2 = await Zotero.Attachments.linkFromFile({
   1270 				file,
   1271 				parentItemID: item.id
   1272 			});
   1273 			var note = await createDataObject('item', { itemType: 'note', parentID: item.id });
   1274 			
   1275 			var originalIDs = [item.id, attachment1.id, attachment2.id, note.id];
   1276 			var originalAttachmentFile = attachment1.getFilePath();
   1277 			var originalAttachmentHash = await attachment1.attachmentHash
   1278 			
   1279 			assert.isTrue(await OS.File.exists(originalAttachmentFile));
   1280 			
   1281 			var newItem = await item.moveToLibrary(group.libraryID);
   1282 			
   1283 			// Old items and file should be gone
   1284 			assert.isTrue(originalIDs.every(id => !Zotero.Items.get(id)));
   1285 			assert.isFalse(await OS.File.exists(originalAttachmentFile));
   1286 			
   1287 			// New items and stored file should exist; linked file should be gone
   1288 			assert.equal(newItem.libraryID, group.libraryID);
   1289 			assert.lengthOf(newItem.getAttachments(), 1);
   1290 			var newAttachment = Zotero.Items.get(newItem.getAttachments()[0]);
   1291 			assert.equal(await newAttachment.attachmentHash, originalAttachmentHash);
   1292 			assert.lengthOf(newItem.getNotes(), 1);
   1293 		});
   1294 		
   1295 		it("should move items from My Library to a non-filesEditable group", async function () {
   1296 			var group = await createGroup({
   1297 				filesEditable: false
   1298 			});
   1299 			
   1300 			var item = await createDataObject('item');
   1301 			var attachment = await importFileAttachment('test.png', { parentID: item.id });
   1302 			
   1303 			var originalIDs = [item.id, attachment.id];
   1304 			var originalAttachmentFile = attachment.getFilePath();
   1305 			var originalAttachmentHash = await attachment.attachmentHash
   1306 			
   1307 			assert.isTrue(await OS.File.exists(originalAttachmentFile));
   1308 			
   1309 			var newItem = await item.moveToLibrary(group.libraryID);
   1310 			
   1311 			// Old items and file should be gone
   1312 			assert.isTrue(originalIDs.every(id => !Zotero.Items.get(id)));
   1313 			assert.isFalse(await OS.File.exists(originalAttachmentFile));
   1314 			
   1315 			// Parent should exist, but attachment should not
   1316 			assert.equal(newItem.libraryID, group.libraryID);
   1317 			assert.lengthOf(newItem.getAttachments(), 0);
   1318 		});
   1319 	});
   1320 	
   1321 	describe("#toJSON()", function () {
   1322 		describe("default mode", function () {
   1323 			it("should output only fields with values", function* () {
   1324 				var itemType = "book";
   1325 				var title = "Test";
   1326 				
   1327 				var item = new Zotero.Item(itemType);
   1328 				item.setField("title", title);
   1329 				var id = yield item.saveTx();
   1330 				item = Zotero.Items.get(id);
   1331 				var json = item.toJSON();
   1332 				
   1333 				assert.equal(json.itemType, itemType);
   1334 				assert.equal(json.title, title);
   1335 				assert.isUndefined(json.date);
   1336 				assert.isUndefined(json.numPages);
   1337 			})
   1338 			
   1339 			it("should output 'deleted' as 1", function* () {
   1340 				var itemType = "book";
   1341 				var title = "Test";
   1342 				
   1343 				var item = new Zotero.Item(itemType);
   1344 				item.setField("title", title);
   1345 				item.deleted = true;
   1346 				var id = yield item.saveTx();
   1347 				item = Zotero.Items.get(id);
   1348 				var json = item.toJSON();
   1349 				
   1350 				assert.strictEqual(json.deleted, 1);
   1351 			})
   1352 			
   1353 			it.skip("should output attachment fields from file", function* () {
   1354 				var file = getTestDataDirectory();
   1355 				file.append('test.png');
   1356 				var item = yield Zotero.Attachments.importFromFile({ file });
   1357 				
   1358 				yield Zotero.DB.executeTransaction(function* () {
   1359 					yield Zotero.Sync.Storage.Local.setSyncedModificationTime(
   1360 						item.id, new Date().getTime()
   1361 					);
   1362 					yield Zotero.Sync.Storage.Local.setSyncedHash(
   1363 						item.id, 'b32e33f529942d73bea4ed112310f804'
   1364 					);
   1365 				});
   1366 				
   1367 				var json = item.toJSON();
   1368 				assert.equal(json.linkMode, 'imported_file');
   1369 				assert.equal(json.filename, 'test.png');
   1370 				assert.isUndefined(json.path);
   1371 				assert.equal(json.mtime, (yield item.attachmentModificationTime));
   1372 				assert.equal(json.md5, (yield item.attachmentHash));
   1373 			})
   1374 			
   1375 			it("should omit storage values with .skipStorageProperties", function* () {
   1376 				var file = getTestDataDirectory();
   1377 				file.append('test.png');
   1378 				var item = yield Zotero.Attachments.importFromFile({ file });
   1379 				
   1380 				item.attachmentSyncedModificationTime = new Date().getTime();
   1381 				item.attachmentSyncedHash = 'b32e33f529942d73bea4ed112310f804';
   1382 				yield item.saveTx({ skipAll: true });
   1383 				
   1384 				var json = item.toJSON({
   1385 					skipStorageProperties: true
   1386 				});
   1387 				assert.isUndefined(json.mtime);
   1388 				assert.isUndefined(json.md5);
   1389 			});
   1390 			
   1391 			it("should output synced storage values with .syncedStorageProperties", function* () {
   1392 				var item = new Zotero.Item('attachment');
   1393 				item.attachmentLinkMode = 'imported_file';
   1394 				item.fileName = 'test.txt';
   1395 				yield item.saveTx();
   1396 				
   1397 				var mtime = new Date().getTime();
   1398 				var md5 = 'b32e33f529942d73bea4ed112310f804';
   1399 				
   1400 				item.attachmentSyncedModificationTime = mtime;
   1401 				item.attachmentSyncedHash = md5;
   1402 				yield item.saveTx({ skipAll: true });
   1403 				
   1404 				var json = item.toJSON({
   1405 					syncedStorageProperties: true
   1406 				});
   1407 				assert.equal(json.mtime, mtime);
   1408 				assert.equal(json.md5, md5);
   1409 			})
   1410 			
   1411 			it.skip("should output unset storage properties as null", function* () {
   1412 				var item = new Zotero.Item('attachment');
   1413 				item.attachmentLinkMode = 'imported_file';
   1414 				item.fileName = 'test.txt';
   1415 				var id = yield item.saveTx();
   1416 				var json = item.toJSON();
   1417 				
   1418 				assert.isNull(json.mtime);
   1419 				assert.isNull(json.md5);
   1420 			})
   1421 			
   1422 			it("shouldn't include filename or path for linked_url attachments", function* () {
   1423 				var item = new Zotero.Item('attachment');
   1424 				item.attachmentLinkMode = 'linked_url';
   1425 				item.url = "https://www.zotero.org/";
   1426 				var json = item.toJSON();
   1427 				assert.notProperty(json, "filename");
   1428 				assert.notProperty(json, "path");
   1429 			});
   1430 			
   1431 			it("should include inPublications=true for items in My Publications", function* () {
   1432 				var item = createUnsavedDataObject('item');
   1433 				item.inPublications = true;
   1434 				var json = item.toJSON();
   1435 				assert.propertyVal(json, "inPublications", true);
   1436 			});
   1437 			
   1438 			it("shouldn't include inPublications for items not in My Publications in patch mode", function* () {
   1439 				var item = createUnsavedDataObject('item');
   1440 				var json = item.toJSON();
   1441 				assert.notProperty(json, "inPublications");
   1442 			});
   1443 			
   1444 			it("should include inPublications=false for personal-library items not in My Publications in full mode", async function () {
   1445 				var item = createUnsavedDataObject('item', { libraryID: Zotero.Libraries.userLibraryID });
   1446 				var json = item.toJSON({ mode: 'full' });
   1447 				assert.property(json, "inPublications", false);
   1448 			});
   1449 			
   1450 			it("shouldn't include inPublications=false for group items not in My Publications in full mode", function* () {
   1451 				var group = yield getGroup();
   1452 				var item = createUnsavedDataObject('item', { libraryID: group.libraryID });
   1453 				var json = item.toJSON({ mode: 'full' });
   1454 				assert.notProperty(json, "inPublications");
   1455 			});
   1456 		})
   1457 		
   1458 		describe("'full' mode", function () {
   1459 			it("should output all fields", function* () {
   1460 				var itemType = "book";
   1461 				var title = "Test";
   1462 				
   1463 				var item = new Zotero.Item(itemType);
   1464 				item.setField("title", title);
   1465 				var id = yield item.saveTx();
   1466 				item = yield Zotero.Items.getAsync(id);
   1467 				var json = item.toJSON({ mode: 'full' });
   1468 				assert.equal(json.title, title);
   1469 				assert.equal(json.date, "");
   1470 				assert.equal(json.numPages, "");
   1471 			})
   1472 		})
   1473 		
   1474 		describe("'patch' mode", function () {
   1475 			it("should output only fields that differ", function* () {
   1476 				var itemType = "book";
   1477 				var title = "Test";
   1478 				var date = "2015-05-12";
   1479 				
   1480 				var item = new Zotero.Item(itemType);
   1481 				item.setField("title", title);
   1482 				var id = yield item.saveTx();
   1483 				item = yield Zotero.Items.getAsync(id);
   1484 				var patchBase = item.toJSON();
   1485 				
   1486 				item.setField("date", date);
   1487 				yield item.saveTx();
   1488 				var json = item.toJSON({
   1489 					patchBase: patchBase
   1490 				})
   1491 				assert.isUndefined(json.itemType);
   1492 				assert.isUndefined(json.title);
   1493 				assert.equal(json.date, date);
   1494 				assert.isUndefined(json.numPages);
   1495 				assert.isUndefined(json.deleted);
   1496 				assert.isUndefined(json.creators);
   1497 				assert.isUndefined(json.relations);
   1498 				assert.isUndefined(json.tags);
   1499 			})
   1500 			
   1501 			it("should include changed 'deleted' field", function* () {
   1502 				// True to false
   1503 				var item = new Zotero.Item('book');
   1504 				item.deleted = true;
   1505 				var id = yield item.saveTx();
   1506 				item = yield Zotero.Items.getAsync(id);
   1507 				var patchBase = item.toJSON();
   1508 				
   1509 				item.deleted = false;
   1510 				var json = item.toJSON({
   1511 					patchBase: patchBase
   1512 				})
   1513 				assert.isUndefined(json.title);
   1514 				assert.isFalse(json.deleted);
   1515 				
   1516 				// False to true
   1517 				var item = new Zotero.Item('book');
   1518 				item.deleted = false;
   1519 				var id = yield item.saveTx();
   1520 				item = yield Zotero.Items.getAsync(id);
   1521 				var patchBase = item.toJSON();
   1522 				
   1523 				item.deleted = true;
   1524 				var json = item.toJSON({
   1525 					patchBase: patchBase
   1526 				})
   1527 				assert.isUndefined(json.title);
   1528 				assert.strictEqual(json.deleted, 1);
   1529 			})
   1530 			
   1531 			it("should set 'parentItem' to false when cleared", function* () {
   1532 				var item = yield createDataObject('item');
   1533 				var note = new Zotero.Item('note');
   1534 				note.parentID = item.id;
   1535 				// Create initial JSON with parentItem
   1536 				var patchBase = note.toJSON();
   1537 				// Clear parent item and regenerate JSON
   1538 				note.parentID = false;
   1539 				var json = note.toJSON({ patchBase });
   1540 				assert.isFalse(json.parentItem);
   1541 			});
   1542 			
   1543 			it("should include relations if related item was removed", function* () {
   1544 				var item1 = yield createDataObject('item');
   1545 				var item2 = yield createDataObject('item');
   1546 				var item3 = yield createDataObject('item');
   1547 				var item4 = yield createDataObject('item');
   1548 				
   1549 				var relateItems = Zotero.Promise.coroutine(function* (i1, i2) {
   1550 					yield Zotero.DB.executeTransaction(function* () {
   1551 						i1.addRelatedItem(i2);
   1552 						yield i1.save({
   1553 							skipDateModifiedUpdate: true
   1554 						});
   1555 						i2.addRelatedItem(i1);
   1556 						yield i2.save({
   1557 							skipDateModifiedUpdate: true
   1558 						});
   1559 					});
   1560 				});
   1561 				
   1562 				yield relateItems(item1, item2);
   1563 				yield relateItems(item1, item3);
   1564 				yield relateItems(item1, item4);
   1565 				
   1566 				var patchBase = item1.toJSON();
   1567 				
   1568 				item1.removeRelatedItem(item2);
   1569 				yield item1.saveTx();
   1570 				item2.removeRelatedItem(item1);
   1571 				yield item2.saveTx();
   1572 				
   1573 				var json = item1.toJSON({ patchBase });
   1574 				assert.sameMembers(json.relations['dc:relation'], item1.getRelations()['dc:relation']);
   1575 			});
   1576 			
   1577 			it("shouldn't clear storage properties from original in .skipStorageProperties mode", function* () {
   1578 				var item = new Zotero.Item('attachment');
   1579 				item.attachmentLinkMode = 'imported_file';
   1580 				item.attachmentFilename = 'test.txt';
   1581 				item.attachmentContentType = 'text/plain';
   1582 				item.attachmentCharset = 'utf-8';
   1583 				item.attachmentSyncedModificationTime = 1234567890000;
   1584 				item.attachmentSyncedHash = '18d21750c8abd5e3afa8ea89e3dfa570';
   1585 				var patchBase = item.toJSON({
   1586 					syncedStorageProperties: true
   1587 				});
   1588 				item.setNote("Test");
   1589 				var json = item.toJSON({
   1590 					patchBase,
   1591 					skipStorageProperties: true
   1592 				});
   1593 				Zotero.debug(json);
   1594 				assert.equal(json.note, "Test");
   1595 				assert.notProperty(json, "md5");
   1596 				assert.notProperty(json, "mtime");
   1597 			});
   1598 		})
   1599 	})
   1600 	
   1601 	describe("#fromJSON()", function () {
   1602 		it("should clear missing fields", function* () {
   1603 			var item = new Zotero.Item('book');
   1604 			item.setField('title', 'Test');
   1605 			item.setField('date', '2016');
   1606 			item.setField('accessDate', '2015-06-07T20:56:00Z');
   1607 			yield item.saveTx();
   1608 			var json = item.toJSON();
   1609 			// Remove fields, which should cause them to be cleared in fromJSON()
   1610 			delete json.date;
   1611 			delete json.accessDate;
   1612 			
   1613 			item.fromJSON(json);
   1614 			assert.strictEqual(item.getField('title'), 'Test');
   1615 			assert.strictEqual(item.getField('date'), '');
   1616 			assert.strictEqual(item.getField('accessDate'), '');
   1617 		});
   1618 		
   1619 		it("should remove item from collection if 'collections' property not provided", function* () {
   1620 			var collection = yield createDataObject('collection');
   1621 			// Create standalone attachment in collection
   1622 			var attachment = yield importFileAttachment('test.png', { collections: [collection.id] });
   1623 			var item = yield createDataObject('item', { collections: [collection.id] });
   1624 			
   1625 			assert.isTrue(collection.hasItem(attachment.id));
   1626 			var json = attachment.toJSON();
   1627 			json.path = 'storage:test2.png';
   1628 			// Add to parent, which implicitly removes from collection
   1629 			json.parentItem = item.key;
   1630 			delete json.collections;
   1631 			attachment.fromJSON(json);
   1632 			yield attachment.saveTx();
   1633 			assert.isFalse(collection.hasItem(attachment.id));
   1634 		});
   1635 		
   1636 		it("should remove child item from parent if 'parentKey' property not provided", async function () {
   1637 			var item = await createDataObject('item');
   1638 			var note = await createDataObject('item', { itemType: 'note', parentKey: [item.key] });
   1639 			
   1640 			var json = note.toJSON();
   1641 			delete json.parentItem;
   1642 			
   1643 			note.fromJSON(json);
   1644 			await note.saveTx();
   1645 			
   1646 			assert.lengthOf(item.getNotes(), 0);
   1647 		});
   1648 		
   1649 		it("should remove item from trash if 'deleted' property not provided", async function () {
   1650 			var item = await createDataObject('item', { deleted: true });
   1651 			
   1652 			assert.isTrue(item.deleted);
   1653 			
   1654 			var json = item.toJSON();
   1655 			delete json.deleted;
   1656 			
   1657 			item.fromJSON(json);
   1658 			await item.saveTx();
   1659 			
   1660 			assert.isFalse(item.deleted);
   1661 		});
   1662 		
   1663 		it("should remove item from My Publications if 'inPublications' property not provided", async function () {
   1664 			var item = await createDataObject('item', { inPublications: true });
   1665 			
   1666 			assert.isTrue(item.inPublications);
   1667 			
   1668 			var json = item.toJSON();
   1669 			delete json.inPublications;
   1670 			
   1671 			item.fromJSON(json);
   1672 			await item.saveTx();
   1673 			
   1674 			assert.isFalse(item.inPublications);
   1675 		});
   1676 		
   1677 		it("should ignore unknown fields", function* () {
   1678 			var json = {
   1679 				itemType: "journalArticle",
   1680 				title: "Test",
   1681 				foo: "Invalid"
   1682 			};
   1683 			var item = new Zotero.Item;
   1684 			item.fromJSON(json);
   1685 			assert.equal(item.getField('title'), 'Test');
   1686 		})
   1687 		
   1688 		it("should accept ISO 8601 dates", function* () {
   1689 			var json = {
   1690 				itemType: "journalArticle",
   1691 				accessDate: "2015-06-07T20:56:00Z",
   1692 				dateAdded: "2015-06-07T20:57:00Z",
   1693 				dateModified: "2015-06-07T20:58:00Z",
   1694 			};
   1695 			var item = new Zotero.Item;
   1696 			item.fromJSON(json);
   1697 			assert.equal(item.getField('accessDate'), '2015-06-07 20:56:00');
   1698 			assert.equal(item.dateAdded, '2015-06-07 20:57:00');
   1699 			assert.equal(item.dateModified, '2015-06-07 20:58:00');
   1700 		})
   1701 		
   1702 		it("should accept ISO 8601 access date without time", function* () {
   1703 			var json = {
   1704 				itemType: "journalArticle",
   1705 				accessDate: "2015-06-07",
   1706 				dateAdded: "2015-06-07T20:57:00Z",
   1707 				dateModified: "2015-06-07T20:58:00Z",
   1708 			};
   1709 			var item = new Zotero.Item;
   1710 			item.fromJSON(json);
   1711 			assert.equal(item.getField('accessDate'), '2015-06-07');
   1712 			assert.equal(item.dateAdded, '2015-06-07 20:57:00');
   1713 			assert.equal(item.dateModified, '2015-06-07 20:58:00');
   1714 		})
   1715 		
   1716 		it("should ignore non–ISO 8601 dates", function* () {
   1717 			var json = {
   1718 				itemType: "journalArticle",
   1719 				accessDate: "2015-06-07 20:56:00",
   1720 				dateAdded: "2015-06-07 20:57:00",
   1721 				dateModified: "2015-06-07 20:58:00",
   1722 			};
   1723 			var item = new Zotero.Item;
   1724 			item.fromJSON(json);
   1725 			assert.strictEqual(item.getField('accessDate'), '');
   1726 			// DEBUG: Should these be null, or empty string like other fields from getField()?
   1727 			assert.isNull(item.dateAdded);
   1728 			assert.isNull(item.dateModified);
   1729 		})
   1730 		
   1731 		it("should set creators", function* () {
   1732 			var json = {
   1733 				itemType: "journalArticle",
   1734 				creators: [
   1735 					{
   1736 						firstName: "First",
   1737 						lastName: "Last",
   1738 						creatorType: "author"
   1739 					},
   1740 					{
   1741 						name: "Test Name",
   1742 						creatorType: "editor"
   1743 					}
   1744 				]
   1745 			};
   1746 			
   1747 			var item = new Zotero.Item;
   1748 			item.fromJSON(json);
   1749 			var id = yield item.saveTx();
   1750 			assert.sameDeepMembers(item.getCreatorsJSON(), json.creators);
   1751 		})
   1752 		
   1753 		it("should map a base field to an item-specific field", function* () {
   1754 			var item = new Zotero.Item("bookSection");
   1755 			item.fromJSON({
   1756 				"itemType":"bookSection",
   1757 				"publicationTitle":"Publication Title"
   1758 			});
   1759 			assert.equal(item.getField("bookTitle"), "Publication Title");
   1760 		});
   1761 	});
   1762 });