mirror of
https://github.com/immich-app/immich
synced 2026-08-29 13:15:45 +00:00
refactor: library e2e (#30867)
This commit is contained in:
parent
d2759ec41e
commit
c63824bcea
2 changed files with 441 additions and 484 deletions
|
|
@ -1,24 +1,20 @@
|
|||
import { LibraryResponseDto, LoginResponseDto, getAllLibraries } from '@immich/sdk';
|
||||
import { cpSync, existsSync } from 'node:fs';
|
||||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { cpSync } from 'node:fs';
|
||||
import { Socket } from 'socket.io-client';
|
||||
import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { testAssetDir, testAssetDirInternal, utils } from 'src/utils';
|
||||
import { utimes } from 'utimes';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/libraries', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let library: LibraryResponseDto;
|
||||
let websocket: Socket;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
await utils.resetAdminConfig(admin.accessToken);
|
||||
library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId });
|
||||
websocket = await utils.connectWebsocket(admin.accessToken);
|
||||
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetA.png`);
|
||||
utils.createImageFile(`${testAssetDir}/temp/directoryB/assetB.png`);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
|
@ -30,113 +26,6 @@ describe('/libraries', () => {
|
|||
utils.resetEvents();
|
||||
});
|
||||
|
||||
describe('POST /libraries', () => {
|
||||
it('should create an external library with defaults', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/libraries')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ ownerId: admin.userId });
|
||||
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
ownerId: admin.userId,
|
||||
name: 'New External Library',
|
||||
refreshedAt: null,
|
||||
assetCount: 0,
|
||||
importPaths: [],
|
||||
exclusionPatterns: expect.any(Array),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create an external library with options', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/libraries')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
ownerId: admin.userId,
|
||||
name: 'My Awesome Library',
|
||||
importPaths: ['/path/to/import'],
|
||||
exclusionPatterns: ['**/Raw/**'],
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'My Awesome Library',
|
||||
importPaths: ['/path/to/import'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /libraries/:id', () => {
|
||||
it('should change the library name', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/libraries/${library.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ name: 'New Library Name' });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'New Library Name',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should change the import paths', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/libraries/${library.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ importPaths: [testAssetDirInternal] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
importPaths: [testAssetDirInternal],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should change the exclusion pattern', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/libraries/${library.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ exclusionPatterns: ['**/Raw/**'] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
exclusionPatterns: ['**/Raw/**'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /libraries/:id', () => {
|
||||
it('should get library by id', async () => {
|
||||
const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId });
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.get(`/libraries/${library.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
ownerId: admin.userId,
|
||||
name: 'New External Library',
|
||||
refreshedAt: null,
|
||||
assetCount: 0,
|
||||
importPaths: [],
|
||||
exclusionPatterns: expect.any(Array),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /libraries/:id/scan', () => {
|
||||
it('should process metadata and thumbnails for external asset', async () => {
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
|
|
@ -193,42 +82,6 @@ describe('/libraries', () => {
|
|||
utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
|
||||
});
|
||||
|
||||
it('should not reimport a file with unchanged timestamp', async () => {
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
importPaths: [`${testAssetDirInternal}/temp/reimport`],
|
||||
});
|
||||
|
||||
utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
|
||||
await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000);
|
||||
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`);
|
||||
await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000);
|
||||
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, {
|
||||
libraryId: library.id,
|
||||
});
|
||||
|
||||
expect(assets.count).toEqual(1);
|
||||
|
||||
const asset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
|
||||
|
||||
expect(asset).toEqual(
|
||||
expect.objectContaining({
|
||||
originalFileName: 'asset.jpg',
|
||||
exifInfo: expect.not.objectContaining({
|
||||
model: 'NIKON D750',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
|
||||
});
|
||||
|
||||
it('should not reimport a modified file more than once', async () => {
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
|
|
@ -270,55 +123,4 @@ describe('/libraries', () => {
|
|||
utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /libraries/:id', () => {
|
||||
it('should delete an external library', async () => {
|
||||
const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId });
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/libraries/${library.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(204);
|
||||
expect(body).toEqual({});
|
||||
|
||||
const libraries = await getAllLibraries({ headers: asBearerAuth(admin.accessToken) });
|
||||
expect(libraries).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: library.id,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete an external library with assets', async () => {
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
importPaths: [`${testAssetDirInternal}/temp`],
|
||||
});
|
||||
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/libraries/${library.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(204);
|
||||
expect(body).toEqual({});
|
||||
|
||||
const libraries = await getAllLibraries({ headers: asBearerAuth(admin.accessToken) });
|
||||
expect(libraries).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: library.id,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// ensure no files get deleted
|
||||
expect(existsSync(`${testAssetDir}/temp/directoryA/assetA.png`)).toBe(true);
|
||||
expect(existsSync(`${testAssetDir}/temp/directoryB/assetB.png`)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Kysely } from 'kysely';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { copyFile, mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
|
@ -126,37 +127,398 @@ describe(LibraryService.name, () => {
|
|||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('offline asset handling', () => {
|
||||
it('should set an asset offline if its file is missing', async () => {
|
||||
describe('create', () => {
|
||||
it('should create an external library with defaults', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const assetRepo = ctx.get(AssetRepository);
|
||||
const library = await ctx.createLibrary({ importPaths: [importPath] });
|
||||
const { asset } = await ctx.newAsset({
|
||||
ownerId: library.ownerId,
|
||||
libraryId: library.id,
|
||||
// the file is intentionally never created on disk
|
||||
originalPath: join(importPath, 'offline.png'),
|
||||
isExternal: true,
|
||||
isOffline: false,
|
||||
status: AssetStatus.Active,
|
||||
});
|
||||
const { user } = await ctx.newUser();
|
||||
|
||||
await expect(
|
||||
sut.handleSyncAssets({
|
||||
libraryId: library.id,
|
||||
importPaths: library.importPaths,
|
||||
exclusionPatterns: library.exclusionPatterns,
|
||||
assetIds: [asset.id],
|
||||
progressCounter: 1,
|
||||
totalAssets: 1,
|
||||
await expect(sut.create({ ownerId: user.id })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ownerId: user.id,
|
||||
name: 'New External Library',
|
||||
refreshedAt: null,
|
||||
assetCount: 0,
|
||||
importPaths: [],
|
||||
exclusionPatterns: expect.any(Array),
|
||||
}),
|
||||
).resolves.toBe(JobStatus.Success);
|
||||
|
||||
const updated = await assetRepo.getById(asset.id);
|
||||
expect(updated).toEqual(expect.objectContaining({ isOffline: true }));
|
||||
expect(updated?.deletedAt).toBeInstanceOf(Date);
|
||||
);
|
||||
});
|
||||
|
||||
it('should create an external library with options', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
|
||||
await expect(
|
||||
sut.create({
|
||||
ownerId: user.id,
|
||||
name: 'My Awesome Library',
|
||||
importPaths: [importPath],
|
||||
exclusionPatterns: ['**/Raw/**'],
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'My Awesome Library',
|
||||
importPaths: [importPath],
|
||||
exclusionPatterns: ['**/Raw/**'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should get a library by id', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const library = await ctx.createLibrary({ importPaths: [importPath] });
|
||||
|
||||
await expect(sut.get(library.id)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: library.id,
|
||||
ownerId: library.ownerId,
|
||||
refreshedAt: null,
|
||||
assetCount: 0,
|
||||
importPaths: [importPath],
|
||||
exclusionPatterns: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error when the library does not exist', async () => {
|
||||
const { sut } = setup();
|
||||
|
||||
await expect(sut.get(newUuid())).rejects.toThrow('Library not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should change the library name', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const library = await ctx.createLibrary({ importPaths: [importPath] });
|
||||
|
||||
await expect(sut.update(library.id, { name: 'New Library Name' })).resolves.toEqual(
|
||||
expect.objectContaining({ name: 'New Library Name' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should change the import paths', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
await mkdir(importPath, { recursive: true });
|
||||
const library = await ctx.createLibrary();
|
||||
|
||||
await expect(sut.update(library.id, { importPaths: [importPath] })).resolves.toEqual(
|
||||
expect.objectContaining({ importPaths: [importPath] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an import path that does not exist', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const library = await ctx.createLibrary();
|
||||
|
||||
await expect(sut.update(library.id, { importPaths: [join(tempDir, 'missing')] })).rejects.toThrow(
|
||||
'Invalid import path: Path does not exist (ENOENT)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should change the exclusion patterns', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const library = await ctx.createLibrary({ importPaths: [importPath] });
|
||||
|
||||
await expect(sut.update(library.id, { exclusionPatterns: ['**/Raw/**'] })).resolves.toEqual(
|
||||
expect.objectContaining({ exclusionPatterns: ['**/Raw/**'] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate', () => {
|
||||
it('should pass with no import paths', async () => {
|
||||
const { sut } = setup();
|
||||
|
||||
await expect(sut.validate(newUuid(), { importPaths: [] })).resolves.toEqual({ importPaths: [] });
|
||||
});
|
||||
|
||||
it('should fail if the path does not exist', async () => {
|
||||
const { sut } = setup();
|
||||
const missingPath = join(tempDir, 'does/not/exist');
|
||||
|
||||
await expect(sut.validate(newUuid(), { importPaths: [missingPath] })).resolves.toEqual({
|
||||
importPaths: [{ importPath: missingPath, isValid: false, message: 'Path does not exist (ENOENT)' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if the path is not absolute', async () => {
|
||||
const { sut } = setup();
|
||||
|
||||
await expect(sut.validate(newUuid(), { importPaths: ['relative/path'] })).resolves.toEqual({
|
||||
importPaths: [
|
||||
{
|
||||
importPath: 'relative/path',
|
||||
isValid: false,
|
||||
message: `Import path must be absolute, try ${resolve('relative/path')}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if the path is a file', async () => {
|
||||
const { sut } = setup();
|
||||
const filePath = await createFile(join(importPath, 'assetA.png'));
|
||||
|
||||
await expect(sut.validate(newUuid(), { importPaths: [filePath] })).resolves.toEqual({
|
||||
importPaths: [{ importPath: filePath, isValid: false, message: 'Not a directory' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDeleteLibrary', () => {
|
||||
it('should delete an empty library', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const libraryRepo = ctx.get(LibraryRepository);
|
||||
const library = await ctx.createLibrary({ importPaths: [importPath] });
|
||||
|
||||
await sut.delete(library.id);
|
||||
|
||||
// the library is hidden right away, but the row survives until the job runs
|
||||
await expect(libraryRepo.get(library.id)).resolves.toBeUndefined();
|
||||
await expect(libraryRepo.get(library.id, true)).resolves.toEqual(expect.objectContaining({ id: library.id }));
|
||||
expect(ctx.getMock(JobRepository).queue).toHaveBeenCalledWith({
|
||||
name: JobName.LibraryDelete,
|
||||
data: { id: library.id },
|
||||
});
|
||||
|
||||
await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
await expect(libraryRepo.get(library.id, true)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should delete a library with assets without deleting the files', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const libraryRepo = ctx.get(LibraryRepository);
|
||||
const jobs = ctx.getMock(JobRepository);
|
||||
const assetA = await createFile(join(importPath, 'assetA.png'));
|
||||
const assetB = await createFile(join(importPath, 'assetB.png'));
|
||||
const library = await ctx.createLibrary({ importPaths: [importPath] });
|
||||
|
||||
await ctx.scan(library.id);
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort());
|
||||
|
||||
await sut.delete(library.id);
|
||||
await expect(libraryRepo.get(library.id)).resolves.toBeUndefined();
|
||||
|
||||
jobs.queueAll.mockClear();
|
||||
await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
// the assets are trashed and queued for removal, so the library row stays until they are gone
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([]);
|
||||
await expect(libraryRepo.get(library.id, true)).resolves.toEqual(expect.objectContaining({ id: library.id }));
|
||||
// deleteOnDisk is what keeps the files of an external library, so assert it explicitly
|
||||
expect(jobs.queueAll).toHaveBeenCalledWith([
|
||||
{ name: JobName.AssetDelete, data: { id: expect.any(String), deleteOnDisk: false } },
|
||||
{ name: JobName.AssetDelete, data: { id: expect.any(String), deleteOnDisk: false } },
|
||||
]);
|
||||
|
||||
// the asset delete jobs are only queued here, so this just proves the handler itself unlinks nothing
|
||||
expect(existsSync(assetA)).toBe(true);
|
||||
expect(existsSync(assetB)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queueScan', () => {
|
||||
it('should import a new asset', async () => {
|
||||
const { ctx } = setup();
|
||||
|
||||
const assetPath = await createFile(join(importPath, 'assetA.png'));
|
||||
const library = await ctx.createLibrary({ importPaths: [importPath] });
|
||||
|
||||
await ctx.scan(library.id);
|
||||
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetPath]);
|
||||
});
|
||||
|
||||
it('should scan multiple import paths', async () => {
|
||||
const { ctx } = setup();
|
||||
|
||||
const directoryA = join(importRoot, 'directoryA');
|
||||
const directoryB = join(importRoot, 'directoryB');
|
||||
const assetA = await createFile(join(directoryA, 'assetA.png'));
|
||||
const assetB = await createFile(join(directoryB, 'assetB.png'));
|
||||
const library = await ctx.createLibrary({ importPaths: [directoryA, directoryB] });
|
||||
|
||||
await ctx.scan(library.id);
|
||||
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort());
|
||||
});
|
||||
|
||||
// https://github.com/immich-app/immich/issues/10699
|
||||
it('should scan multiple import paths with commas', async () => {
|
||||
const { ctx } = setup();
|
||||
|
||||
const folderA = join(importRoot, 'folder, a');
|
||||
const folderB = join(importRoot, 'folder, b');
|
||||
const assetA = await createFile(join(folderA, 'assetA.png'));
|
||||
const assetB = await createFile(join(folderB, 'assetB.png'));
|
||||
const library = await ctx.createLibrary({ importPaths: [folderA, folderB] });
|
||||
|
||||
await ctx.scan(library.id);
|
||||
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort());
|
||||
});
|
||||
|
||||
// https://github.com/immich-app/immich/issues/10699
|
||||
it('should scan multiple import paths with braces', async () => {
|
||||
const { ctx } = setup();
|
||||
|
||||
const folderA = join(importRoot, 'folder{ a');
|
||||
const folderB = join(importRoot, 'folder} b');
|
||||
const assetA = await createFile(join(folderA, 'assetA.png'));
|
||||
const assetB = await createFile(join(folderB, 'assetB.png'));
|
||||
const library = await ctx.createLibrary({ importPaths: [folderA, folderB] });
|
||||
|
||||
await ctx.scan(library.id);
|
||||
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort());
|
||||
});
|
||||
|
||||
// We never got backslashes to work
|
||||
const annoyingChars = [
|
||||
"'",
|
||||
'"',
|
||||
'`',
|
||||
'*',
|
||||
'{',
|
||||
'}',
|
||||
',',
|
||||
'(',
|
||||
')',
|
||||
'[',
|
||||
']',
|
||||
'?',
|
||||
'!',
|
||||
'@',
|
||||
'#',
|
||||
'$',
|
||||
'%',
|
||||
'^',
|
||||
'&',
|
||||
'=',
|
||||
'+',
|
||||
'~',
|
||||
'|',
|
||||
'<',
|
||||
'>',
|
||||
';',
|
||||
':',
|
||||
'/',
|
||||
];
|
||||
|
||||
it.each(annoyingChars)('should scan multiple import paths with %s', async (char) => {
|
||||
const { ctx } = setup();
|
||||
|
||||
const folderA = join(importRoot, `folder${char}1`);
|
||||
const folderB = join(importRoot, `folder${char}2`);
|
||||
const asset1 = await createFile(join(folderA, 'asset1.png'));
|
||||
const asset2 = await createFile(join(folderB, 'asset2.png'));
|
||||
const library = await ctx.createLibrary({ importPaths: [folderA, folderB] });
|
||||
|
||||
await ctx.scan(library.id);
|
||||
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort());
|
||||
});
|
||||
|
||||
it('should not import assets covered by an exclusion pattern', async () => {
|
||||
const { ctx } = setup();
|
||||
|
||||
await createFile(join(importRoot, 'directoryA/assetA.png'));
|
||||
const assetB = await createFile(join(importRoot, 'directoryB/assetB.png'));
|
||||
const library = await ctx.createLibrary({
|
||||
importPaths: [importRoot],
|
||||
exclusionPatterns: ['**/directoryA/**'],
|
||||
});
|
||||
|
||||
await ctx.scan(library.id);
|
||||
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetB]);
|
||||
});
|
||||
|
||||
it('should not import assets covered by multiple exclusion patterns', async () => {
|
||||
const { ctx } = setup();
|
||||
|
||||
await createFile(join(importRoot, 'directoryA/assetA.png'));
|
||||
await createFile(join(importRoot, 'directoryB/assetB.png'));
|
||||
const library = await ctx.createLibrary({
|
||||
importPaths: [importRoot],
|
||||
exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'],
|
||||
});
|
||||
|
||||
await ctx.scan(library.id);
|
||||
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('should offline assets covered by a new exclusion pattern', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const assetA = await createFile(join(importRoot, 'directoryA/assetA.png'));
|
||||
const assetB = await createFile(join(importRoot, 'directoryB/assetB.png'));
|
||||
const library = await ctx.createLibrary({ importPaths: [importRoot] });
|
||||
|
||||
await ctx.scan(library.id);
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort());
|
||||
|
||||
await sut.update(library.id, { exclusionPatterns: ['**/directoryA/**'] });
|
||||
await ctx.scan(library.id);
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetB]);
|
||||
|
||||
await sut.update(library.id, { exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'] });
|
||||
await ctx.scan(library.id);
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
// https://github.com/immich-app/immich/issues/17121
|
||||
it('should respect exclusion patterns when using multiple import paths', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const inPath = join(importRoot, 'exclusion');
|
||||
// a second import path that never exists on disk, as in the original report
|
||||
const missingPath = join(importRoot, 'exclusion2');
|
||||
const asset1 = await createFile(join(inPath, 'asset1.png'));
|
||||
const asset2 = await createFile(join(inPath, 'Raw/asset2.png'));
|
||||
const library = await ctx.createLibrary({ importPaths: [`${inPath}/`, `${missingPath}/`] });
|
||||
|
||||
// scanning twice must be idempotent
|
||||
await ctx.scan(library.id);
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort());
|
||||
await ctx.scan(library.id);
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort());
|
||||
|
||||
await sut.update(library.id, { exclusionPatterns: ['**/Raw/**'] });
|
||||
|
||||
await ctx.scan(library.id);
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]);
|
||||
await ctx.scan(library.id);
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]);
|
||||
});
|
||||
|
||||
const annoyingExclusionPatterns = ['@', '#', '$', '%', '^', '&', '='];
|
||||
|
||||
it.each(annoyingExclusionPatterns)('should support exclusion patterns with %s', async (char) => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const inPath = join(importRoot, 'exclusion');
|
||||
const excludedFolder = `${char}folder`;
|
||||
const asset1 = await createFile(join(inPath, 'asset1.png'));
|
||||
const asset2 = await createFile(join(inPath, excludedFolder, 'asset2.png'));
|
||||
const library = await ctx.createLibrary({ importPaths: [inPath] });
|
||||
|
||||
await ctx.scan(library.id);
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort());
|
||||
|
||||
await sut.update(library.id, { exclusionPatterns: [`**/${excludedFolder}/**`] });
|
||||
|
||||
await ctx.scan(library.id);
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleQueueSyncAssets', () => {
|
||||
it('should set an asset offline if its file is not in any import path', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const assetRepo = ctx.get(AssetRepository);
|
||||
|
|
@ -200,6 +562,38 @@ describe(LibraryService.name, () => {
|
|||
expect(updated).toEqual(expect.objectContaining({ isOffline: true }));
|
||||
expect(updated?.deletedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSyncAssets', () => {
|
||||
it('should set an asset offline if its file is missing', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const assetRepo = ctx.get(AssetRepository);
|
||||
const library = await ctx.createLibrary({ importPaths: [importPath] });
|
||||
const { asset } = await ctx.newAsset({
|
||||
ownerId: library.ownerId,
|
||||
libraryId: library.id,
|
||||
// the file is intentionally never created on disk
|
||||
originalPath: join(importPath, 'offline.png'),
|
||||
isExternal: true,
|
||||
isOffline: false,
|
||||
status: AssetStatus.Active,
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.handleSyncAssets({
|
||||
libraryId: library.id,
|
||||
importPaths: library.importPaths,
|
||||
exclusionPatterns: library.exclusionPatterns,
|
||||
assetIds: [asset.id],
|
||||
progressCounter: 1,
|
||||
totalAssets: 1,
|
||||
}),
|
||||
).resolves.toBe(JobStatus.Success);
|
||||
|
||||
const updated = await assetRepo.getById(asset.id);
|
||||
expect(updated).toEqual(expect.objectContaining({ isOffline: true }));
|
||||
expect(updated?.deletedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should not set an asset offline if file exists in import path and is not excluded', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
|
@ -389,30 +783,6 @@ describe(LibraryService.name, () => {
|
|||
expect(updated).toEqual(expect.objectContaining({ isOffline: false }));
|
||||
expect(updated?.deletedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('xmp scan behavior', () => {
|
||||
it('should queue sidecar checks for newly imported assets', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const jobs = ctx.getMock(JobRepository);
|
||||
const library = await ctx.createLibrary({ importPaths: [importPath] });
|
||||
const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef'));
|
||||
|
||||
await expect(
|
||||
sut.handleSyncFiles({
|
||||
libraryId: library.id,
|
||||
paths: [rawPath],
|
||||
progressCounter: 1,
|
||||
}),
|
||||
).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(jobs.queueAll).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
name: JobName.SidecarCheck,
|
||||
data: expect.objectContaining({ id: expect.any(String) }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should queue sidecar checks for assets whose file changed', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
|
@ -481,242 +851,27 @@ describe(LibraryService.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('scanning', () => {
|
||||
it('should import a new asset', async () => {
|
||||
const { ctx } = setup();
|
||||
|
||||
const assetPath = await createFile(join(importPath, 'assetA.png'));
|
||||
describe('handleSyncFiles', () => {
|
||||
it('should queue sidecar checks for newly imported assets', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const jobs = ctx.getMock(JobRepository);
|
||||
const library = await ctx.createLibrary({ importPaths: [importPath] });
|
||||
const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef'));
|
||||
|
||||
await ctx.scan(library.id);
|
||||
await expect(
|
||||
sut.handleSyncFiles({
|
||||
libraryId: library.id,
|
||||
paths: [rawPath],
|
||||
progressCounter: 1,
|
||||
}),
|
||||
).resolves.toBe(JobStatus.Success);
|
||||
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetPath]);
|
||||
});
|
||||
|
||||
it('should scan multiple import paths', async () => {
|
||||
const { ctx } = setup();
|
||||
|
||||
const directoryA = join(importRoot, 'directoryA');
|
||||
const directoryB = join(importRoot, 'directoryB');
|
||||
const assetA = await createFile(join(directoryA, 'assetA.png'));
|
||||
const assetB = await createFile(join(directoryB, 'assetB.png'));
|
||||
const library = await ctx.createLibrary({ importPaths: [directoryA, directoryB] });
|
||||
|
||||
await ctx.scan(library.id);
|
||||
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort());
|
||||
});
|
||||
|
||||
// https://github.com/immich-app/immich/issues/10699
|
||||
it('should scan multiple import paths with commas', async () => {
|
||||
const { ctx } = setup();
|
||||
|
||||
const folderA = join(importRoot, 'folder, a');
|
||||
const folderB = join(importRoot, 'folder, b');
|
||||
const assetA = await createFile(join(folderA, 'assetA.png'));
|
||||
const assetB = await createFile(join(folderB, 'assetB.png'));
|
||||
const library = await ctx.createLibrary({ importPaths: [folderA, folderB] });
|
||||
|
||||
await ctx.scan(library.id);
|
||||
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort());
|
||||
});
|
||||
|
||||
// https://github.com/immich-app/immich/issues/10699
|
||||
it('should scan multiple import paths with braces', async () => {
|
||||
const { ctx } = setup();
|
||||
|
||||
const folderA = join(importRoot, 'folder{ a');
|
||||
const folderB = join(importRoot, 'folder} b');
|
||||
const assetA = await createFile(join(folderA, 'assetA.png'));
|
||||
const assetB = await createFile(join(folderB, 'assetB.png'));
|
||||
const library = await ctx.createLibrary({ importPaths: [folderA, folderB] });
|
||||
|
||||
await ctx.scan(library.id);
|
||||
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort());
|
||||
});
|
||||
|
||||
// We never got backslashes to work
|
||||
const annoyingChars = [
|
||||
"'",
|
||||
'"',
|
||||
'`',
|
||||
'*',
|
||||
'{',
|
||||
'}',
|
||||
',',
|
||||
'(',
|
||||
')',
|
||||
'[',
|
||||
']',
|
||||
'?',
|
||||
'!',
|
||||
'@',
|
||||
'#',
|
||||
'$',
|
||||
'%',
|
||||
'^',
|
||||
'&',
|
||||
'=',
|
||||
'+',
|
||||
'~',
|
||||
'|',
|
||||
'<',
|
||||
'>',
|
||||
';',
|
||||
':',
|
||||
'/',
|
||||
];
|
||||
|
||||
it.each(annoyingChars)('should scan multiple import paths with %s', async (char) => {
|
||||
const { ctx } = setup();
|
||||
|
||||
const folderA = join(importRoot, `folder${char}1`);
|
||||
const folderB = join(importRoot, `folder${char}2`);
|
||||
const asset1 = await createFile(join(folderA, 'asset1.png'));
|
||||
const asset2 = await createFile(join(folderB, 'asset2.png'));
|
||||
const library = await ctx.createLibrary({ importPaths: [folderA, folderB] });
|
||||
|
||||
await ctx.scan(library.id);
|
||||
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('exclusion patterns', () => {
|
||||
it('should not import assets covered by an exclusion pattern', async () => {
|
||||
const { ctx } = setup();
|
||||
|
||||
await createFile(join(importRoot, 'directoryA/assetA.png'));
|
||||
const assetB = await createFile(join(importRoot, 'directoryB/assetB.png'));
|
||||
const library = await ctx.createLibrary({
|
||||
importPaths: [importRoot],
|
||||
exclusionPatterns: ['**/directoryA/**'],
|
||||
});
|
||||
|
||||
await ctx.scan(library.id);
|
||||
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetB]);
|
||||
});
|
||||
|
||||
it('should not import assets covered by multiple exclusion patterns', async () => {
|
||||
const { ctx } = setup();
|
||||
|
||||
await createFile(join(importRoot, 'directoryA/assetA.png'));
|
||||
await createFile(join(importRoot, 'directoryB/assetB.png'));
|
||||
const library = await ctx.createLibrary({
|
||||
importPaths: [importRoot],
|
||||
exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'],
|
||||
});
|
||||
|
||||
await ctx.scan(library.id);
|
||||
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('should offline assets covered by a new exclusion pattern', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const assetA = await createFile(join(importRoot, 'directoryA/assetA.png'));
|
||||
const assetB = await createFile(join(importRoot, 'directoryB/assetB.png'));
|
||||
const library = await ctx.createLibrary({ importPaths: [importRoot] });
|
||||
|
||||
await ctx.scan(library.id);
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort());
|
||||
|
||||
await sut.update(library.id, { exclusionPatterns: ['**/directoryA/**'] });
|
||||
await ctx.scan(library.id);
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetB]);
|
||||
|
||||
await sut.update(library.id, { exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'] });
|
||||
await ctx.scan(library.id);
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
// https://github.com/immich-app/immich/issues/17121
|
||||
it('should respect exclusion patterns when using multiple import paths', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const inPath = join(importRoot, 'exclusion');
|
||||
// a second import path that never exists on disk, as in the original report
|
||||
const missingPath = join(importRoot, 'exclusion2');
|
||||
const asset1 = await createFile(join(inPath, 'asset1.png'));
|
||||
const asset2 = await createFile(join(inPath, 'Raw/asset2.png'));
|
||||
const library = await ctx.createLibrary({ importPaths: [`${inPath}/`, `${missingPath}/`] });
|
||||
|
||||
// scanning twice must be idempotent
|
||||
await ctx.scan(library.id);
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort());
|
||||
await ctx.scan(library.id);
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort());
|
||||
|
||||
await sut.update(library.id, { exclusionPatterns: ['**/Raw/**'] });
|
||||
|
||||
await ctx.scan(library.id);
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]);
|
||||
await ctx.scan(library.id);
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]);
|
||||
});
|
||||
|
||||
const annoyingExclusionPatterns = ['@', '#', '$', '%', '^', '&', '='];
|
||||
|
||||
it.each(annoyingExclusionPatterns)('should support exclusion patterns with %s', async (char) => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const inPath = join(importRoot, 'exclusion');
|
||||
const excludedFolder = `${char}folder`;
|
||||
const asset1 = await createFile(join(inPath, 'asset1.png'));
|
||||
const asset2 = await createFile(join(inPath, excludedFolder, 'asset2.png'));
|
||||
const library = await ctx.createLibrary({ importPaths: [inPath] });
|
||||
|
||||
await ctx.scan(library.id);
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort());
|
||||
|
||||
await sut.update(library.id, { exclusionPatterns: [`**/${excludedFolder}/**`] });
|
||||
|
||||
await ctx.scan(library.id);
|
||||
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate', () => {
|
||||
it('should pass with no import paths', async () => {
|
||||
const { sut } = setup();
|
||||
|
||||
await expect(sut.validate(newUuid(), { importPaths: [] })).resolves.toEqual({ importPaths: [] });
|
||||
});
|
||||
|
||||
it('should fail if the path does not exist', async () => {
|
||||
const { sut } = setup();
|
||||
const missingPath = join(tempDir, 'does/not/exist');
|
||||
|
||||
await expect(sut.validate(newUuid(), { importPaths: [missingPath] })).resolves.toEqual({
|
||||
importPaths: [{ importPath: missingPath, isValid: false, message: 'Path does not exist (ENOENT)' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if the path is not absolute', async () => {
|
||||
const { sut } = setup();
|
||||
|
||||
await expect(sut.validate(newUuid(), { importPaths: ['relative/path'] })).resolves.toEqual({
|
||||
importPaths: [
|
||||
{
|
||||
importPath: 'relative/path',
|
||||
isValid: false,
|
||||
message: `Import path must be absolute, try ${resolve('relative/path')}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if the path is a file', async () => {
|
||||
const { sut } = setup();
|
||||
const filePath = await createFile(join(importPath, 'assetA.png'));
|
||||
|
||||
await expect(sut.validate(newUuid(), { importPaths: [filePath] })).resolves.toEqual({
|
||||
importPaths: [{ importPath: filePath, isValid: false, message: 'Not a directory' }],
|
||||
});
|
||||
expect(jobs.queueAll).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
name: JobName.SidecarCheck,
|
||||
data: expect.objectContaining({ id: expect.any(String) }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue