refactor: library e2e (#30865)
This commit is contained in:
@@ -138,29 +138,6 @@ describe('/libraries', () => {
|
||||
});
|
||||
|
||||
describe('POST /libraries/:id/scan', () => {
|
||||
it('should import new asset when scanning external library', async () => {
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
importPaths: [`${testAssetDirInternal}/temp/directoryA`],
|
||||
});
|
||||
|
||||
const { status } = await request(app)
|
||||
.post(`/libraries/${library.id}/scan`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send();
|
||||
expect(status).toBe(204);
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
||||
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, {
|
||||
originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`,
|
||||
libraryId: library.id,
|
||||
});
|
||||
expect(assets.count).toBe(1);
|
||||
});
|
||||
|
||||
it('should process metadata and thumbnails for external asset', async () => {
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
@@ -180,337 +157,6 @@ describe('/libraries', () => {
|
||||
expect(asset.thumbhash).not.toBe(null);
|
||||
});
|
||||
|
||||
it('should scan external library with exclusion pattern', async () => {
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
importPaths: [`${testAssetDirInternal}/temp`],
|
||||
exclusionPatterns: ['**/directoryA/**'],
|
||||
});
|
||||
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||
|
||||
expect(assets.count).toBe(1);
|
||||
|
||||
expect(assets.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ originalPath: expect.stringContaining('directoryB/assetB.png') }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should scan external library with multiple exclusion patterns', async () => {
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
importPaths: [`${testAssetDirInternal}/temp`],
|
||||
exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'],
|
||||
});
|
||||
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||
|
||||
expect(assets.count).toBe(0);
|
||||
|
||||
expect(assets.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('should remove assets covered by a new exclusion pattern', async () => {
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
importPaths: [`${testAssetDirInternal}/temp`],
|
||||
});
|
||||
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
{
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||
|
||||
expect(assets.count).toBe(2);
|
||||
|
||||
expect(assets.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ originalPath: expect.stringContaining('directoryA/assetA.png') }),
|
||||
expect.objectContaining({ originalPath: expect.stringContaining('directoryB/assetB.png') }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
await utils.updateLibrary(admin.accessToken, library.id, {
|
||||
exclusionPatterns: ['**/directoryA/**'],
|
||||
});
|
||||
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
{
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||
|
||||
expect(assets.count).toBe(1);
|
||||
|
||||
expect(assets.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ originalPath: expect.stringContaining('directoryB/assetB.png') }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
await utils.updateLibrary(admin.accessToken, library.id, {
|
||||
exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'],
|
||||
});
|
||||
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
{
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||
|
||||
expect(assets.count).toBe(0);
|
||||
|
||||
expect(assets.items).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should scan multiple import paths', async () => {
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`],
|
||||
});
|
||||
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||
|
||||
expect(assets.count).toBe(2);
|
||||
expect(assets.items.find((asset) => asset.originalPath.includes('directoryA'))).toBeDefined();
|
||||
expect(assets.items.find((asset) => asset.originalPath.includes('directoryB'))).toBeDefined();
|
||||
});
|
||||
|
||||
it('should scan multiple import paths with commas', async () => {
|
||||
// https://github.com/immich-app/immich/issues/10699
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
importPaths: [`${testAssetDirInternal}/temp/folder, a`, `${testAssetDirInternal}/temp/folder, b`],
|
||||
});
|
||||
|
||||
utils.createImageFile(`${testAssetDir}/temp/folder, a/assetA.png`);
|
||||
utils.createImageFile(`${testAssetDir}/temp/folder, b/assetB.png`);
|
||||
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||
|
||||
expect(assets.count).toBe(2);
|
||||
expect(assets.items.find((asset) => asset.originalPath.includes('folder, a'))).toBeDefined();
|
||||
expect(assets.items.find((asset) => asset.originalPath.includes('folder, b'))).toBeDefined();
|
||||
|
||||
utils.removeImageFile(`${testAssetDir}/temp/folder, a/assetA.png`);
|
||||
utils.removeImageFile(`${testAssetDir}/temp/folder, b/assetB.png`);
|
||||
});
|
||||
|
||||
it('should scan multiple import paths with braces', async () => {
|
||||
// https://github.com/immich-app/immich/issues/10699
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
importPaths: [`${testAssetDirInternal}/temp/folder{ a`, `${testAssetDirInternal}/temp/folder} b`],
|
||||
});
|
||||
|
||||
utils.createImageFile(`${testAssetDir}/temp/folder{ a/assetA.png`);
|
||||
utils.createImageFile(`${testAssetDir}/temp/folder} b/assetB.png`);
|
||||
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||
|
||||
expect(assets.count).toBe(2);
|
||||
expect(assets.items.find((asset) => asset.originalPath.includes('folder{ a'))).toBeDefined();
|
||||
expect(assets.items.find((asset) => asset.originalPath.includes('folder} b'))).toBeDefined();
|
||||
|
||||
utils.removeImageFile(`${testAssetDir}/temp/folder{ a/assetA.png`);
|
||||
utils.removeImageFile(`${testAssetDir}/temp/folder} b/assetB.png`);
|
||||
});
|
||||
|
||||
const annoyingChars = [
|
||||
"'",
|
||||
'"',
|
||||
'`',
|
||||
'*',
|
||||
'{',
|
||||
'}',
|
||||
',',
|
||||
'(',
|
||||
')',
|
||||
'[',
|
||||
']',
|
||||
'?',
|
||||
'!',
|
||||
'@',
|
||||
'#',
|
||||
'$',
|
||||
'%',
|
||||
'^',
|
||||
'&',
|
||||
'=',
|
||||
'+',
|
||||
'~',
|
||||
'|',
|
||||
'<',
|
||||
'>',
|
||||
';',
|
||||
':',
|
||||
'/', // We never got backslashes to work
|
||||
];
|
||||
|
||||
it.each(annoyingChars)('should scan multiple import paths with %s', async (char) => {
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
importPaths: [`${testAssetDirInternal}/temp/folder${char}1`, `${testAssetDirInternal}/temp/folder${char}2`],
|
||||
});
|
||||
|
||||
utils.createImageFile(`${testAssetDir}/temp/folder${char}1/asset1.png`);
|
||||
utils.createImageFile(`${testAssetDir}/temp/folder${char}2/asset2.png`);
|
||||
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||
|
||||
expect(assets.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ originalPath: expect.stringContaining(`folder${char}1/asset1.png`) }),
|
||||
expect.objectContaining({ originalPath: expect.stringContaining(`folder${char}2/asset2.png`) }),
|
||||
]),
|
||||
);
|
||||
|
||||
utils.removeImageFile(`${testAssetDir}/temp/folder${char}1/asset1.png`);
|
||||
utils.removeImageFile(`${testAssetDir}/temp/folder${char}2/asset2.png`);
|
||||
});
|
||||
|
||||
it('should respect exclusion patterns when using multiple import paths', async () => {
|
||||
// https://github.com/immich-app/immich/issues/17121
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
importPaths: [`${testAssetDirInternal}/temp/exclusion/`, `${testAssetDirInternal}/temp/exclusion2/`],
|
||||
});
|
||||
|
||||
const excludedFolder = `Raw`;
|
||||
|
||||
utils.createImageFile(`${testAssetDir}/temp/exclusion/asset1.png`);
|
||||
utils.createImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`);
|
||||
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
{
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||
|
||||
expect(assets.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }),
|
||||
expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
{
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||
|
||||
expect(assets.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }),
|
||||
expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: [`**/${excludedFolder}/**`] });
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
{
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||
|
||||
expect(assets.items).toEqual([
|
||||
expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }),
|
||||
]);
|
||||
}
|
||||
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
{
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||
|
||||
expect(assets.items).toEqual([
|
||||
expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }),
|
||||
]);
|
||||
}
|
||||
|
||||
utils.removeImageFile(`${testAssetDir}/temp/exclusion/asset1.png`);
|
||||
utils.removeImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`);
|
||||
});
|
||||
|
||||
const annoyingExclusionPatterns = ['@', '#', '$', '%', '^', '&', '='];
|
||||
|
||||
it.each(annoyingExclusionPatterns)('should support exclusion patterns with %s', async (char) => {
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
importPaths: [`${testAssetDirInternal}/temp/exclusion/`],
|
||||
});
|
||||
|
||||
const excludedFolder = `${char}folder`;
|
||||
|
||||
utils.createImageFile(`${testAssetDir}/temp/exclusion/asset1.png`);
|
||||
utils.createImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`);
|
||||
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
{
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||
|
||||
expect(assets.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }),
|
||||
expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
{
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||
|
||||
expect(assets.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }),
|
||||
expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: [`**/${excludedFolder}/**`] });
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
{
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||
|
||||
expect(assets.items).toEqual([
|
||||
expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }),
|
||||
]);
|
||||
}
|
||||
|
||||
await utils.scan(admin.accessToken, library.id);
|
||||
|
||||
{
|
||||
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
|
||||
|
||||
expect(assets.items).toEqual([
|
||||
expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }),
|
||||
]);
|
||||
}
|
||||
|
||||
utils.removeImageFile(`${testAssetDir}/temp/exclusion/asset1.png`);
|
||||
utils.removeImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`);
|
||||
});
|
||||
|
||||
it('should reimport a modified file', async () => {
|
||||
const library = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
@@ -625,70 +271,6 @@ describe('/libraries', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /libraries/:id/validate', () => {
|
||||
it('should pass with no import paths', async () => {
|
||||
const response = await utils.validateLibrary(admin.accessToken, library.id, { importPaths: [] });
|
||||
expect(response.importPaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('should fail if path does not exist', async () => {
|
||||
const pathToTest = `${testAssetDirInternal}/does/not/exist`;
|
||||
|
||||
const response = await utils.validateLibrary(admin.accessToken, library.id, {
|
||||
importPaths: [pathToTest],
|
||||
});
|
||||
|
||||
expect(response.importPaths?.length).toEqual(1);
|
||||
const pathResponse = response?.importPaths?.at(0);
|
||||
|
||||
expect(pathResponse).toEqual({
|
||||
importPath: pathToTest,
|
||||
isValid: false,
|
||||
message: `Path does not exist (ENOENT)`,
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail if path isn't absolute", async () => {
|
||||
const pathToTest = `relative/path`;
|
||||
|
||||
const cwd = process.cwd();
|
||||
// Create directory in cwd
|
||||
utils.createDirectory(`${cwd}/${pathToTest}`);
|
||||
|
||||
const response = await utils.validateLibrary(admin.accessToken, library.id, {
|
||||
importPaths: [pathToTest],
|
||||
});
|
||||
|
||||
utils.removeDirectory(`${cwd}/${pathToTest}`);
|
||||
|
||||
expect(response.importPaths?.length).toEqual(1);
|
||||
const pathResponse = response?.importPaths?.at(0);
|
||||
|
||||
expect(pathResponse).toEqual({
|
||||
importPath: pathToTest,
|
||||
isValid: false,
|
||||
message: expect.stringMatching('Import path must be absolute, try /usr/src/app/relative/path'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if path is a file', async () => {
|
||||
const pathToTest = `${testAssetDirInternal}/albums/nature/el_torcal_rocks.jpg`;
|
||||
|
||||
const response = await utils.validateLibrary(admin.accessToken, library.id, {
|
||||
importPaths: [pathToTest],
|
||||
});
|
||||
|
||||
expect(response.importPaths?.length).toEqual(1);
|
||||
const pathResponse = response?.importPaths?.at(0);
|
||||
|
||||
expect(pathResponse).toEqual({
|
||||
importPath: pathToTest,
|
||||
isValid: false,
|
||||
message: `Not a directory`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /libraries/:id', () => {
|
||||
it('should delete an external library', async () => {
|
||||
const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId });
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { copyFile, mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { AssetStatus, JobName, JobStatus } from 'src/enum';
|
||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
@@ -13,9 +14,13 @@ import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { LibraryService } from 'src/services/library.service';
|
||||
import { newMediumService, testAssetsDir } from 'test/medium.factory';
|
||||
import { MediumTestContext, testAssetsDir } from 'test/medium.factory';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
// `validateImportPath` checks candidate paths against the media location
|
||||
StorageCore.setMediaLocation('/photos');
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
const fileModifiedAt = new Date(1_700_000_000_000);
|
||||
@@ -34,20 +39,68 @@ const copyTestAsset = async (source: string, filePath: string, modifiedAt: Date
|
||||
return filePath;
|
||||
};
|
||||
|
||||
class LibraryTestContext extends MediumTestContext<typeof LibraryService> {
|
||||
constructor(database: Kysely<DB>) {
|
||||
super(LibraryService, {
|
||||
database,
|
||||
real: [AssetRepository, AssetJobRepository, CryptoRepository, LibraryRepository, StorageRepository],
|
||||
mock: [EventRepository, JobRepository, LoggingRepository],
|
||||
});
|
||||
|
||||
const jobs = this.getMock(JobRepository);
|
||||
jobs.queue.mockResolvedValue();
|
||||
jobs.queueAll.mockResolvedValue();
|
||||
|
||||
this.getMock(EventRepository).emit.mockResolvedValue();
|
||||
}
|
||||
|
||||
async createLibrary(options: { importPaths?: string[]; exclusionPatterns?: string[] } = {}) {
|
||||
const { user } = await this.newUser();
|
||||
return this.get(LibraryRepository).create({
|
||||
ownerId: user.id,
|
||||
name: 'Medium test library',
|
||||
importPaths: options.importPaths ?? [],
|
||||
exclusionPatterns: options.exclusionPatterns ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
/** Runs a full library scan, manually routing jobs to their handlers */
|
||||
async scan(libraryId: string) {
|
||||
const jobs = this.getMock(JobRepository);
|
||||
|
||||
jobs.queue.mockClear();
|
||||
await this.sut.handleQueueSyncFiles({ id: libraryId });
|
||||
for (const [job] of jobs.queue.mock.calls) {
|
||||
if (job.name === JobName.LibrarySyncFiles) {
|
||||
await this.sut.handleSyncFiles(job.data);
|
||||
}
|
||||
}
|
||||
|
||||
jobs.queue.mockClear();
|
||||
await this.sut.handleQueueSyncAssets({ id: libraryId });
|
||||
for (const [job] of jobs.queue.mock.calls) {
|
||||
if (job.name === JobName.LibrarySyncAssets) {
|
||||
await this.sut.handleSyncAssets(job.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The paths a library scan left visible, i.e. neither offline nor trashed */
|
||||
async getAssetPaths(libraryId: string) {
|
||||
const assets = await this.database
|
||||
.selectFrom('asset')
|
||||
.select('originalPath')
|
||||
.where('libraryId', '=', libraryId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
|
||||
return assets.map(({ originalPath }) => originalPath).sort();
|
||||
}
|
||||
}
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
const context = newMediumService(LibraryService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [AssetRepository, AssetJobRepository, CryptoRepository, LibraryRepository, StorageRepository],
|
||||
mock: [EventRepository, JobRepository, LoggingRepository],
|
||||
});
|
||||
|
||||
const jobs = context.ctx.getMock(JobRepository);
|
||||
jobs.queue.mockResolvedValue();
|
||||
jobs.queueAll.mockResolvedValue();
|
||||
|
||||
context.ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
|
||||
return context;
|
||||
const ctx = new LibraryTestContext(db || defaultDatabase);
|
||||
return { sut: ctx.sut, ctx };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -73,25 +126,11 @@ describe(LibraryService.name, () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const createLibrary = async (
|
||||
ctx: ReturnType<typeof setup>['ctx'],
|
||||
options: { importPaths?: string[]; exclusionPatterns?: string[] } = {},
|
||||
) => {
|
||||
const { user } = await ctx.newUser();
|
||||
return ctx.get(LibraryRepository).create({
|
||||
ownerId: user.id,
|
||||
name: 'Medium test library',
|
||||
importPaths: options.importPaths ?? [importPath],
|
||||
exclusionPatterns: options.exclusionPatterns ?? [],
|
||||
});
|
||||
};
|
||||
|
||||
describe('offline asset handling', () => {
|
||||
it('should set an asset offline if its file is missing', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const assetRepo = ctx.get(AssetRepository);
|
||||
|
||||
const library = await createLibrary(ctx);
|
||||
const library = await ctx.createLibrary({ importPaths: [importPath] });
|
||||
const { asset } = await ctx.newAsset({
|
||||
ownerId: library.ownerId,
|
||||
libraryId: library.id,
|
||||
@@ -121,8 +160,7 @@ describe(LibraryService.name, () => {
|
||||
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);
|
||||
|
||||
const library = await createLibrary(ctx, { importPaths: [importPath] });
|
||||
const library = await ctx.createLibrary({ importPaths: [importPath] });
|
||||
const { asset } = await ctx.newAsset({
|
||||
ownerId: library.ownerId,
|
||||
libraryId: library.id,
|
||||
@@ -142,8 +180,7 @@ describe(LibraryService.name, () => {
|
||||
it('should set an asset offline if its file is covered by an exclusion pattern', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const assetRepo = ctx.get(AssetRepository);
|
||||
|
||||
const library = await createLibrary(ctx, {
|
||||
const library = await ctx.createLibrary({
|
||||
importPaths: [importRoot],
|
||||
exclusionPatterns: ['**/excluded/**'],
|
||||
});
|
||||
@@ -167,8 +204,7 @@ describe(LibraryService.name, () => {
|
||||
it('should not set an asset offline if file exists in import path and is not excluded', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const assetRepo = ctx.get(AssetRepository);
|
||||
|
||||
const library = await createLibrary(ctx, {
|
||||
const library = await ctx.createLibrary({
|
||||
importPaths: [importRoot],
|
||||
exclusionPatterns: ['**/excluded/**'],
|
||||
});
|
||||
@@ -202,8 +238,7 @@ describe(LibraryService.name, () => {
|
||||
it('should set an offline asset to online if its file exists in an import path and is not excluded', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const assetRepo = ctx.get(AssetRepository);
|
||||
|
||||
const library = await createLibrary(ctx, { importPaths: [importPath] });
|
||||
const library = await ctx.createLibrary({ importPaths: [importPath] });
|
||||
const { asset } = await ctx.newAsset({
|
||||
ownerId: library.ownerId,
|
||||
libraryId: library.id,
|
||||
@@ -233,8 +268,7 @@ describe(LibraryService.name, () => {
|
||||
it('should not set an offline asset to online if its file exists in an import path but is excluded', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const assetRepo = ctx.get(AssetRepository);
|
||||
|
||||
const library = await createLibrary(ctx, {
|
||||
const library = await ctx.createLibrary({
|
||||
importPaths: [importRoot],
|
||||
exclusionPatterns: ['**/excluded/**'],
|
||||
});
|
||||
@@ -268,8 +302,7 @@ describe(LibraryService.name, () => {
|
||||
it('should keep an offline asset offline if it is outside import paths', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const assetRepo = ctx.get(AssetRepository);
|
||||
|
||||
const library = await createLibrary(ctx, { importPaths: [importPath] });
|
||||
const library = await ctx.createLibrary({ importPaths: [importPath] });
|
||||
const { asset } = await ctx.newAsset({
|
||||
ownerId: library.ownerId,
|
||||
libraryId: library.id,
|
||||
@@ -299,8 +332,7 @@ describe(LibraryService.name, () => {
|
||||
it('should set a trashed asset offline if its file is missing', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const assetRepo = ctx.get(AssetRepository);
|
||||
|
||||
const library = await createLibrary(ctx, { importPaths: [importPath] });
|
||||
const library = await ctx.createLibrary({ importPaths: [importPath] });
|
||||
const { asset } = await ctx.newAsset({
|
||||
ownerId: library.ownerId,
|
||||
libraryId: library.id,
|
||||
@@ -331,8 +363,7 @@ describe(LibraryService.name, () => {
|
||||
it('should set a trashed offline asset to online but keep it in trash', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const assetRepo = ctx.get(AssetRepository);
|
||||
|
||||
const library = await createLibrary(ctx, { importPaths: [importPath] });
|
||||
const library = await ctx.createLibrary({ importPaths: [importPath] });
|
||||
const { asset } = await ctx.newAsset({
|
||||
ownerId: library.ownerId,
|
||||
libraryId: library.id,
|
||||
@@ -364,8 +395,7 @@ describe(LibraryService.name, () => {
|
||||
it('should queue sidecar checks for newly imported assets', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const jobs = ctx.getMock(JobRepository);
|
||||
|
||||
const library = await createLibrary(ctx, { importPaths: [importPath] });
|
||||
const library = await ctx.createLibrary({ importPaths: [importPath] });
|
||||
const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef'));
|
||||
|
||||
await expect(
|
||||
@@ -387,8 +417,7 @@ describe(LibraryService.name, () => {
|
||||
it('should queue sidecar checks for assets whose file changed', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const jobs = ctx.getMock(JobRepository);
|
||||
|
||||
const library = await createLibrary(ctx, { importPaths: [importPath] });
|
||||
const library = await ctx.createLibrary({ importPaths: [importPath] });
|
||||
const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef'));
|
||||
|
||||
const { asset } = await ctx.newAsset({
|
||||
@@ -424,8 +453,7 @@ describe(LibraryService.name, () => {
|
||||
it('should not queue sidecar checks for unchanged assets', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const jobs = ctx.getMock(JobRepository);
|
||||
|
||||
const library = await createLibrary(ctx, { importPaths: [importPath] });
|
||||
const library = await ctx.createLibrary({ importPaths: [importPath] });
|
||||
const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef'));
|
||||
|
||||
const { asset } = await ctx.newAsset({
|
||||
@@ -452,4 +480,243 @@ describe(LibraryService.name, () => {
|
||||
expect(jobs.queueAll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanning', () => {
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
||||
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' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user