Repository: web-scrobbler/web-scrobbler
Pull request #3693 (2023-05-03 13:09:55)

Reimplement tests
Some tests were removed in v3 due to the complexity of reimplementing them.
#3671 removed a fair amount of that complexity, so I went back and reimplemented all of it.

Some of this is VERY cursed because these tests were not written with typescript in mind, but they seem to work.


Patch:
diff --git a/src/core/object/pipeline/regex-edits.ts b/src/core/object/pipeline/regex-edits.ts
index 2257ed41..fd801f27 100644
--- a/src/core/object/pipeline/regex-edits.ts
+++ b/src/core/object/pipeline/regex-edits.ts
@@ -12,7 +12,7 @@ export async function process(song: Song): Promise<void> {
 	}
 	try {
 		await RegexEdits.loadSongInfo(song);
-	} catch (e) {
-		// Do nothing
+	} catch (err) {
+		console.error(err);
 	}
 }
diff --git a/src/core/object/pipeline/user-input.ts b/src/core/object/pipeline/user-input.ts
index c842cee4..f1a590e3 100644
--- a/src/core/object/pipeline/user-input.ts
+++ b/src/core/object/pipeline/user-input.ts
@@ -14,8 +14,8 @@ export async function process(song: Song): Promise<void> {
 	let isSongInfoLoaded = false;
 	try {
 		isSongInfoLoaded = await SavedEdits.loadSongInfo(song);
-	} catch (e) {
-		// Do nothing
+	} catch (err) {
+		console.error(err);
 	}
 
 	song.flags.isCorrectedByUser = isSongInfoLoaded;
diff --git a/src/core/storage/custom-patterns.ts b/src/core/storage/custom-patterns.ts
index 31941a28..270fbd0c 100644
--- a/src/core/storage/custom-patterns.ts
+++ b/src/core/storage/custom-patterns.ts
@@ -23,11 +23,7 @@ export async function setPatterns(
 	connectorId: string,
 	patterns: string[]
 ): Promise<void> {
-	const data = await storage.get();
-	if (!data) {
-		throw new Error('No custom patterns found');
-	}
-
+	const data = (await storage.get()) ?? {};
 	data[connectorId] = patterns;
 	await storage.set(data);
 }
@@ -37,11 +33,7 @@ export async function setPatterns(
  * @param connectorId - Connector ID
  */
 export async function resetPatterns(connectorId: string): Promise<void> {
-	const data = await storage.get();
-	if (!data) {
-		throw new Error('No custom patterns found');
-	}
-
+	const data = (await storage.get()) ?? {};
 	delete data[connectorId];
 	await storage.set(data);
 }
diff --git a/src/core/storage/saved-edits.model.ts b/src/core/storage/saved-edits.model.ts
index 6d1affae..4b07047c 100644
--- a/src/core/storage/saved-edits.model.ts
+++ b/src/core/storage/saved-edits.model.ts
@@ -11,7 +11,7 @@ import { SavedEdit } from '@/core/storage/options';
 type K = typeof LOCAL_CACHE;
 type V = DataModels[K];
 type T = Record<K, V>;
-type TContent = T[K];
+export type TContent = T[K];
 
 export default abstract class SavedEditsModel extends CustomStorage<K> {
 	/**
diff --git a/src/core/storage/wrapper.ts b/src/core/storage/wrapper.ts
index 24f8b7db..d164ba6a 100644
--- a/src/core/storage/wrapper.ts
+++ b/src/core/storage/wrapper.ts
@@ -230,9 +230,6 @@ export default class StorageWrapper<K extends keyof DataModels> {
 			if (hiddenKeys.includes(key)) {
 				return hideObjectValue(value);
 			}
-			if (typeof value !== 'string' && typeof value !== 'number') {
-				return 'expected a number or a string';
-			}
 
 			return value;
 		};


Test patch:
diff --git a/tests/background/connectors.test.ts b/tests/background/connectors.test.ts
new file mode 100644
index 00000000..aacde5ec
--- /dev/null
+++ b/tests/background/connectors.test.ts
@@ -0,0 +1,99 @@
+import fs from 'fs';
+import path from 'path';
+
+import { expect, assert, describe, it } from 'vitest';
+
+import connectors, { ConnectorMeta } from '@/core/connectors';
+import * as UrlMatch from '@/util/url-match';
+
+const PROP_TYPES: {
+	allFrames: 'boolean';
+	matches: 'array';
+	label: 'string';
+	js: 'string';
+	id: 'string';
+} = {
+	allFrames: 'boolean',
+	matches: 'array',
+	label: 'string',
+	js: 'string',
+	id: 'string',
+};
+const REQUIRED_PROPS: ['label', 'js', 'id'] = ['label', 'js', 'id'];
+
+function testProps(entry: ConnectorMeta) {
+	for (const prop of REQUIRED_PROPS) {
+		assert(entry[prop], `Missing property: ${prop}`);
+	}
+
+	for (const _prop in entry) {
+		const prop = _prop as keyof ConnectorMeta;
+		const type = PROP_TYPES[prop];
+
+		assert(type, `Missing property: ${prop}`);
+		expect(entry[prop]).to.be.a(type);
+	}
+}
+
+function testMatches(entry: ConnectorMeta) {
+	if (!entry.matches) {
+		return;
+	}
+
+	assert(entry.matches.length !== 0, 'Property is empty: matches');
+
+	for (const m of entry.matches) {
+		assert(UrlMatch.createPattern(m), `URL pattern is invalid: ${m}`);
+	}
+}
+
+function testPaths(entry: ConnectorMeta) {
+	if (!entry.js) {
+		return;
+	}
+
+	const jsPath = path.join(
+		__dirname,
+		'../../src/connectors',
+		entry.js.replace('.js', '.ts')
+	);
+	try {
+		fs.statSync(jsPath);
+	} catch (e) {
+		throw new Error(`File is missing: ${entry.js}`);
+	}
+}
+
+function testUniqueness(entry: ConnectorMeta) {
+	for (const connector of connectors) {
+		if (connector.label === entry.label) {
+			continue;
+		}
+
+		assert(entry.id !== connector.id, `Id is not unique: ${entry.label}`);
+	}
+}
+
+function runTests() {
+	for (const entry of connectors) {
+		describe(entry.label, () => {
+			it('should have valid prop types', () => {
+				testProps(entry);
+			});
+
+			it('should have valid URL matches', () => {
+				testMatches(entry);
+			});
+
+			it('should have js files for', () => {
+				testPaths(entry);
+			});
+
+			it('should have unique id', () => {
+				testUniqueness(entry);
+			});
+		});
+	}
+}
+
+runTests();
diff --git a/tests/background/custom-patterns.test.ts b/tests/background/custom-patterns.test.ts
new file mode 100644
index 00000000..c92c1fc9
--- /dev/null
+++ b/tests/background/custom-patterns.test.ts
@@ -0,0 +1,42 @@
+import { expect, describe, it, beforeAll, afterAll } from 'vitest';
+
+import webextensionPolyfill from '#/mocks/webextension-polyfill';
+import * as CustomPatterns from '@/core/storage/custom-patterns';
+
+/**
+ * Run all tests.
+ */
+function runTests() {
+	describe('Test custom patterns', () => {
+		beforeAll(() => {
+			webextensionPolyfill.reset();
+		});
+
+		afterAll(() => {
+			webextensionPolyfill.reset();
+		});
+
+		it('should return null before patterns have been configured', async () => {
+			const data = await CustomPatterns.getAllPatterns();
+			expect(data).to.be.null;
+		});
+
+		it('should set patterns for connector', async () => {
+			const patterns = ['1', '2'];
+			const expectedData = {
+				connector: patterns,
+			};
+			await CustomPatterns.setPatterns('connector', patterns);
+			const data = await CustomPatterns.getAllPatterns();
+			expect(expectedData).to.deep.equal(data);
+		});
+
+		it('should clear custom patterns', async () => {
+			await CustomPatterns.resetPatterns('connector');
+			const data = await CustomPatterns.getAllPatterns();
+			expect(data).to.deep.equal({});
+		});
+	});
+}
+
+runTests();
diff --git a/tests/background/regex.test.ts b/tests/background/regex-edits.test.ts
similarity index 97%
rename from tests/background/regex.test.ts
rename to tests/background/regex-edits.test.ts
index 2fbab5fc..d38b989e 100644
--- a/tests/background/regex.test.ts
+++ b/tests/background/regex-edits.test.ts
@@ -1,4 +1,4 @@
-import WebextensionPolyfillMocker from '#/mocks/webextension-polyfill';
+import webextensionPolyfill from '#/mocks/webextension-polyfill';
 import Pipeline from '@/core/object/pipeline/pipeline';
 import Song from '@/core/object/song';
 import { SavedEdit } from '@/core/storage/options';
@@ -14,13 +14,12 @@ import { getConnectorById } from '@/util/util-connector';
 import { randomBytes } from 'crypto';
 import { beforeEach, describe, expect, it } from 'vitest';
 
-const mocker = new WebextensionPolyfillMocker();
 const pipeline = new Pipeline();
 
 describe('Should edit Regex', () => {
 	beforeEach(() => {
-		mocker.reset();
-		mocker.setUser();
+		webextensionPolyfill.reset();
+		webextensionPolyfill.setUser();
 	});
 
 	it('Should add edits to storage', async () => {
diff --git a/tests/background/saved-edits.test.ts b/tests/background/saved-edits.test.ts
new file mode 100644
index 00000000..b52fb1d5
--- /dev/null
+++ b/tests/background/saved-edits.test.ts
@@ -0,0 +1,237 @@
+import { expect, describe, it, beforeAll } from 'vitest';
+
+import webextensionPolyfill from '#/mocks/webextension-polyfill';
+('#/mocks/webextension-polyfill');
+
+import Song from '@/core/object/song';
+import { getConnectorById } from '@/util/util-connector';
+import { SavedEdit } from '@/core/storage/options';
+import savedEdits from '@/core/storage/saved-edits';
+import SavedEditsModel from '@/core/storage/saved-edits.model';
+
+const editedInfo = {
+	artist: 'ArtistEdited',
+	album: 'AlbumEdited',
+	track: 'TrackEdited',
+	albumArtist: null,
+};
+
+const connectorStub = getConnectorById('youtube')!;
+/**
+ * Run all tests.
+ */
+function runTests() {
+	describe('should throw an error for empty songs', testSaveEmptySong);
+
+	describe('clear storage', testClearStorage);
+	describe('should not load not a song', testLoadNoSavedSong);
+	describe('should remove song from storage', testRemoveSongInfo);
+	describe('should overwrite edited song info', testSaveOverwriteSong);
+	describe(
+		'should save and load a song with unique ID',
+		testSaveLoadSongWithId
+	);
+	describe(
+		'should save and load a song with no unique ID',
+		testSaveLoadSongWithNoId
+	);
+	describe(
+		'should save and load a song (with fallback)',
+		testSaveLoadSongFallback
+	);
+}
+
+function testSaveEmptySong() {
+	emptySavedEdits();
+	const emptySong = makeNonProcessedSong();
+
+	it('should throw an error while loading info of an empty song', async () => {
+		expect(savedEdits.loadSongInfo(emptySong)).rejects.to.deep.equal(
+			new Error('Empty song')
+		);
+	});
+
+	it('should throw an error while saving an empty song', async () => {
+		expect(
+			savedEdits.saveSongInfo(emptySong, editedInfo)
+		).rejects.to.deep.equal(new Error('Empty song'));
+	});
+}
+
+function testClearStorage() {
+	const song = makeNonProcessedSong('Artist', 'Track', 'Album');
+	const savedEdits = makeSavedEdits({
+		song,
+		edit: editedInfo,
+	});
+
+	it('should clear storage', () => {
+		return savedEdits.clear();
+	});
+
+	it('should not load song', async () => {
+		await expectSongInfoNotLoaded(savedEdits, song);
+	});
+}
+
+function testLoadNoSavedSong() {
+	emptySavedEdits();
+
+	const song = makeNonProcessedSong('Artist', 'Track', 'Album');
+
+	it('should not load not saved song', async () => {
+		const songInfo = await savedEdits.loadSongInfo(song);
+		expect(songInfo).to.be.false;
+	});
+}
+
+function testSaveLoadSongWithNoId() {
+	beforeAll(() => {
+		emptySavedEdits();
+	});
+
+	const songWithNoId = makeNonProcessedSong('Artist', 'Track', 'Album');
+
+	it('should return false for song with no unique ID', async () => {
+		await expectSongInfoNotLoaded(savedEdits, songWithNoId);
+	});
+
+	it('should save edited info of song with no unique ID', () => {
+		return savedEdits.saveSongInfo(songWithNoId, editedInfo);
+	});
+
+	it('should load edited info of song with no unique ID', async () => {
+		await expectSongInfoLoaded(savedEdits, songWithNoId, editedInfo);
+	});
+}
+
+function testSaveLoadSongWithId() {
+	beforeAll(() => {
+		emptySavedEdits();
+	});
+	const songWitId = makeNonProcessedSong(
+		'Artist',
+		'Track',
+		'Album',
+		'uniqueId'
+	);
+
+	it('should return false for song with unique ID', async () => {
+		await expectSongInfoNotLoaded(savedEdits, songWitId);
+	});
+
+	it('should save edited info of song with unique ID', () => {
+		return savedEdits.saveSongInfo(songWitId, editedInfo);
+	});
+
+	it('should load edited info of song with unique ID', async () => {
+		await expectSongInfoLoaded(savedEdits, songWitId, editedInfo);
+	});
+}
+
+function testSaveOverwriteSong() {
+	emptySavedEdits();
+
+	const editedInfo1 = {
+		artist: 'ArtistEdited1',
+		album: 'AlbumEdited1',
+		track: 'TrackEdited1',
+		albumArtist: null,
+	};
+	const editedInfo2 = {
+		artist: 'ArtistEdited2',
+		album: 'AlbumEdited2',
+		track: 'TrackEdited2',
+		albumArtist: null,
+	};
+	const song = makeNonProcessedSong('Artist', 'Track', 'Album');
+
+	it('should save edited info of song with no album', async () => {
+		await savedEdits.saveSongInfo(song, editedInfo1);
+		await savedEdits.saveSongInfo(song, editedInfo2);
+
+		await expectSongInfoLoaded(savedEdits, song, editedInfo2);
+	});
+}
+
+function testSaveLoadSongFallback() {
+	emptySavedEdits();
+
+	const songWithNoAlbum = makeNonProcessedSong('Artist', 'Track');
+	const songWithAlbum = makeNonProcessedSong('Artist', 'Track', 'Album');
+
+	it('should save edited info of song with no album', () => {
+		return savedEdits.saveSongInfo(songWithNoAlbum, editedInfo);
+	});
+
+	it('should load edited info of song with no album', async () => {
+		await expectSongInfoLoaded(savedEdits, songWithNoAlbum, editedInfo);
+	});
+
+	it('should load edited info of song with album', async () => {
+		await expectSongInfoLoaded(savedEdits, songWithAlbum, editedInfo);
+	});
+}
+
+function testRemoveSongInfo() {
+	const song = makeNonProcessedSong('Artist', 'Track', 'Album');
+	const savedEdits = makeSavedEdits({
+		song,
+		edit: editedInfo,
+	});
+
+	it('should remove saved song', async () => {
+		await savedEdits.removeSongInfo(song);
+	});
+
+	it('should not load removed song', async () => {
+		await expectSongInfoNotLoaded(savedEdits, song);
+	});
+}
+
+function makeNonProcessedSong(
+	artist?: string,
+	track?: string,
+	album?: string,
+	uniqueID?: string
+) {
+	return new Song({ artist, track, album, uniqueID }, connectorStub);
+}
+
+async function expectSongInfoLoaded(
+	model: SavedEditsModel,
+	song: Song,
+	editedInfo: SavedEdit
+) {
+	const isLoaded = await model.loadSongInfo(song);
+
+	const { artist, album, track, albumArtist } = song.processed;
+	const loadedInfo = { artist, album, track, albumArtist };
+
+	expect(isLoaded).to.be.true;
+	expect(loadedInfo).to.be.deep.equal(editedInfo);
+}
+
+async function expectSongInfoNotLoaded(model: SavedEditsModel, song: Song) {
+	const songInfo = await model.loadSongInfo(song);
+	expect(songInfo).be.false;
+}
+
+function makeSavedEdits(
+	...edits: {
+		song: Song;
+		edit: SavedEdit;
+	}[]
+) {
+	webextensionPolyfill.reset();
+	for (const edit of edits) {
+		savedEdits.saveSongInfo(edit.song, edit.edit);
+	}
+	return savedEdits;
+}
+
+function emptySavedEdits() {
+	webextensionPolyfill.reset();
+}
+
+runTests();
diff --git a/tests/background/storage.test.ts b/tests/background/storage.test.ts
new file mode 100644
index 00000000..fcb9cf3c
--- /dev/null
+++ b/tests/background/storage.test.ts
@@ -0,0 +1,96 @@
+/**
+ * Tests for 'BrowserStorage' module.
+ */
+
+import '#/mocks/webextension-polyfill';
+import * as BrowserStorage from '@/core/storage/browser-storage';
+import StorageWrapper from '@/core/storage/wrapper';
+import { describe, it, expect } from 'vitest';
+
+/**
+ * Test storage object.
+ * @param type - Storage type
+ * @param storage - Storage instance
+ */
+function testStorage<K extends BrowserStorage.StorageNamespace>(
+	type: K,
+	storage: StorageWrapper<K>
+) {
+	describe(`${type} storage`, () => {
+		it('should return empty object', async () => {
+			const data = await storage.get();
+			expect(data).to.be.null;
+		});
+
+		it('should set key value', async () => {
+			const newData = { test: 'ok' };
+			// @ts-expect-error
+			await storage.set(newData);
+
+			const data = await storage.get();
+			expect(newData).to.be.deep.equal(data);
+		});
+
+		it('should update storage', async () => {
+			const newData = { test: 'ok', key: 'value' };
+			const dataToAdd = { key: 'value' };
+			// @ts-expect-error
+			await storage.update(dataToAdd);
+			const data = await storage.get();
+			expect(newData).to.deep.equal(data);
+		});
+
+		it('should clear storage', async () => {
+			await storage.clear();
+			const data = await storage.get();
+			expect(data).to.be.null;
+		});
+	});
+}
+
+/**
+ * Run all tests.
+ */
+function runTests() {
+	const storages = {
+		// @ts-expect-error
+		local: BrowserStorage.getLocalStorage('Local'),
+		// @ts-expect-error
+		sync: BrowserStorage.getSyncStorage('Sync'),
+	};
+
+	for (const type in storages) {
+		// @ts-expect-error
+		const storage = storages[type];
+		// @ts-expect-error
+		testStorage(type, storage);
+	}
+
+	testBrowserStorage();
+}
+
+function testBrowserStorage() {
+	it('should return local storage', () => {
+		const storage = BrowserStorage.getScrobblerStorage('LastFM');
+		expect(storage).to.be.an('object');
+	});
+
+	it('should return local storage', () => {
+		const storage = BrowserStorage.getStorage(BrowserStorage.CORE);
+		expect(storage).to.be.an('object');
+	});
+
+	it('should return sync storage', () => {
+		const storage = BrowserStorage.getStorage(BrowserStorage.OPTIONS);
+		expect(storage).to.be.an('object');
+	});
+
+	it('should throw error for unknown storage', () => {
+		expect(() => {
+			// @ts-expect-error
+			return BrowserStorage.getStorage('unknown-storage123');
+		}).to.throw();
+	});
+}
+
+runTests();
diff --git a/tests/mocks/webextension-polyfill.ts b/tests/mocks/webextension-polyfill.ts
index 9a9c874d..db89a018 100644
--- a/tests/mocks/webextension-polyfill.ts
+++ b/tests/mocks/webextension-polyfill.ts
@@ -36,7 +36,7 @@ const browser = {
 	},
 };
 
-export default class WebextensionPolyfillMocker {
+class WebextensionPolyfillMocker {
 	constructor() {
 		vi.mock('webextension-polyfill', () => ({
 			default: browser,
@@ -57,3 +57,5 @@ export default class WebextensionPolyfillMocker {
 		browser.storage.sync.clear();
 	}
 }
+
+export default new WebextensionPolyfillMocker();