zotero/test/tests/schemaTest.js
Dan Stillman a81af0c268 Don't show Extra migration progress window unnecessarily
The candidate scan counted items whose Extra content parsed as fields
but couldn't actually be migrated (e.g., fields already set on the
item), so the window could appear on every global schema update without
changing anything.

Also use a dedicated headline instead of "Upgrading database…", and show
the window only if the migration is still running after 500 ms.
2026-07-23 23:21:01 -04:00

567 lines
18 KiB
JavaScript

describe("Zotero.Schema", function () {
describe("#initializeSchema()", function () {
it("should set last client version", async function () {
await resetDB({
thisArg: this,
skipBundledFiles: true
});
var sql = "SELECT value FROM settings WHERE setting='client' AND key='lastVersion'";
var lastVersion = await Zotero.DB.valueQueryAsync(sql);
assert.equal(await Zotero.DB.valueQueryAsync(sql), Zotero.version);
});
});
describe("#updateSchema()", function () {
it("should set last client version", async function () {
var sql = "REPLACE INTO settings (setting, key, value) VALUES ('client', 'lastVersion', ?)";
await Zotero.DB.queryAsync(sql, "5.0old");
await Zotero.Schema.updateSchema();
var sql = "SELECT value FROM settings WHERE setting='client' AND key='lastVersion'";
var lastVersion = await Zotero.DB.valueQueryAsync(sql);
assert.equal(await Zotero.DB.valueQueryAsync(sql), Zotero.version);
});
it("shouldn't repeat forced backup when retrying a failed update", async function () {
// Backups are disabled in the test profile
Zotero.Prefs.set('backup.numBackups', 2);
await Zotero.DB.queryAsync(
"DELETE FROM settings WHERE setting='backup' AND key='lastSchemaUpdateState'"
);
await Zotero.Schema.setIntegrityCheckRequired(true);
var backupSpy = sinon.spy(Zotero.DB, 'backUpDatabase');
var integrityCheckStub = sinon.stub(Zotero.Schema, 'integrityCheck')
.rejects(new Error("Simulated failure"));
try {
// First attempt makes a forced backup and fails
var e = await getPromiseError(Zotero.Schema.updateSchema());
assert.equal(e.message, "Simulated failure");
assert.equal(backupSpy.callCount, 1);
// Retrying with the same pending state doesn't make another backup
e = await getPromiseError(Zotero.Schema.updateSchema());
assert.equal(e.message, "Simulated failure");
assert.equal(backupSpy.callCount, 1);
// If the backup no longer exists, retrying makes a new one
await IOUtils.remove(Zotero.DB.path + '.bak');
e = await getPromiseError(Zotero.Schema.updateSchema());
assert.equal(e.message, "Simulated failure");
assert.equal(backupSpy.callCount, 2);
// A successful update clears the stored state
integrityCheckStub.restore();
await Zotero.Schema.updateSchema();
assert.isFalse(await Zotero.DB.valueQueryAsync(
"SELECT value FROM settings WHERE setting='backup' AND key='lastSchemaUpdateState'"
));
// A new pending state makes a fresh backup
await Zotero.Schema.setIntegrityCheckRequired(true);
await Zotero.Schema.updateSchema();
assert.equal(backupSpy.callCount, 3);
}
finally {
integrityCheckStub.restore();
backupSpy.restore();
}
});
});
describe("Global Schema", function () {
var schemaJSON, schema;
before(async function () {
schemaJSON = await Zotero.File.getResourceAsync('resource://zotero/schema/global/schema.json');
});
beforeEach(async function () {
await resetDB({
thisArg: this,
skipBundledFiles: true
});
schema = JSON.parse(schemaJSON);
});
after(async function () {
await resetDB({
thisArg: this,
skipBundledFiles: true
});
});
describe("#migrateExtraFields()", function () {
async function migrate(options) {
schema.version++;
schema.itemTypes.find(x => x.itemType == 'book').fields.splice(0, 1, { field: 'fooBar' })
var newLocales = {};
Object.keys(schema.locales).forEach((locale) => {
var o = schema.locales[locale];
o.fields.fooBar = 'Foo Bar';
newLocales[locale] = o;
});
await Zotero.Schema._updateGlobalSchemaForTest(schema);
await Zotero.Schema.migrateExtraFields(options);
}
it("should add a new field and migrate values from Extra", async function () {
var item = await createDataObject('item', { itemType: 'book' });
item.setField('numPages', "10");
item.setField('extra', 'Foo Bar: This is a value.\nnumber-of-pages: 11\nThis is another line.');
item.synced = true;
await item.saveTx();
await migrate();
assert.isNumber(Zotero.ItemFields.getID('fooBar'));
assert.equal(Zotero.ItemFields.getLocalizedString('fooBar'), 'Foo Bar');
assert.equal(item.getField('fooBar'), 'This is a value.');
// Existing fields shouldn't be overwritten and should be left in Extra
assert.equal(item.getField('numPages'), '10');
assert.equal(item.getField('extra'), 'number-of-pages: 11\nThis is another line.');
assert.isFalse(item.synced);
});
it("should migrate valid creator", async function () {
var item = await createDataObject('item', { itemType: 'book' });
item.setCreators([
{
firstName: 'Abc',
lastName: 'Def',
creatorType: 'author',
fieldMode: 0
}
]);
item.setField('extra', 'editor: Last || First\nFoo: Bar');
item.synced = true;
await item.saveTx();
await migrate();
var creators = item.getCreators();
assert.lengthOf(creators, 2);
assert.propertyVal(creators[0], 'firstName', 'Abc');
assert.propertyVal(creators[0], 'lastName', 'Def');
assert.propertyVal(creators[0], 'creatorTypeID', Zotero.CreatorTypes.getID('author'));
assert.propertyVal(creators[1], 'firstName', 'First');
assert.propertyVal(creators[1], 'lastName', 'Last');
assert.propertyVal(creators[1], 'creatorTypeID', Zotero.CreatorTypes.getID('editor'));
assert.equal(item.getField('extra'), 'Foo: Bar');
assert.isFalse(item.synced);
});
it("shouldn't migrate creator not valid for item type", async function () {
var item = await createDataObject('item', { itemType: 'book' });
item.setCreators([
{
firstName: 'Abc',
lastName: 'Def',
creatorType: 'author',
fieldMode: 0
}
]);
item.setField('extra', 'container-author: Last || First\nFoo: Bar');
item.synced = true;
await item.saveTx();
await migrate();
var creators = item.getCreators();
assert.lengthOf(creators, 1);
assert.propertyVal(creators[0], 'firstName', 'Abc');
assert.propertyVal(creators[0], 'lastName', 'Def');
assert.propertyVal(creators[0], 'creatorTypeID', Zotero.CreatorTypes.getID('author'));
assert.equal(item.getField('extra'), 'container-author: Last || First\nFoo: Bar');
assert.isTrue(item.synced);
});
it("shouldn't migrate fields in read-only library", async function () {
var library = await createGroup({ editable: false, filesEditable: false });
var item = createUnsavedDataObject('item', { libraryID: library.libraryID, itemType: 'book' });
item.setField('extra', 'Foo Bar: This is a value.');
item.synced = true;
await item.saveTx({
skipEditCheck: true
});
await migrate();
assert.isNumber(Zotero.ItemFields.getID('fooBar'));
assert.equal(item.getField('fooBar'), '');
assert.equal(item.getField('extra'), 'Foo Bar: This is a value.');
assert.isTrue(item.synced);
});
it("shouldn't report progress if Extra fields can't be migrated", async function () {
var item = await createDataObject('item', { itemType: 'book' });
item.setField('numPages', "10");
item.setField('extra', 'number-of-pages: 11');
item.synced = true;
await item.saveTx();
var onProgress = sinon.spy();
await migrate({ onProgress });
assert.isFalse(onProgress.called);
assert.equal(item.getField('extra'), 'number-of-pages: 11');
assert.isTrue(item.synced);
});
it("should change item type if 'type:' is defined", async function () {
var item = await createDataObject('item', { itemType: 'document' });
item.setField('extra', 'type: personal_communication');
item.synced = true;
await item.saveTx();
await migrate();
assert.equal(item.itemTypeID, Zotero.ItemTypes.getID('letter'));
assert.equal(item.getField('extra'), '');
assert.isFalse(item.synced);
});
it("should remove 'type:' line for CSL type if item is the first mapped Zotero type", async function () {
var item = await createDataObject('item', { itemType: 'letter' });
item.setField('extra', 'type: personal_communication');
item.synced = true;
await item.saveTx();
await migrate();
assert.equal(item.itemTypeID, Zotero.ItemTypes.getID('letter'));
assert.equal(item.getField('extra'), '');
assert.isFalse(item.synced);
});
it("should remove 'type:' line for CSL type if item is a non-primary mapped Zotero type", async function () {
var item = await createDataObject('item', { itemType: 'instantMessage' });
item.setField('extra', 'type: personal_communication');
item.synced = true;
await item.saveTx();
await migrate();
assert.equal(item.itemTypeID, Zotero.ItemTypes.getID('instantMessage'));
assert.equal(item.getField('extra'), '');
assert.isFalse(item.synced);
});
it("should move existing fields that would be invalid in the new 'type:' type to Extra", async function () {
var item = await createDataObject('item', { itemType: 'book' });
item.setField('numPages', '123');
item.setField('extra', 'type: article-journal\nJournal Abbreviation: abc.\nnumPages: 234');
item.synced = true;
await item.saveTx();
await migrate();
assert.equal(item.itemTypeID, Zotero.ItemTypes.getID('journalArticle'));
assert.equal(item.getField('journalAbbreviation'), 'abc.');
// Migrated real field should be placed at beginning, followed by unused line from Extra
assert.equal(item.getField('extra'), 'Num Pages: 123\nnumPages: 234');
assert.isFalse(item.synced);
});
it("shouldn't migrate invalid item type", async function () {
var item = await createDataObject('item', { itemType: 'book' });
item.setField('numPages', 30);
item.setCreators(
[
{
firstName: 'Abc',
lastName: 'Def',
creatorType: 'author',
fieldMode: 0
},
{
firstName: 'Ghi',
lastName: 'Jkl',
creatorType: 'author',
fieldMode: 0
}
]
);
item.setField('extra', 'type: invalid');
item.synced = true;
await item.saveTx();
await migrate();
assert.equal(item.getField('numPages'), 30);
var creators = item.getCreators();
assert.lengthOf(creators, 2);
assert.equal(item.itemTypeID, Zotero.ItemTypes.getID('book'));
assert.equal(item.getField('extra'), 'type: invalid');
assert.isTrue(item.synced);
});
it("shouldn't migrate certain fields temporarily", async function () {
var item = await createDataObject('item', { itemType: 'book' });
var extra = 'event-place: Event Place\npublisher-place: Publisher Place\nIssued: 2020/2023';
item.setField('extra', extra);
item.synced = true;
await item.saveTx();
await migrate();
assert.equal(item.getField('place'), '');
assert.equal(item.getField('date'), '');
assert.equal(item.getField('extra'), extra);
});
});
});
describe("Repository Check", function () {
describe("Notices", function () {
var win;
var server;
before(async function () {
// We need bundled files
await resetDB({
thisArg: this
});
win = await loadZoteroPane();
});
beforeEach(function () {
Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
server = sinon.fakeServer.create();
server.autoRespond = true;
});
afterEach(function () {
Zotero.Prefs.clear('hiddenNotices');
});
after(function () {
win.close();
Zotero.HTTP.mock = null;
});
function createResponseWithMessage(message) {
server.respond(function (req) {
if (req.method != "POST" || !req.url.includes('/repo/updated')) {
return;
}
req.respond(
200,
{
"Content-Type": "application/xml"
},
'<xml>'
+ '<currentTime>1630219842</currentTime>'
+ message
+ '</xml>'
);
});
}
it("should show dialog if repo returns a message", async function () {
createResponseWithMessage(
`<message infoURL="https://example.com">This is a warning</message>`
);
var promise = waitForDialog(function (dialog) {
var html = dialog.document.documentElement.outerHTML;
assert.include(html, "This is a warning");
});
await Zotero.Schema.updateFromRepository(3);
await promise;
// Don't show id-less message again for a day
var spy = sinon.spy(Zotero, 'debug');
await Zotero.Schema.updateFromRepository(3);
assert.notEqual(spy.args.findIndex(x => {
return typeof x[0] == 'string' && x[0].startsWith("Not showing hidden");
}), -1);
spy.restore();
});
it("shouldn't show message with id again for 1 day even if not hidden", async function () {
var id = Zotero.Utilities.randomString();
createResponseWithMessage(
`<message id="${id}" infoURL="https://example.com">This is a warning</message>`
);
var promise = waitForDialog();
await Zotero.Schema.updateFromRepository(3);
await promise;
// Make sure notice is hidden for 1 day
var hiddenNotices;
var tries = 0;
var ttl = 86400;
while (tries < 100) {
tries++;
hiddenNotices = Zotero.Prefs.get('hiddenNotices');
if (!hiddenNotices) {
await Zotero.Promise.delay(10);
continue;
}
hiddenNotices = JSON.parse(hiddenNotices);
assert.property(hiddenNotices, id);
assert.approximately(hiddenNotices[id], Math.round(Date.now() / 1000) + ttl, 10);
break;
}
});
it("shouldn't show message with id again for 30 days", async function () {
var id = Zotero.Utilities.randomString();
createResponseWithMessage(
`<message id="${id}" infoURL="https://example.com">This is a warning</message>`
);
var promise = waitForDialog(function (dialog) {
var doc = dialog.document;
var innerHTML = doc.documentElement.innerHTML;
assert.include(innerHTML, "This is a warning");
assert.include(innerHTML, Zotero.getString('general.dontShowAgainFor', 30, 30));
// Check "Don't show again"
doc.getElementById('checkbox').click();
});
await Zotero.Schema.updateFromRepository(3);
await promise;
// Make sure notice is hidden for 30 days
var hiddenNotices;
var tries = 0;
var ttl = 30 * 86400;
while (tries < 100) {
tries++;
hiddenNotices = Zotero.Prefs.get('hiddenNotices');
if (!hiddenNotices) {
await Zotero.Promise.delay(10);
continue;
}
hiddenNotices = JSON.parse(hiddenNotices);
assert.property(hiddenNotices, id);
assert.approximately(hiddenNotices[id], Math.round(Date.now() / 1000) + ttl, 10);
break;
}
});
it("shouldn't show message with id if before expiration", async function () {
var id = Zotero.Utilities.randomString();
createResponseWithMessage(
`<message id="${id}" infoURL="https://example.com">This is a warning</message>`
);
// Set expiration for 30 days from now
var ttl = 30 * 86400;
Zotero.Prefs.set(
'hiddenNotices',
JSON.stringify({
[id]: Math.round(Date.now() / 1000) + ttl
})
);
// Message should be hidden
var spy = sinon.spy(Zotero, 'debug');
await Zotero.Schema.updateFromRepository(3);
assert.notEqual(spy.args.findIndex(x => {
return typeof x[0] == 'string' && x[0].startsWith("Not showing hidden");
}), -1);
spy.restore();
});
});
});
describe("#integrityCheck()", function () {
before(function* () {
yield resetDB({
thisArg: this,
skipBundledFiles: true
});
})
it("should create missing tables unless 'skipReconcile' is true", async function () {
await Zotero.DB.queryAsync("DROP TABLE retractedItems");
assert.isFalse(await Zotero.DB.tableExists('retractedItems'));
assert.isTrue(await Zotero.Schema.integrityCheck(false, { skipReconcile: true }));
assert.isFalse(await Zotero.Schema.integrityCheck());
assert.isTrue(await Zotero.Schema.integrityCheck(true));
assert.isTrue(await Zotero.DB.tableExists('retractedItems'));
});
it("should repair a foreign key violation", async function () {
assert.isTrue(await Zotero.Schema.integrityCheck());
await Zotero.DB.queryAsync("PRAGMA foreign_keys = OFF");
await Zotero.DB.queryAsync("INSERT INTO itemTags VALUES (1234,1234,0)");
await Zotero.DB.queryAsync("PRAGMA foreign_keys = ON");
assert.isFalse(await Zotero.Schema.integrityCheck());
assert.isTrue(await Zotero.Schema.integrityCheck(true));
assert.isTrue(await Zotero.Schema.integrityCheck());
})
it("should repair invalid nesting between two collections", async function () {
var c1 = await createDataObject('collection');
var c2 = await createDataObject('collection', { parentID: c1.id });
await Zotero.DB.queryAsync(
"UPDATE collections SET parentCollectionID=? WHERE collectionID=?",
[c2.id, c1.id]
);
await assert.isFalse(await Zotero.Schema.integrityCheck());
await assert.isTrue(await Zotero.Schema.integrityCheck(true));
await assert.isTrue(await Zotero.Schema.integrityCheck());
});
it("should repair invalid nesting between three collections", async function () {
var c1 = await createDataObject('collection');
var c2 = await createDataObject('collection', { parentID: c1.id });
var c3 = await createDataObject('collection', { parentID: c2.id });
await Zotero.DB.queryAsync(
"UPDATE collections SET parentCollectionID=? WHERE collectionID=?",
[c3.id, c2.id]
);
await assert.isFalse(await Zotero.Schema.integrityCheck());
await assert.isTrue(await Zotero.Schema.integrityCheck(true));
await assert.isTrue(await Zotero.Schema.integrityCheck());
});
it("should allow embedded-image attachments under notes", async function () {
var item = await createDataObject('item', { itemType: 'note' });
await createEmbeddedImage(item);
await assert.isTrue(await Zotero.Schema.integrityCheck());
});
})
describe("Database Upgrades", function () {
after(async function () {
await resetDB({
thisArg: this,
skipBundledFiles: true,
});
});
it("should upgrade 4.0 database", async function () {
await resetDB({
thisArg: this,
skipBundledFiles: true,
dbFile: OS.Path.join(getTestDataDirectory().path, 'zotero-4.0.sqlite.zip'),
// Backups are disabled in the test profile
prefs: { 'backup.numBackups': 2 }
});
// Make sure we can open the Zotero pane without errors
win = await loadZoteroPane();
win.close();
// A backup should have been made before the upgrade
assert.isTrue(
await IOUtils.exists(PathUtils.join(Zotero.DataDirectory.dir, 'zotero.sqlite.bak'))
);
});
});
})