mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
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:
parent
4079e92bbf
commit
068e703e88
48 changed files with 1613 additions and 113 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue