Sfoglia il codice sorgente

added e2e tests for metadata search

fixed fileCreatedAt
mertalev 1 anno fa
parent
commit
c18affaad1

+ 2 - 2
server/src/domain/repositories/asset.repository.ts

@@ -1,5 +1,5 @@
 import { SearchExploreItem } from '@app/domain';
-import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '@app/infra/entities';
+import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity, SmartInfoEntity } from '@app/infra/entities';
 import { FindOptionsRelations } from 'typeorm';
 import { Paginated, PaginationOptions } from '../domain.util';
 
@@ -173,7 +173,7 @@ export interface IAssetRepository {
   getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null>;
   getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
   getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated<AssetEntity>;
-  getById(id: string): Promise<AssetEntity | null>;
+  getById(id: string, relations?: FindOptionsRelations<AssetEntity>): Promise<AssetEntity | null>;
   getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
   getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated<AssetEntity>;
   getRandom(userId: string, count: number): Promise<AssetEntity[]>;

+ 9 - 3
server/src/domain/search/search.service.ts

@@ -54,9 +54,15 @@ export class SearchService {
 
   async search(authUser: AuthUserDto, dto: SearchDto): Promise<SearchResponseDto> {
     const { machineLearning } = await this.configCore.getConfig();
-    const query = dto.q || dto.query || '*';
+    const query = dto.q || dto.query;
+    if (!query) {
+      throw new Error('Missing query');
+    }
     const hasClip = machineLearning.enabled && machineLearning.clip.enabled;
-    const strategy = dto.clip && hasClip ? SearchStrategy.CLIP : SearchStrategy.TEXT;
+    if (dto.clip && !hasClip) {
+      throw new Error('CLIP is not enabled');
+    }
+    const strategy = dto.clip ? SearchStrategy.CLIP : SearchStrategy.TEXT;
 
     let assets: AssetEntity[] = [];
 
@@ -74,7 +80,7 @@ export class SearchService {
       default:
         break;
     }
-
+    
     return {
       albums: {
         total: 0,

+ 26 - 10
server/src/infra/repositories/asset.repository.ts

@@ -2,7 +2,6 @@ import {
   AssetBuilderOptions,
   AssetCreate,
   AssetExploreFieldOptions,
-  AssetExploreOptions,
   AssetSearchOptions,
   AssetStats,
   AssetStatsOptions,
@@ -361,16 +360,20 @@ export class AssetRepository implements IAssetRepository {
   }
 
   @GenerateSql({ params: [DummyValue.UUID] })
-  getById(id: string): Promise<AssetEntity | null> {
-    return this.repository.findOne({
-      where: { id },
-      relations: {
+  getById(id: string, relations: FindOptionsRelations<AssetEntity>): Promise<AssetEntity | null> {
+    if (!relations) {
+      relations = {
         faces: {
           person: true,
         },
         library: true,
         stack: true,
-      },
+      };
+    }
+
+    return this.repository.findOne({
+      where: { id },
+      relations,
       // We are specifically asking for this asset. Return it even if it is soft deleted
       withDeleted: true,
     });
@@ -705,8 +708,13 @@ export class AssetRepository implements IAssetRepository {
       .having('count(city) >= :minAssetsPerField', { minAssetsPerField })
       .orderBy('random()')
       .limit(maxFields);
-  
-    const items = await this.getBuilder({ userIds: [ownerId], exifInfo: false, assetType: AssetType.IMAGE, isArchived: false })
+
+    const items = await this.getBuilder({
+      userIds: [ownerId],
+      exifInfo: false,
+      assetType: AssetType.IMAGE,
+      isArchived: false,
+    })
       .select('c.city', 'value')
       .addSelect('asset.id', 'data')
       .distinctOn(['c.city'])
@@ -731,7 +739,12 @@ export class AssetRepository implements IAssetRepository {
       .orderBy('random()')
       .limit(maxFields);
 
-    const items = await this.getBuilder({ userIds: [ownerId], exifInfo: false, assetType: AssetType.IMAGE, isArchived: false })
+    const items = await this.getBuilder({
+      userIds: [ownerId],
+      exifInfo: false,
+      assetType: AssetType.IMAGE,
+      isArchived: false,
+    })
       .select('unnest(si.tags)', 'value')
       .addSelect('asset.id', 'data')
       .distinctOn(['unnest(si.tags)'])
@@ -808,7 +821,10 @@ export class AssetRepository implements IAssetRepository {
       .innerJoin('smart_info', 'si', 'si."assetId" = assets."id"')
       .innerJoin('exif', 'e', 'assets."id" = e."assetId"')
       .where('a.ownerId = :ownerId', { ownerId })
-      .where('(e."exifTextSearchableColumn" || si."smartInfoTextSearchableColumn") @@ PLAINTO_TSQUERY(\'english\', :query)', { query })
+      .where(
+        '(e."exifTextSearchableColumn" || si."smartInfoTextSearchableColumn") @@ PLAINTO_TSQUERY(\'english\', :query)',
+        { query },
+      )
       .limit(numResults)
       .getRawMany();
 

+ 20 - 32
server/test/e2e/asset.e2e-spec.ts

@@ -10,19 +10,15 @@ import {
   usePagination,
 } from '@app/domain';
 import { AssetController } from '@app/immich';
-import { AssetEntity, AssetType, LibraryType, SharedLinkType } from '@app/infra/entities';
+import { AssetEntity, AssetType, SharedLinkType } from '@app/infra/entities';
 import { AssetRepository } from '@app/infra/repositories';
 import { INestApplication } from '@nestjs/common';
 import { api } from '@test/api';
 import { errorStub, userDto, uuidStub } from '@test/fixtures';
-import { testApp } from '@test/test-utils';
+import { generateAsset, testApp, today, yesterday } from '@test/test-utils';
 import { randomBytes } from 'crypto';
-import { DateTime } from 'luxon';
 import request from 'supertest';
 
-const today = DateTime.fromObject({ year: 2023, month: 11, day: 3 });
-const yesterday = today.minus({ days: 1 });
-
 const makeUploadDto = (options?: { omit: string }): Record<string, any> => {
   const dto: Record<string, any> = {
     deviceAssetId: 'example-image',
@@ -54,30 +50,14 @@ describe(`${AssetController.name} (e2e)`, () => {
   let asset4: AssetResponseDto;
   let asset5: AssetResponseDto;
 
-  let assetCount = 0;
-  const createAsset = async (loginResponse: LoginResponseDto, createdAt: Date, other: Partial<AssetEntity> = {}) => {
-    const id = assetCount++;
-    const asset = await assetRepository.create({
-      createdAt: today.toJSDate(),
-      updatedAt: today.toJSDate(),
-      ownerId: loginResponse.userId,
-      checksum: randomBytes(20),
-      originalPath: `/tests/test_${id}`,
-      deviceAssetId: `test_${id}`,
-      deviceId: 'e2e-test',
-      libraryId: (
-        libraries.find(
-          ({ ownerId, type }) => ownerId === loginResponse.userId && type === LibraryType.UPLOAD,
-        ) as LibraryResponseDto
-      ).id,
-      isVisible: true,
-      fileCreatedAt: createdAt,
-      fileModifiedAt: new Date(),
-      localDateTime: createdAt,
-      type: AssetType.IMAGE,
-      originalFileName: `test_${id}`,
-      ...other,
-    });
+  const createAsset = async (
+    loginResponse: LoginResponseDto,
+    fileCreatedAt: Date,
+    other: Partial<AssetEntity> = {},
+  ) => {
+    const asset = await assetRepository.create(
+      generateAsset(loginResponse.userId, libraries, { fileCreatedAt, ...other }),
+    );
 
     return mapAsset(asset);
   };
@@ -764,7 +744,11 @@ describe(`${AssetController.name} (e2e)`, () => {
       const personRepository = app.get<IPersonRepository>(IPersonRepository);
       const person = await personRepository.create({ ownerId: asset1.ownerId, name: 'Test Person' });
 
-      await personRepository.createFace({ assetId: asset1.id, personId: person.id, embedding: Array.from({length: 512}, Math.random) });
+      await personRepository.createFace({
+        assetId: asset1.id,
+        personId: person.id,
+        embedding: Array.from({ length: 512 }, Math.random),
+      });
 
       const { status, body } = await request(server)
         .put(`/asset/${asset1.id}`)
@@ -1339,7 +1323,11 @@ describe(`${AssetController.name} (e2e)`, () => {
           beforeEach(async () => {
             const personRepository = app.get<IPersonRepository>(IPersonRepository);
             const person = await personRepository.create({ ownerId: asset1.ownerId, name: 'Test Person' });
-            await personRepository.createFace({ assetId: asset1.id, personId: person.id, embedding: Array.from({length: 512}, Math.random) });
+            await personRepository.createFace({
+              assetId: asset1.id,
+              personId: person.id,
+              embedding: Array.from({ length: 512 }, Math.random),
+            });
           });
 
           it('should not return asset with facesRecognizedAt unset', async () => {

+ 205 - 0
server/test/e2e/search.e2e-spec.ts

@@ -0,0 +1,205 @@
+import { AssetResponseDto, IAssetRepository, ISmartInfoRepository, LibraryResponseDto, LoginResponseDto, mapAsset } from '@app/domain';
+import { SearchController } from '@app/immich';
+import { INestApplication } from '@nestjs/common';
+import { api } from '@test/api';
+import { errorStub } from '@test/fixtures';
+import { generateAsset, testApp } from '@test/test-utils';
+import request from 'supertest';
+
+describe(`${SearchController.name}`, () => {
+  let app: INestApplication;
+  let server: any;
+  let loginResponse: LoginResponseDto;
+  let accessToken: string;
+  let libraries: LibraryResponseDto[];
+  let assetRepository: IAssetRepository;
+  let smartInfoRepository: ISmartInfoRepository;
+  let asset1: AssetResponseDto;
+
+  beforeAll(async () => {
+    [server, app] = await testApp.create();
+    assetRepository = app.get<IAssetRepository>(IAssetRepository);
+    smartInfoRepository = app.get<ISmartInfoRepository>(ISmartInfoRepository);
+  });
+
+  afterAll(async () => {
+    await testApp.teardown();
+  });
+
+  beforeEach(async () => {
+    await testApp.reset();
+    await api.authApi.adminSignUp(server);
+    loginResponse = await api.authApi.adminLogin(server);
+    accessToken = loginResponse.accessToken;
+    libraries = await api.libraryApi.getAll(server, accessToken);
+
+    const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
+    await assetRepository.upsertExif({
+      assetId,
+      latitude: 90,
+      longitude: 90,
+      city: 'Immich',
+      state: 'Nebraska',
+      country: 'United States',
+      make: 'Canon',
+      model: 'EOS Rebel T7',
+      lensModel: 'Fancy lens',
+    });
+    await smartInfoRepository.upsert(
+      { assetId, objects: ['car', 'tree'], tags: ['accident'] },
+      Array.from({ length: 512 }, Math.random));
+    const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true, smartInfo: true });
+    if (!assetWithMetadata) {
+      throw new Error('Asset not found');
+    }
+    asset1 = mapAsset(assetWithMetadata);
+  });
+
+  describe('GET /search', () => {
+    beforeEach(async () => {});
+
+    it('should require authentication', async () => {
+      const { status, body } = await request(server).get('/search');
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should return assets when searching by exif', async () => {
+      if (!asset1?.exifInfo?.make) {
+        throw new Error('Asset 1 does not have exif info');
+      }
+
+      const { status, body } = await request(server)
+        .get('/search')
+        .set('Authorization', `Bearer ${accessToken}`)
+        .query({ q: asset1.exifInfo.make });
+
+      expect(status).toBe(200);
+      expect(body).toMatchObject({
+        albums: {
+          total: 0,
+          count: 0,
+          items: [],
+          facets: [],
+        },
+        assets: {
+          total: 1,
+          count: 1,
+          items: [
+            {
+              id: asset1.id,
+              exifInfo: {
+                make: asset1.exifInfo.make,
+              },
+            },
+          ],
+          facets: [],
+        },
+      });
+    });
+
+    it('should be case-insensitive for metadata search', async () => {
+      if (!asset1?.exifInfo?.make) {
+        throw new Error('Asset 1 does not have exif info');
+      }
+
+      const { status, body } = await request(server)
+        .get('/search')
+        .set('Authorization', `Bearer ${accessToken}`)
+        .query({ q: asset1.exifInfo.make.toLowerCase() });
+
+      expect(status).toBe(200);
+      expect(body).toMatchObject({
+        albums: {
+          total: 0,
+          count: 0,
+          items: [],
+          facets: [],
+        },
+        assets: {
+          total: 1,
+          count: 1,
+          items: [
+            {
+              id: asset1.id,
+              exifInfo: {
+                make: asset1.exifInfo.make,
+              },
+            },
+          ],
+          facets: [],
+        },
+      });
+    });
+
+    it('should be whitespace-insensitive for metadata search', async () => {
+      if (!asset1?.exifInfo?.make) {
+        throw new Error('Asset 1 does not have exif info');
+      }
+
+      const { status, body } = await request(server)
+        .get('/search')
+        .set('Authorization', `Bearer ${accessToken}`)
+        .query({ q: ` ${asset1.exifInfo.make}  ` });
+
+      expect(status).toBe(200);
+      expect(body).toMatchObject({
+        albums: {
+          total: 0,
+          count: 0,
+          items: [],
+          facets: [],
+        },
+        assets: {
+          total: 1,
+          count: 1,
+          items: [
+            {
+              id: asset1.id,
+              exifInfo: {
+                make: asset1.exifInfo.make,
+              },
+            },
+          ],
+          facets: [],
+        },
+      });
+    });
+
+    it('should return assets when searching by object', async () => {
+      if (!asset1?.smartInfo?.objects) {
+        throw new Error('Asset 1 does not have smart info');
+      }
+
+      const { status, body } = await request(server)
+        .get('/search')
+        .set('Authorization', `Bearer ${accessToken}`)
+        .query({ q: asset1.smartInfo.objects[0] });
+
+      expect(status).toBe(200);
+      expect(body).toMatchObject({
+        albums: {
+          total: 0,
+          count: 0,
+          items: [],
+          facets: [],
+        },
+        assets: {
+          total: 1,
+          count: 1,
+          items: [
+            {
+              id: asset1.id,
+              smartInfo: {
+                objects: asset1.smartInfo.objects,
+                tags: asset1.smartInfo.tags,
+              },
+            },
+          ],
+          facets: [],
+        },
+      });
+    });
+  });
+});

+ 1 - 1
server/test/e2e/setup.ts

@@ -35,7 +35,7 @@ export default async () => {
 
   if (process.env.DB_HOSTNAME === undefined) {
     // DB hostname not set which likely means we're not running e2e through docker compose. Start a local postgres container.
-    const pg = await new PostgreSqlContainer('postgres')
+    const pg = await new PostgreSqlContainer('tensorchord/pgvecto-rs:pg14-v0.1.10')
       .withExposedPorts(5432)
       .withDatabase('immich')
       .withUsername('postgres')

+ 41 - 1
server/test/test-utils.ts

@@ -1,9 +1,12 @@
-import { IJobRepository, JobItem, JobItemHandler, QueueName } from '@app/domain';
+import { AssetCreate, IJobRepository, JobItem, JobItemHandler, LibraryResponseDto, QueueName } from '@app/domain';
 import { AppModule } from '@app/immich';
 import { dataSource } from '@app/infra';
+import { AssetEntity, AssetType, LibraryType } from '@app/infra/entities';
 import { INestApplication } from '@nestjs/common';
 import { Test } from '@nestjs/testing';
+import { randomBytes } from 'crypto';
 import * as fs from 'fs';
+import { DateTime } from 'luxon';
 import path from 'path';
 import { EntityTarget, ObjectLiteral } from 'typeorm';
 import { AppService } from '../src/microservices/app.service';
@@ -11,6 +14,9 @@ import { AppService } from '../src/microservices/app.service';
 export const IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH;
 export const IMMICH_TEST_ASSET_TEMP_PATH = path.normalize(`${IMMICH_TEST_ASSET_PATH}/temp/`);
 
+export const today = DateTime.fromObject({ year: 2023, month: 11, day: 3 });
+export const yesterday = today.minus({ days: 1 });
+
 export interface ResetOptions {
   entities?: EntityTarget<ObjectLiteral>[];
 }
@@ -115,3 +121,37 @@ export async function restoreTempFolder(): Promise<void> {
   // Create temp folder
   await fs.promises.mkdir(IMMICH_TEST_ASSET_TEMP_PATH);
 }
+
+function randomDate(start: Date, end: Date): Date {
+  return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
+}
+
+let assetCount = 0;
+export function generateAsset(
+  userId: string,
+  libraries: LibraryResponseDto[],
+  other: Partial<AssetEntity> = {},
+): AssetCreate {
+  const id = assetCount++;
+  const { fileCreatedAt = randomDate(new Date(1970, 1, 1), new Date(2023, 1, 1)) } = other;
+
+  return {
+    createdAt: today.toJSDate(),
+    updatedAt: today.toJSDate(),
+    ownerId: userId,
+    checksum: randomBytes(20),
+    originalPath: `/tests/test_${id}`,
+    deviceAssetId: `test_${id}`,
+    deviceId: 'e2e-test',
+    libraryId: (
+      libraries.find(({ ownerId, type }) => ownerId === userId && type === LibraryType.UPLOAD) as LibraryResponseDto
+    ).id,
+    isVisible: true,
+    fileCreatedAt,
+    fileModifiedAt: new Date(),
+    localDateTime: fileCreatedAt,
+    type: AssetType.IMAGE,
+    originalFileName: `test_${id}`,
+    ...other,
+  };
+}