From c18affaad1c9d92a09bfa9651314e5a4ec79a104 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 24 Nov 2023 16:07:45 -0500 Subject: [PATCH] added e2e tests for metadata search fixed fileCreatedAt --- .../domain/repositories/asset.repository.ts | 4 +- server/src/domain/search/search.service.ts | 12 +- .../infra/repositories/asset.repository.ts | 36 ++- server/test/e2e/asset.e2e-spec.ts | 52 ++--- server/test/e2e/search.e2e-spec.ts | 205 ++++++++++++++++++ server/test/e2e/setup.ts | 2 +- server/test/test-utils.ts | 42 +++- 7 files changed, 304 insertions(+), 49 deletions(-) create mode 100644 server/test/e2e/search.e2e-spec.ts diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts index e68c8888b..96d615416 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/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; getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated; getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated; - getById(id: string): Promise; + getById(id: string, relations?: FindOptionsRelations): Promise; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated; getRandom(userId: string, count: number): Promise; diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index f4bc55c4f..b1b3e67d0 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -54,9 +54,15 @@ export class SearchService { async search(authUser: AuthUserDto, dto: SearchDto): Promise { 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, diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 7f401b0ae..eff3ab738 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/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 { - return this.repository.findOne({ - where: { id }, - relations: { + getById(id: string, relations: FindOptionsRelations): Promise { + 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(); diff --git a/server/test/e2e/asset.e2e-spec.ts b/server/test/e2e/asset.e2e-spec.ts index e749231b2..ef9eff809 100644 --- a/server/test/e2e/asset.e2e-spec.ts +++ b/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 => { const dto: Record = { 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 = {}) => { - 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 = {}, + ) => { + 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); 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); 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 () => { diff --git a/server/test/e2e/search.e2e-spec.ts b/server/test/e2e/search.e2e-spec.ts new file mode 100644 index 000000000..d0524d2db --- /dev/null +++ b/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); + smartInfoRepository = app.get(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: [], + }, + }); + }); + }); +}); diff --git a/server/test/e2e/setup.ts b/server/test/e2e/setup.ts index be35d1e43..d24df8994 100644 --- a/server/test/e2e/setup.ts +++ b/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') diff --git a/server/test/test-utils.ts b/server/test/test-utils.ts index ae14ac4ae..5e99b535a 100644 --- a/server/test/test-utils.ts +++ b/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[]; } @@ -115,3 +121,37 @@ export async function restoreTempFolder(): Promise { // 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 = {}, +): 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, + }; +}