feat(server): Automatic watching of library folders (#6192)

* feat: initial watch support

* allow offline files

* chore: ignore query errors when resetting e2e db

* revert db query

* add savepoint

* guard the user query

* chore: openapi and db migration

* wip

* support multiple libraries

* fix tests

* wip

* can now cleanup chokidar watchers

* fix unit tests

* add library watch queue

* add missing init from merge

* wip

* can now filter file extensions

* remove watch api from non job client

* Fix e2e test

* watch library with updated import path and exclusion pattern

* add library watch frontend ui

* case sensitive watching extensions

* can auto watch libraries

* move watcher e2e tests to separate file

* don't watch libraries from a queue

* use event emitters

* shorten e2e test timeout

* refactor chokidar code to filesystem provider

* expose chokidar parameters to config file

* fix storage mock

* set default config for library watching

* add fs provider mocks

* cleanup

* add more unit tests for watcher

* chore: fix format + sql

* add more tests

* move unwatch feature back to library service

* add file event unit tests

* chore: formatting

* add documentation

* fix e2e tests

* chore: fix e2e tests

* fix library updating

* test cleanup

* fix typo

* cleanup

* fixing as per pr comments

* reduce library watch config file

* update storage config and mocks

* move negative event tests to unit tests

* fix library watcher e2e

* make watch configuration global

* remove the feature flag

* refactor watcher teardown

* fix microservices init

* centralize asset scan job queue

* improve docs

* add more tests

* chore: open api

* initialize app service

* fix docs

* fix library watch feature flag

* Update docs/docs/features/libraries.md

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>

* fix: import right app service

* don't be truthy

* fix test speling

* stricter library update tests

* move fs watcher mock to external file

* subscribe to config changes

* docker does not need polling

* make library watch() private

* feat: add configuration ui

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jonathan Jogenfors 2024-01-31 09:15:54 +01:00 committed by GitHub
parent 4079e92bbf
commit 068e703e88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1613 additions and 113 deletions

View file

@ -2,9 +2,9 @@ import { AssetType, LibraryType, SystemConfig, SystemConfigKey, UserEntity } fro
import { BadRequestException } from '@nestjs/common';
import {
IAccessRepositoryMock,
assetStub,
authStub,
IAccessRepositoryMock,
libraryStub,
newAccessRepositoryMock,
newAssetRepositoryMock,
@ -14,8 +14,11 @@ import {
newStorageRepositoryMock,
newSystemConfigRepositoryMock,
newUserRepositoryMock,
systemConfigStub,
userStub,
} from '@test';
import { newFSWatcherMock } from '@test/mocks';
import { Stats } from 'fs';
import { ILibraryFileJob, ILibraryRefreshJob, JobName } from '../job';
import {
@ -28,6 +31,7 @@ import {
IUserRepository,
} from '../repositories';
import { SystemConfigCore } from '../system-config/system-config.core';
import { mapLibrary } from './library.dto';
import { LibraryService } from './library.service';
describe(LibraryService.name, () => {
@ -94,11 +98,60 @@ describe(LibraryService.name, () => {
enabled: true,
cronExpression: '0 1 * * *',
},
watch: { enabled: false },
},
} as SystemConfig);
expect(jobMock.updateCronJob).toHaveBeenCalledWith('libraryScan', '0 1 * * *', true);
});
it('should initialize watcher for all external libraries', async () => {
libraryMock.getAll.mockResolvedValue([
libraryStub.externalLibraryWithImportPaths1,
libraryStub.externalLibraryWithImportPaths2,
]);
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
libraryMock.get.mockImplementation(async (id) => {
switch (id) {
case libraryStub.externalLibraryWithImportPaths1.id:
return libraryStub.externalLibraryWithImportPaths1;
case libraryStub.externalLibraryWithImportPaths2.id:
return libraryStub.externalLibraryWithImportPaths2;
default:
return null;
}
});
const mockWatcher = newFSWatcherMock();
mockWatcher.on.mockImplementation((event, callback) => {
if (event === 'ready') {
callback();
}
});
storageMock.watch.mockReturnValue(mockWatcher);
await sut.init();
expect(storageMock.watch.mock.calls).toEqual(
expect.arrayContaining([
(libraryStub.externalLibrary1.importPaths, expect.anything()),
(libraryStub.externalLibrary2.importPaths, expect.anything()),
]),
);
});
it('should not initialize when watching is disabled', async () => {
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchDisabled);
await sut.init();
expect(storageMock.watch).not.toHaveBeenCalled();
});
});
describe('handleQueueAssetRefresh', () => {
@ -148,6 +201,34 @@ describe(LibraryService.name, () => {
]);
});
it('should force queue new assets', async () => {
const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: false,
refreshAllFiles: true,
};
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']);
assetMock.getByLibraryId.mockResolvedValue([]);
libraryMock.getOnlineAssetPaths.mockResolvedValue([]);
userMock.get.mockResolvedValue(userStub.externalPath1);
await sut.handleQueueAssetRefresh(mockLibraryJob);
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.LIBRARY_SCAN_ASSET,
data: {
id: libraryStub.externalLibrary1.id,
ownerId: libraryStub.externalLibrary1.owner.id,
assetPath: '/data/user1/photo.jpg',
force: true,
},
},
]);
});
it("should mark assets outside of the user's external path as offline", async () => {
const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibrary1.id,
@ -564,7 +645,7 @@ describe(LibraryService.name, () => {
expect(createdAsset.fileModifiedAt).toEqual(filemtime);
});
it('should error when asset does not exist', async () => {
it('should throw error when asset does not exist', async () => {
storageMock.stat.mockRejectedValue(new Error("ENOENT, no such file or directory '/data/user1/photo.jpg'"));
const mockLibraryJob: ILibraryFileJob = {
@ -625,6 +706,31 @@ describe(LibraryService.name, () => {
expect(libraryMock.softDelete).toHaveBeenCalledWith(libraryStub.externalLibrary1.id);
});
it('should unwatch an external library when deleted', async () => {
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image);
libraryMock.getUploadLibraryCount.mockResolvedValue(1);
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]);
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
const mockWatcher = newFSWatcherMock();
mockWatcher.on.mockImplementation((event, callback) => {
if (event === 'ready') {
callback();
}
});
storageMock.watch.mockReturnValue(mockWatcher);
await sut.init();
await sut.delete(authStub.admin, libraryStub.externalLibraryWithImportPaths1.id);
expect(mockWatcher.close).toHaveBeenCalled();
});
});
describe('getCount', () => {
@ -638,7 +744,7 @@ describe(LibraryService.name, () => {
});
describe('get', () => {
it('can return a library', async () => {
it('should return a library', async () => {
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
await expect(sut.get(authStub.admin, libraryStub.uploadLibrary1.id)).resolves.toEqual(
expect.objectContaining({
@ -659,7 +765,7 @@ describe(LibraryService.name, () => {
});
describe('getAllForUser', () => {
it('can return all libraries for user', async () => {
it('should return all libraries for user', async () => {
libraryMock.getAllByUserId.mockResolvedValue([libraryStub.uploadLibrary1, libraryStub.externalLibrary1]);
await expect(sut.getAllForUser(authStub.admin)).resolves.toEqual([
expect.objectContaining({
@ -679,7 +785,7 @@ describe(LibraryService.name, () => {
});
describe('getStatistics', () => {
it('can return library statistics', async () => {
it('should return library statistics', async () => {
libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 });
await expect(sut.getStatistics(authStub.admin, libraryStub.uploadLibrary1.id)).resolves.toEqual({
photos: 10,
@ -694,7 +800,7 @@ describe(LibraryService.name, () => {
describe('create', () => {
describe('external library', () => {
it('can create with default settings', async () => {
it('should create with default settings', async () => {
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
await expect(
sut.create(authStub.admin, {
@ -717,7 +823,7 @@ describe(LibraryService.name, () => {
expect(libraryMock.create).toHaveBeenCalledWith(
expect.objectContaining({
name: 'New External Library',
name: expect.any(String),
type: LibraryType.EXTERNAL,
importPaths: [],
exclusionPatterns: [],
@ -726,7 +832,7 @@ describe(LibraryService.name, () => {
);
});
it('can create with name', async () => {
it('should create with name', async () => {
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
await expect(
sut.create(authStub.admin, {
@ -759,7 +865,7 @@ describe(LibraryService.name, () => {
);
});
it('can create invisible', async () => {
it('should create invisible', async () => {
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
await expect(
sut.create(authStub.admin, {
@ -783,7 +889,7 @@ describe(LibraryService.name, () => {
expect(libraryMock.create).toHaveBeenCalledWith(
expect.objectContaining({
name: 'New External Library',
name: expect.any(String),
type: LibraryType.EXTERNAL,
importPaths: [],
exclusionPatterns: [],
@ -792,7 +898,7 @@ describe(LibraryService.name, () => {
);
});
it('can create with import paths', async () => {
it('should create with import paths', async () => {
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
await expect(
sut.create(authStub.admin, {
@ -816,7 +922,7 @@ describe(LibraryService.name, () => {
expect(libraryMock.create).toHaveBeenCalledWith(
expect.objectContaining({
name: 'New External Library',
name: expect.any(String),
type: LibraryType.EXTERNAL,
importPaths: ['/data/images', '/data/videos'],
exclusionPatterns: [],
@ -825,7 +931,35 @@ describe(LibraryService.name, () => {
);
});
it('can create with exclusion patterns', async () => {
it('should create watched with import paths', async () => {
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
libraryMock.create.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
libraryMock.getAll.mockResolvedValue([]);
const mockWatcher = newFSWatcherMock();
mockWatcher.on.mockImplementation((event, callback) => {
if (event === 'ready') {
callback();
}
});
storageMock.watch.mockReturnValue(mockWatcher);
await sut.init();
await sut.create(authStub.admin, {
type: LibraryType.EXTERNAL,
importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths,
});
expect(storageMock.watch).toHaveBeenCalledWith(
libraryStub.externalLibraryWithImportPaths1.importPaths,
expect.anything(),
);
});
it('should create with exclusion patterns', async () => {
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
await expect(
sut.create(authStub.admin, {
@ -849,7 +983,7 @@ describe(LibraryService.name, () => {
expect(libraryMock.create).toHaveBeenCalledWith(
expect.objectContaining({
name: 'New External Library',
name: expect.any(String),
type: LibraryType.EXTERNAL,
importPaths: [],
exclusionPatterns: ['*.tmp', '*.bak'],
@ -860,7 +994,7 @@ describe(LibraryService.name, () => {
});
describe('upload library', () => {
it('can create with default settings', async () => {
it('should create with default settings', async () => {
libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1);
await expect(
sut.create(authStub.admin, {
@ -892,7 +1026,7 @@ describe(LibraryService.name, () => {
);
});
it('can create with name', async () => {
it('should create with name', async () => {
libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1);
await expect(
sut.create(authStub.admin, {
@ -925,7 +1059,7 @@ describe(LibraryService.name, () => {
);
});
it('can not create with import paths', async () => {
it('should not create with import paths', async () => {
await expect(
sut.create(authStub.admin, {
type: LibraryType.UPLOAD,
@ -936,7 +1070,7 @@ describe(LibraryService.name, () => {
expect(libraryMock.create).not.toHaveBeenCalled();
});
it('can not create with exclusion patterns', async () => {
it('should not create with exclusion patterns', async () => {
await expect(
sut.create(authStub.admin, {
type: LibraryType.UPLOAD,
@ -946,11 +1080,22 @@ describe(LibraryService.name, () => {
expect(libraryMock.create).not.toHaveBeenCalled();
});
it('should not create watched', async () => {
await expect(
sut.create(authStub.admin, {
type: LibraryType.UPLOAD,
isWatched: true,
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(storageMock.watch).not.toHaveBeenCalled();
});
});
});
describe('handleQueueCleanup', () => {
it('can queue cleanup jobs', async () => {
it('should queue cleanup jobs', async () => {
libraryMock.getAllDeleted.mockResolvedValue([libraryStub.uploadLibrary1, libraryStub.externalLibrary1]);
await expect(sut.handleQueueCleanup()).resolves.toBe(true);
@ -962,19 +1107,357 @@ describe(LibraryService.name, () => {
});
describe('update', () => {
it('can update library ', async () => {
beforeEach(async () => {
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
libraryMock.getAll.mockResolvedValue([]);
await sut.init();
});
it('should update library', async () => {
libraryMock.update.mockResolvedValue(libraryStub.uploadLibrary1);
await expect(sut.update(authStub.admin, authStub.admin.user.id, {})).resolves.toBeTruthy();
await expect(sut.update(authStub.admin, authStub.admin.user.id, {})).resolves.toEqual(
mapLibrary(libraryStub.uploadLibrary1),
);
expect(libraryMock.update).toHaveBeenCalledWith(
expect.objectContaining({
id: authStub.admin.user.id,
}),
);
});
it('should re-watch library when updating import paths', async () => {
libraryMock.update.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
const mockWatcher = newFSWatcherMock();
mockWatcher.on.mockImplementation((event, callback) => {
if (event === 'ready') {
callback();
}
});
storageMock.watch.mockReturnValue(mockWatcher);
await expect(sut.update(authStub.admin, authStub.admin.user.id, { importPaths: ['/foo'] })).resolves.toEqual(
mapLibrary(libraryStub.externalLibraryWithImportPaths1),
);
expect(libraryMock.update).toHaveBeenCalledWith(
expect.objectContaining({
id: authStub.admin.user.id,
}),
);
expect(storageMock.watch).toHaveBeenCalledWith(
libraryStub.externalLibraryWithImportPaths1.importPaths,
expect.anything(),
);
});
it('should re-watch library when updating exclusion patterns', async () => {
libraryMock.update.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
const mockWatcher = newFSWatcherMock();
mockWatcher.on.mockImplementation((event, callback) => {
if (event === 'ready') {
callback();
}
});
storageMock.watch.mockReturnValue(mockWatcher);
await expect(sut.update(authStub.admin, authStub.admin.user.id, { exclusionPatterns: ['bar'] })).resolves.toEqual(
mapLibrary(libraryStub.externalLibraryWithImportPaths1),
);
expect(libraryMock.update).toHaveBeenCalledWith(
expect.objectContaining({
id: authStub.admin.user.id,
}),
);
expect(storageMock.watch).toHaveBeenCalledWith(expect.arrayContaining([expect.any(String)]), expect.anything());
});
});
describe('watchAll new', () => {
describe('watching disabled', () => {
beforeEach(async () => {
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchDisabled);
await sut.init();
});
it('should not watch library', async () => {
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]);
await sut.watchAll();
expect(storageMock.watch).not.toHaveBeenCalled();
});
});
describe('watching enabled', () => {
const mockWatcher = newFSWatcherMock();
beforeEach(async () => {
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
libraryMock.getAll.mockResolvedValue([]);
await sut.init();
storageMock.watch.mockReturnValue(mockWatcher);
});
it('should watch library', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]);
const mockWatcher = newFSWatcherMock();
let isReady = false;
mockWatcher.on.mockImplementation((event, callback) => {
if (event === 'ready') {
isReady = true;
callback();
}
});
storageMock.watch.mockReturnValue(mockWatcher);
await sut.watchAll();
expect(storageMock.watch).toHaveBeenCalledWith(
libraryStub.externalLibraryWithImportPaths1.importPaths,
expect.anything(),
);
expect(isReady).toBe(true);
});
it('should watch and unwatch library', async () => {
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]);
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
mockWatcher.on.mockImplementation((event, callback) => {
if (event === 'ready') {
callback();
}
});
await sut.watchAll();
await sut.unwatch(libraryStub.externalLibraryWithImportPaths1.id);
expect(mockWatcher.close).toHaveBeenCalled();
});
it('should not watch library without import paths', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]);
await sut.watchAll();
expect(storageMock.watch).not.toHaveBeenCalled();
});
it('should throw error when watching upload library', async () => {
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
libraryMock.getAll.mockResolvedValue([libraryStub.uploadLibrary1]);
await expect(sut.watchAll()).rejects.toThrow('Can only watch external libraries');
expect(storageMock.watch).not.toHaveBeenCalled();
});
it('should handle a new file event', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]);
mockWatcher.on.mockImplementation((event, callback) => {
if (event === 'ready') {
callback();
} else if (event === 'add') {
callback('/foo/photo.jpg');
}
});
await sut.watchAll();
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.LIBRARY_SCAN_ASSET,
data: {
id: libraryStub.externalLibraryWithImportPaths1.id,
assetPath: '/foo/photo.jpg',
ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id,
force: false,
},
},
]);
});
it('should handle a file change event', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]);
mockWatcher.on.mockImplementation((event, callback) => {
if (event === 'ready') {
callback();
} else if (event === 'change') {
callback('/foo/photo.jpg');
}
});
await sut.watchAll();
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.LIBRARY_SCAN_ASSET,
data: {
id: libraryStub.externalLibraryWithImportPaths1.id,
assetPath: '/foo/photo.jpg',
ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id,
force: false,
},
},
]);
});
it('should handle a file unlink event', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]);
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external);
mockWatcher.on.mockImplementation((event, callback) => {
if (event === 'ready') {
callback();
} else if (event === 'unlink') {
callback('/foo/photo.jpg');
}
});
await sut.watchAll();
expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true });
});
it('should handle an error event', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external);
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]);
let didError = false;
mockWatcher.on.mockImplementation((event, callback) => {
if (event === 'ready') {
callback();
} else if (event === 'error') {
didError = true;
callback('Error!');
}
});
await sut.watchAll();
expect(didError).toBe(true);
});
it('should ignore unknown extensions', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]);
mockWatcher.on.mockImplementation((event, callback) => {
if (event === 'ready') {
callback();
} else if (event === 'add') {
callback('/foo/photo.txt');
}
});
await sut.watchAll();
expect(jobMock.queue).not.toHaveBeenCalled();
});
it('should ignore excluded paths', async () => {
libraryMock.get.mockResolvedValue(libraryStub.patternPath);
libraryMock.getAll.mockResolvedValue([libraryStub.patternPath]);
mockWatcher.on.mockImplementation((event, callback) => {
if (event === 'ready') {
callback();
} else if (event === 'add') {
callback('/dir1/photo.txt');
}
});
await sut.watchAll();
expect(jobMock.queue).not.toHaveBeenCalled();
});
it('should ignore excluded paths without case sensitivity', async () => {
libraryMock.get.mockResolvedValue(libraryStub.patternPath);
libraryMock.getAll.mockResolvedValue([libraryStub.patternPath]);
mockWatcher.on.mockImplementation((event, callback) => {
if (event === 'ready') {
callback();
} else if (event === 'add') {
callback('/DIR1/photo.txt');
}
});
await sut.watchAll();
expect(jobMock.queue).not.toHaveBeenCalled();
});
});
});
describe('tearDown', () => {
it('should tear down all watchers', async () => {
libraryMock.getAll.mockResolvedValue([
libraryStub.externalLibraryWithImportPaths1,
libraryStub.externalLibraryWithImportPaths2,
]);
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
libraryMock.get.mockImplementation(async (id) => {
switch (id) {
case libraryStub.externalLibraryWithImportPaths1.id:
return libraryStub.externalLibraryWithImportPaths1;
case libraryStub.externalLibraryWithImportPaths2.id:
return libraryStub.externalLibraryWithImportPaths2;
default:
return null;
}
});
const mockWatcher = newFSWatcherMock();
mockWatcher.on.mockImplementation((event, callback) => {
if (event === 'ready') {
callback();
}
});
storageMock.watch.mockReturnValue(mockWatcher);
await sut.init();
await sut.unwatchAll();
expect(mockWatcher.close).toHaveBeenCalledTimes(2);
});
});
describe('handleDeleteLibrary', () => {
it('can not delete a nonexistent library', async () => {
it('should not delete a nonexistent library', async () => {
libraryMock.get.mockImplementation(async () => {
return null;
});
@ -984,7 +1467,7 @@ describe(LibraryService.name, () => {
await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(false);
});
it('can delete an empty library', async () => {
it('should delete an empty library', async () => {
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
libraryMock.getAssetIds.mockResolvedValue([]);
libraryMock.delete.mockImplementation(async () => {});
@ -992,7 +1475,7 @@ describe(LibraryService.name, () => {
await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(true);
});
it('can delete a library with assets', async () => {
it('should delete a library with assets', async () => {
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
libraryMock.getAssetIds.mockResolvedValue([assetStub.image1.id]);
libraryMock.delete.mockImplementation(async () => {});
@ -1004,7 +1487,7 @@ describe(LibraryService.name, () => {
});
describe('queueScan', () => {
it('can queue a library scan of external library', async () => {
it('should queue a library scan of external library', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, {});
@ -1023,7 +1506,7 @@ describe(LibraryService.name, () => {
]);
});
it('can not queue a library scan of upload library', async () => {
it('should not queue a library scan of upload library', async () => {
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
await expect(sut.queueScan(authStub.admin, libraryStub.uploadLibrary1.id, {})).rejects.toBeInstanceOf(
@ -1033,7 +1516,7 @@ describe(LibraryService.name, () => {
expect(jobMock.queue).not.toBeCalled();
});
it('can queue a library scan of all modified assets', async () => {
it('should queue a library scan of all modified assets', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, { refreshModifiedFiles: true });
@ -1052,7 +1535,7 @@ describe(LibraryService.name, () => {
]);
});
it('can queue a forced library scan', async () => {
it('should queue a forced library scan', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, { refreshAllFiles: true });
@ -1073,7 +1556,7 @@ describe(LibraryService.name, () => {
});
describe('queueEmptyTrash', () => {
it('can queue the trash job', async () => {
it('should queue the trash job', async () => {
await sut.queueRemoveOffline(authStub.admin, libraryStub.externalLibrary1.id);
expect(jobMock.queue.mock.calls).toEqual([
@ -1090,7 +1573,7 @@ describe(LibraryService.name, () => {
});
describe('handleQueueAllScan', () => {
it('can queue the refresh job', async () => {
it('should queue the refresh job', async () => {
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]);
await expect(sut.handleQueueAllScan({})).resolves.toBe(true);
@ -1115,19 +1598,16 @@ describe(LibraryService.name, () => {
]);
});
it('can queue the force refresh job', async () => {
it('should queue the force refresh job', async () => {
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]);
await expect(sut.handleQueueAllScan({ force: true })).resolves.toBe(true);
expect(jobMock.queue.mock.calls).toEqual([
[
{
name: JobName.LIBRARY_QUEUE_CLEANUP,
data: {},
},
],
]);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.LIBRARY_QUEUE_CLEANUP,
data: {},
});
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.LIBRARY_SCAN,
@ -1142,7 +1622,7 @@ describe(LibraryService.name, () => {
});
describe('handleRemoveOfflineFiles', () => {
it('can queue trash deletion jobs', async () => {
it('should queue trash deletion jobs', async () => {
assetMock.getWith.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false });
assetMock.getById.mockResolvedValue(assetStub.image1);