refactor(server): library syncing (#12220)

* refactor: library scanning

fix tests

remove offline files step

cleanup library service

improve tests

cleanup tests

add db migration

fix e2e

cleanup openapi

fix tests

fix tests

update docs

update docs

update mobile code

fix formatting

don't remove assets from library with invalid import path

use trash for offline files

add migration

simplify scan endpoint

cleanup library panel

fix library tests

e2e lint

fix e2e

trash e2e

fix lint

add asset trash tests

add more tests

ensure thumbs are generated

cleanup svelte

cleanup queue names

fix tests

fix lint

add warning due to trash

fix trash tests

fix lint

fix tests

Admin message for offline asset

fix comments

Update web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte

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

add permission to library scan endpoint

revert asset interface sort

add trash reason to shared link stub

improve path view in offline

update docs

improve trash performance

fix comments

remove stray comment

* refactor: add back isOffline and remove trashReason from asset, change sync job flow

* chore(server): drop coverage to 80% for functions

* chore: rebase and generated files

---------

Co-authored-by: Zack Pollard <zackpollard@ymail.com>
This commit is contained in:
Jonathan Jogenfors 2024-09-25 19:26:19 +02:00 committed by GitHub
parent 1ef2834603
commit b2f2be3485
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 941 additions and 1926 deletions

View file

@ -10,9 +10,8 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import {
IJobRepository,
ILibraryAssetJob,
ILibraryFileJob,
ILibraryOfflineJob,
ILibraryRefreshJob,
JobName,
JOBS_LIBRARY_PAGINATION_SIZE,
JobStatus,
@ -37,6 +36,10 @@ import { makeMockWatcher, newStorageRepositoryMock } from 'test/repositories/sto
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked, vitest } from 'vitest';
async function* mockWalk() {
yield await Promise.resolve(['/data/user1/photo.jpg']);
}
describe(LibraryService.name, () => {
let sut: LibraryService;
@ -91,7 +94,7 @@ describe(LibraryService.name, () => {
enabled: true,
cronExpression: '0 1 * * *',
},
watch: { enabled: false },
watch: { enabled: true },
},
} as SystemConfig);
@ -163,102 +166,29 @@ describe(LibraryService.name, () => {
describe('handleQueueAssetRefresh', () => {
it('should queue refresh of a new asset', async () => {
const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: false,
refreshAllFiles: false,
};
assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false });
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
// eslint-disable-next-line @typescript-eslint/require-await
storageMock.walk.mockImplementation(async function* generator() {
yield ['/data/user1/photo.jpg'];
});
assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false });
storageMock.walk.mockImplementation(mockWalk);
await sut.handleQueueAssetRefresh(mockLibraryJob);
await sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id });
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.LIBRARY_SCAN_ASSET,
name: JobName.LIBRARY_SYNC_FILE,
data: {
id: libraryStub.externalLibrary1.id,
ownerId: libraryStub.externalLibrary1.owner.id,
assetPath: '/data/user1/photo.jpg',
force: false,
},
},
]);
});
it('should queue offline check of existing online assets', async () => {
const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: false,
refreshAllFiles: false,
};
assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false });
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
storageMock.walk.mockImplementation(async function* generator() {});
assetMock.getWith.mockResolvedValue({ items: [assetStub.external], hasNextPage: false });
await sut.handleQueueAssetRefresh(mockLibraryJob);
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.LIBRARY_CHECK_OFFLINE,
data: {
id: assetStub.external.id,
importPaths: libraryStub.externalLibrary1.importPaths,
exclusionPatterns: [],
},
},
]);
});
it("should fail when library can't be found", async () => {
const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: false,
refreshAllFiles: false,
};
libraryMock.get.mockResolvedValue(null);
await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED);
});
it('should force queue new assets', async () => {
const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: false,
refreshAllFiles: true,
};
assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false });
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
// eslint-disable-next-line @typescript-eslint/require-await
storageMock.walk.mockImplementation(async function* generator() {
yield ['/data/user1/photo.jpg'];
});
assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false });
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,
},
},
]);
await expect(sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED);
});
it('should ignore import paths that do not exist', async () => {
@ -276,16 +206,9 @@ describe(LibraryService.name, () => {
assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false });
const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibraryWithImportPaths1.id,
refreshModifiedFiles: false,
refreshAllFiles: false,
};
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false });
await sut.handleQueueAssetRefresh(mockLibraryJob);
await sut.handleQueueSyncFiles({ id: libraryStub.externalLibraryWithImportPaths1.id });
expect(storageMock.walk).toHaveBeenCalledWith({
pathsToCrawl: [libraryStub.externalLibraryWithImportPaths1.importPaths[1]],
@ -296,9 +219,36 @@ describe(LibraryService.name, () => {
});
});
describe('handleOfflineCheck', () => {
describe('handleQueueRemoveDeleted', () => {
it('should queue online check of existing assets', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
storageMock.walk.mockImplementation(async function* generator() {});
assetMock.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false });
await sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id });
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.LIBRARY_SYNC_ASSET,
data: {
id: assetStub.external.id,
importPaths: libraryStub.externalLibrary1.importPaths,
exclusionPatterns: [],
},
},
]);
});
it("should fail when library can't be found", async () => {
libraryMock.get.mockResolvedValue(null);
await expect(sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED);
});
});
describe('handleSyncAsset', () => {
it('should skip missing assets', async () => {
const mockAssetJob: ILibraryOfflineJob = {
const mockAssetJob: ILibraryAssetJob = {
id: assetStub.external.id,
importPaths: ['/'],
exclusionPatterns: [],
@ -306,41 +256,31 @@ describe(LibraryService.name, () => {
assetMock.getById.mockResolvedValue(null);
await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SKIPPED);
await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SKIPPED);
expect(assetMock.update).not.toHaveBeenCalled();
});
it('should do nothing with already-offline assets', async () => {
const mockAssetJob: ILibraryOfflineJob = {
id: assetStub.external.id,
importPaths: ['/'],
exclusionPatterns: [],
};
assetMock.getById.mockResolvedValue(assetStub.offline);
await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.update).not.toHaveBeenCalled();
expect(assetMock.remove).not.toHaveBeenCalled();
});
it('should offline assets no longer on disk', async () => {
const mockAssetJob: ILibraryOfflineJob = {
const mockAssetJob: ILibraryAssetJob = {
id: assetStub.external.id,
importPaths: ['/'],
exclusionPatterns: [],
};
assetMock.getById.mockResolvedValue(assetStub.external);
storageMock.stat.mockRejectedValue(new Error('ENOENT, no such file or directory'));
await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true });
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], {
isOffline: true,
deletedAt: expect.any(Date),
});
});
it('should offline assets matching an exclusion pattern', async () => {
const mockAssetJob: ILibraryOfflineJob = {
const mockAssetJob: ILibraryAssetJob = {
id: assetStub.external.id,
importPaths: ['/'],
exclusionPatterns: ['**/user1/**'],
@ -348,13 +288,15 @@ describe(LibraryService.name, () => {
assetMock.getById.mockResolvedValue(assetStub.external);
await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true });
await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], {
isOffline: true,
deletedAt: expect.any(Date),
});
});
it('should set assets outside of import paths as offline', async () => {
const mockAssetJob: ILibraryOfflineJob = {
const mockAssetJob: ILibraryAssetJob = {
id: assetStub.external.id,
importPaths: ['/data/user2'],
exclusionPatterns: [],
@ -363,28 +305,74 @@ describe(LibraryService.name, () => {
assetMock.getById.mockResolvedValue(assetStub.external);
storageMock.checkFileExists.mockResolvedValue(true);
await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true });
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], {
isOffline: true,
deletedAt: expect.any(Date),
});
});
it('should do nothing with online assets', async () => {
const mockAssetJob: ILibraryOfflineJob = {
const mockAssetJob: ILibraryAssetJob = {
id: assetStub.external.id,
importPaths: ['/'],
exclusionPatterns: [],
};
assetMock.getById.mockResolvedValue(assetStub.external);
storageMock.checkFileExists.mockResolvedValue(true);
storageMock.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats);
await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.update).not.toHaveBeenCalled();
expect(assetMock.updateAll).not.toHaveBeenCalled();
});
it('should un-trash an asset previously marked as offline', async () => {
const mockAssetJob: ILibraryAssetJob = {
id: assetStub.external.id,
importPaths: ['/'],
exclusionPatterns: [],
};
assetMock.getById.mockResolvedValue(assetStub.trashedOffline);
storageMock.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats);
await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.trashedOffline.id], {
deletedAt: null,
fileCreatedAt: assetStub.trashedOffline.fileModifiedAt,
fileModifiedAt: assetStub.trashedOffline.fileModifiedAt,
isOffline: false,
originalFileName: 'path.jpg',
});
});
});
describe('handleAssetRefresh', () => {
it('should update file when mtime has changed', async () => {
const mockAssetJob: ILibraryAssetJob = {
id: assetStub.external.id,
importPaths: ['/'],
exclusionPatterns: [],
};
const newMTime = new Date();
assetMock.getById.mockResolvedValue(assetStub.external);
storageMock.stat.mockResolvedValue({ mtime: newMTime } as Stats);
await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], {
fileModifiedAt: newMTime,
fileCreatedAt: newMTime,
isOffline: false,
originalFileName: 'photo.jpg',
deletedAt: null,
});
});
describe('handleSyncFile', () => {
let mockUser: UserEntity;
beforeEach(() => {
@ -397,42 +385,18 @@ describe(LibraryService.name, () => {
} as Stats);
});
it('should reject an unknown file extension', async () => {
const mockLibraryJob: ILibraryFileJob = {
id: libraryStub.externalLibrary1.id,
ownerId: mockUser.id,
assetPath: '/data/user1/file.xyz',
force: false,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException);
});
it('should reject an unknown file type', async () => {
const mockLibraryJob: ILibraryFileJob = {
id: libraryStub.externalLibrary1.id,
ownerId: mockUser.id,
assetPath: '/data/user1/file.xyz',
force: false,
};
await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException);
});
it('should add a new image', async () => {
it('should import a new asset', async () => {
const mockLibraryJob: ILibraryFileJob = {
id: libraryStub.externalLibrary1.id,
ownerId: mockUser.id,
assetPath: '/data/user1/photo.jpg',
force: false,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.image);
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.create.mock.calls).toEqual([
[
@ -467,19 +431,19 @@ describe(LibraryService.name, () => {
]);
});
it('should add a new image with sidecar', async () => {
it('should import a new asset with sidecar', async () => {
const mockLibraryJob: ILibraryFileJob = {
id: libraryStub.externalLibrary1.id,
ownerId: mockUser.id,
assetPath: '/data/user1/photo.jpg',
force: false,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.image);
storageMock.checkFileExists.mockResolvedValue(true);
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.create.mock.calls).toEqual([
[
@ -514,18 +478,18 @@ describe(LibraryService.name, () => {
]);
});
it('should add a new video', async () => {
it('should import a new video', async () => {
const mockLibraryJob: ILibraryFileJob = {
id: libraryStub.externalLibrary1.id,
ownerId: mockUser.id,
assetPath: '/data/user1/video.mp4',
force: false,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.video);
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.create.mock.calls).toEqual([
[
@ -568,29 +532,27 @@ describe(LibraryService.name, () => {
]);
});
it('should not add an image to a soft deleted library', async () => {
it('should not import an asset to a soft deleted library', async () => {
const mockLibraryJob: ILibraryFileJob = {
id: libraryStub.externalLibrary1.id,
ownerId: mockUser.id,
assetPath: '/data/user1/photo.jpg',
force: false,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.image);
libraryMock.get.mockResolvedValue({ ...libraryStub.externalLibrary1, deletedAt: new Date() });
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.FAILED);
await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED);
expect(assetMock.create.mock.calls).toEqual([]);
});
it('should not import an asset when mtime matches db asset', async () => {
it('should not refresh a file whose mtime matches existing asset', async () => {
const mockLibraryJob: ILibraryFileJob = {
id: libraryStub.externalLibrary1.id,
ownerId: mockUser.id,
assetPath: assetStub.hasFileExtension.originalPath,
force: false,
};
storageMock.stat.mockResolvedValue({
@ -601,190 +563,52 @@ describe(LibraryService.name, () => {
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED);
await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED);
expect(jobMock.queue).not.toHaveBeenCalled();
expect(jobMock.queueAll).not.toHaveBeenCalled();
});
it('should import an asset when mtime differs from db asset', async () => {
it('should skip existing asset', async () => {
const mockLibraryJob: ILibraryFileJob = {
id: libraryStub.externalLibrary1.id,
ownerId: mockUser.id,
assetPath: '/data/user1/photo.jpg',
force: false,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image);
assetMock.create.mockResolvedValue(assetStub.image);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.METADATA_EXTRACTION,
data: {
id: assetStub.image.id,
source: 'upload',
},
});
expect(jobMock.queue).not.toHaveBeenCalledWith({
name: JobName.VIDEO_CONVERSION,
data: {
id: assetStub.image.id,
},
});
await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED);
});
it('should import an asset that is missing a file extension', async () => {
// This tests for the case where the file extension is missing from the asset path.
// This happened in previous versions of Immich
it('should not refresh an asset trashed by user', async () => {
const mockLibraryJob: ILibraryFileJob = {
id: libraryStub.externalLibrary1.id,
ownerId: mockUser.id,
assetPath: assetStub.missingFileExtension.originalPath,
force: false,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.missingFileExtension);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.updateAll).toHaveBeenCalledWith(
[assetStub.missingFileExtension.id],
expect.objectContaining({ originalFileName: 'photo.jpg' }),
);
});
it('should set a missing asset to offline', async () => {
storageMock.stat.mockRejectedValue(new Error('Path not found'));
const mockLibraryJob: ILibraryFileJob = {
id: assetStub.image.id,
ownerId: mockUser.id,
assetPath: '/data/user1/photo.jpg',
force: false,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image);
assetMock.create.mockResolvedValue(assetStub.image);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, isOffline: true });
expect(jobMock.queue).not.toHaveBeenCalled();
expect(jobMock.queueAll).not.toHaveBeenCalled();
});
it('should online a previously-offline asset', async () => {
const mockLibraryJob: ILibraryFileJob = {
id: assetStub.offline.id,
ownerId: mockUser.id,
assetPath: '/data/user1/photo.jpg',
force: false,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.offline);
assetMock.create.mockResolvedValue(assetStub.offline);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.offline.id, isOffline: false });
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.METADATA_EXTRACTION,
data: {
id: assetStub.offline.id,
source: 'upload',
},
});
expect(jobMock.queue).not.toHaveBeenCalledWith({
name: JobName.VIDEO_CONVERSION,
data: {
id: assetStub.offline.id,
},
});
});
it('should do nothing when mtime matches existing asset', async () => {
const mockLibraryJob: ILibraryFileJob = {
id: assetStub.image.id,
ownerId: assetStub.image.ownerId,
assetPath: '/data/user1/photo.jpg',
force: false,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image);
assetMock.create.mockResolvedValue(assetStub.image);
expect(assetMock.update).not.toHaveBeenCalled();
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
});
it('should refresh an existing asset if forced', async () => {
const mockLibraryJob: ILibraryFileJob = {
id: assetStub.image.id,
ownerId: assetStub.hasFileExtension.ownerId,
assetPath: assetStub.hasFileExtension.originalPath,
force: true,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension);
assetMock.create.mockResolvedValue(assetStub.hasFileExtension);
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.trashed);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED);
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.hasFileExtension.id], {
fileCreatedAt: new Date('2023-01-01'),
fileModifiedAt: new Date('2023-01-01'),
originalFileName: assetStub.hasFileExtension.originalFileName,
});
expect(jobMock.queue).not.toHaveBeenCalled();
expect(jobMock.queueAll).not.toHaveBeenCalled();
});
it('should refresh an existing asset with modified mtime', async () => {
const filemtime = new Date();
filemtime.setSeconds(assetStub.image.fileModifiedAt.getSeconds() + 10);
const mockLibraryJob: ILibraryFileJob = {
id: libraryStub.externalLibrary1.id,
ownerId: userStub.admin.id,
assetPath: '/data/user1/photo.jpg',
force: false,
};
storageMock.stat.mockResolvedValue({
size: 100,
mtime: filemtime,
ctime: new Date('2023-01-01'),
} as Stats);
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.image);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.create).toHaveBeenCalled();
const createdAsset = assetMock.create.mock.calls[0][0];
expect(createdAsset.fileModifiedAt).toEqual(filemtime);
});
it('should throw error when asset does not exist', async () => {
it('should throw BadRequestException when asset does not exist', async () => {
storageMock.stat.mockRejectedValue(new Error("ENOENT, no such file or directory '/data/user1/photo.jpg'"));
const mockLibraryJob: ILibraryFileJob = {
id: libraryStub.externalLibrary1.id,
ownerId: userStub.admin.id,
assetPath: '/data/user1/photo.jpg',
force: false,
};
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.image);
await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED);
});
});
@ -857,7 +681,6 @@ describe(LibraryService.name, () => {
describe('getStatistics', () => {
it('should return library statistics', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 });
await expect(sut.getStatistics(libraryStub.externalLibrary1.id)).resolves.toEqual({
photos: 10,
@ -1092,12 +915,11 @@ describe(LibraryService.name, () => {
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.LIBRARY_SCAN_ASSET,
name: JobName.LIBRARY_SYNC_FILE,
data: {
id: libraryStub.externalLibraryWithImportPaths1.id,
assetPath: '/foo/photo.jpg',
ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id,
force: false,
},
},
]);
@ -1114,30 +936,16 @@ describe(LibraryService.name, () => {
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.LIBRARY_SCAN_ASSET,
name: JobName.LIBRARY_SYNC_FILE,
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);
storageMock.watch.mockImplementation(
makeMockWatcher({ items: [{ event: 'unlink', value: '/foo/photo.jpg' }] }),
);
await sut.watchAll();
expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true });
});
it('should handle an error event', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external);
@ -1232,72 +1040,23 @@ describe(LibraryService.name, () => {
});
describe('queueScan', () => {
it('should queue a library scan of external library', async () => {
it('should queue a library scan', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
await sut.queueScan(libraryStub.externalLibrary1.id, {});
await sut.queueScan(libraryStub.externalLibrary1.id);
expect(jobMock.queue.mock.calls).toEqual([
[
{
name: JobName.LIBRARY_SCAN,
name: JobName.LIBRARY_QUEUE_SYNC_FILES,
data: {
id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: false,
refreshAllFiles: false,
},
},
],
]);
});
it('should queue a library scan of all modified assets', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
await sut.queueScan(libraryStub.externalLibrary1.id, { refreshModifiedFiles: true });
expect(jobMock.queue.mock.calls).toEqual([
[
{
name: JobName.LIBRARY_SCAN,
data: {
id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: true,
refreshAllFiles: false,
},
},
],
]);
});
it('should queue a forced library scan', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
await sut.queueScan(libraryStub.externalLibrary1.id, { refreshAllFiles: true });
expect(jobMock.queue.mock.calls).toEqual([
[
{
name: JobName.LIBRARY_SCAN,
data: {
id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: false,
refreshAllFiles: true,
},
},
],
]);
});
});
describe('queueEmptyTrash', () => {
it('should queue the trash job', async () => {
await sut.queueRemoveOffline(libraryStub.externalLibrary1.id);
expect(jobMock.queue.mock.calls).toEqual([
[
{
name: JobName.LIBRARY_REMOVE_OFFLINE,
name: JobName.LIBRARY_QUEUE_SYNC_ASSETS,
data: {
id: libraryStub.externalLibrary1.id,
},
@ -1311,7 +1070,7 @@ describe(LibraryService.name, () => {
it('should queue the refresh job', async () => {
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]);
await expect(sut.handleQueueAllScan({})).resolves.toBe(JobStatus.SUCCESS);
await expect(sut.handleQueueSyncAll()).resolves.toBe(JobStatus.SUCCESS);
expect(jobMock.queue.mock.calls).toEqual([
[
@ -1323,48 +1082,32 @@ describe(LibraryService.name, () => {
]);
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.LIBRARY_SCAN,
name: JobName.LIBRARY_QUEUE_SYNC_FILES,
data: {
id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: true,
refreshAllFiles: false,
},
},
]);
});
it('should queue the force refresh job', async () => {
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]);
await expect(sut.handleQueueAllScan({ force: true })).resolves.toBe(JobStatus.SUCCESS);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.LIBRARY_QUEUE_CLEANUP,
data: {},
});
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.LIBRARY_SCAN,
data: {
id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: false,
refreshAllFiles: true,
},
},
]);
});
});
describe('handleRemoveOfflineFiles', () => {
it('should queue trash deletion jobs', async () => {
assetMock.getWith.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false });
describe('handleQueueAssetOfflineCheck', () => {
it('should queue removal jobs', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
assetMock.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false });
assetMock.getById.mockResolvedValue(assetStub.image1);
await expect(sut.handleRemoveOffline({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS);
await expect(sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS);
expect(jobMock.queueAll).toHaveBeenCalledWith([
{ name: JobName.ASSET_DELETION, data: { id: assetStub.image1.id, deleteOnDisk: false } },
{
name: JobName.LIBRARY_SYNC_ASSET,
data: {
id: assetStub.image1.id,
importPaths: libraryStub.externalLibrary1.importPaths,
exclusionPatterns: libraryStub.externalLibrary1.exclusionPatterns,
},
},
]);
});
});