From 89f6190fb0884a82ef2caf29de9278c2cfcd0b79 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 8 Mar 2024 11:20:54 -0500 Subject: [PATCH] refactor: search e2e (#7732) --- e2e/src/api/specs/search.e2e-spec.ts | 224 ++++++++++++++ e2e/src/utils.ts | 5 + server/e2e/api/specs/search.e2e-spec.ts | 292 ------------------ server/src/domain/search/dto/search.dto.ts | 2 +- server/src/domain/search/search.service.ts | 2 - .../immich/controllers/search.controller.ts | 16 +- server/test/fixtures/index.ts | 1 - server/test/fixtures/search.stub.ts | 36 --- 8 files changed, 239 insertions(+), 339 deletions(-) create mode 100644 e2e/src/api/specs/search.e2e-spec.ts delete mode 100644 server/e2e/api/specs/search.e2e-spec.ts delete mode 100644 server/test/fixtures/search.stub.ts diff --git a/e2e/src/api/specs/search.e2e-spec.ts b/e2e/src/api/specs/search.e2e-spec.ts new file mode 100644 index 0000000000..de7d9ef4c5 --- /dev/null +++ b/e2e/src/api/specs/search.e2e-spec.ts @@ -0,0 +1,224 @@ +import { AssetFileUploadResponseDto, LoginResponseDto } from '@immich/sdk'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { Socket } from 'socket.io-client'; +import { errorDto } from 'src/responses'; +import { app, testAssetDir, utils } from 'src/utils'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const albums = { total: 0, count: 0, items: [], facets: [] }; + +describe('/search', () => { + let admin: LoginResponseDto; + let assetFalcon: AssetFileUploadResponseDto; + let assetDenali: AssetFileUploadResponseDto; + let websocket: Socket; + + beforeAll(async () => { + await utils.resetDatabase(); + admin = await utils.adminSetup(); + websocket = await utils.connectWebsocket(admin.accessToken); + + const files: string[] = [ + '/albums/nature/prairie_falcon.jpg', + '/formats/webp/denali.webp', + '/formats/raw/Nikon/D700/philadelphia.nef', + '/albums/nature/orychophragmus_violaceus.jpg', + '/albums/nature/notocactus_minimus.jpg', + '/albums/nature/silver_fir.jpg', + '/albums/nature/tanners_ridge.jpg', + '/albums/nature/cyclamen_persicum.jpg', + '/albums/nature/polemonium_reptans.jpg', + '/albums/nature/wood_anemones.jpg', + '/formats/heic/IMG_2682.heic', + '/formats/jpg/el_torcal_rocks.jpg', + '/formats/png/density_plot.png', + '/formats/motionphoto/Samsung One UI 6.jpg', + '/formats/motionphoto/Samsung One UI 6.heic', + '/formats/motionphoto/Samsung One UI 5.jpg', + '/formats/raw/Nikon/D80/glarus.nef', + '/metadata/gps-position/thompson-springs.jpg', + ]; + const assets: AssetFileUploadResponseDto[] = []; + for (const filename of files) { + const bytes = await readFile(join(testAssetDir, filename)); + assets.push( + await utils.createAsset(admin.accessToken, { + deviceAssetId: `test-${filename}`, + assetData: { bytes, filename }, + }), + ); + } + + for (const asset of assets) { + await utils.waitForWebsocketEvent({ event: 'upload', assetId: asset.id }); + } + + [assetFalcon, assetDenali] = assets; + }); + + afterAll(async () => { + await utils.disconnectWebsocket(websocket); + }); + + describe('POST /search/metadata', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post('/search/metadata'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should search by camera make', async () => { + const { status, body } = await request(app) + .post('/search/metadata') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ make: 'Canon' }); + expect(status).toBe(200); + expect(body).toEqual({ + albums, + assets: { + count: 2, + items: expect.arrayContaining([ + expect.objectContaining({ id: assetDenali.id }), + expect.objectContaining({ id: assetFalcon.id }), + ]), + facets: [], + nextPage: null, + total: 2, + }, + }); + }); + + it('should search by camera model', async () => { + const { status, body } = await request(app) + .post('/search/metadata') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ model: 'Canon EOS 7D' }); + expect(status).toBe(200); + expect(body).toEqual({ + albums, + assets: { + count: 1, + items: [expect.objectContaining({ id: assetDenali.id })], + facets: [], + nextPage: null, + total: 1, + }, + }); + }); + }); + + describe('POST /search/smart', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post('/search/smart'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + describe('GET /search/explore', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/search/explore'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should get explore data', async () => { + const { status, body } = await request(app) + .get('/search/explore') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual([ + { fieldName: 'exifInfo.city', items: [] }, + { fieldName: 'smartInfo.tags', items: [] }, + ]); + }); + }); + + describe('GET /search/places', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/search/places'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should get places', async () => { + const { status, body } = await request(app) + .get('/search/places?name=Paris') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThan(10); + }); + }); + + describe('GET /search/suggestions', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/search/suggestions'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should get suggestions for country', async () => { + const { status, body } = await request(app) + .get('/search/suggestions?type=country') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual(['United States of America']); + expect(status).toBe(200); + }); + + it('should get suggestions for state', async () => { + const { status, body } = await request(app) + .get('/search/suggestions?type=state') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual(['Douglas County, Nebraska', 'Mesa County, Colorado']); + expect(status).toBe(200); + }); + + it('should get suggestions for city', async () => { + const { status, body } = await request(app) + .get('/search/suggestions?type=city') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual(['Palisade', 'Ralston']); + expect(status).toBe(200); + }); + + it('should get suggestions for camera make', async () => { + const { status, body } = await request(app) + .get('/search/suggestions?type=camera-make') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual([ + 'Apple', + 'Canon', + 'FUJIFILM', + 'NIKON CORPORATION', + 'PENTAX Corporation', + 'samsung', + 'SONY', + ]); + expect(status).toBe(200); + }); + + it('should get suggestions for camera model', async () => { + const { status, body } = await request(app) + .get('/search/suggestions?type=camera-model') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual([ + 'Canon EOS 7D', + 'Canon EOS R5', + 'DSLR-A550', + 'FinePix S3Pro', + 'iPhone 7', + 'NIKON D700', + 'NIKON D750', + 'NIKON D80', + 'PENTAX K10D', + 'SM-F711N', + 'SM-S906U', + 'SM-T970', + ]); + expect(status).toBe(200); + }); + }); +}); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 49ac2b8122..d62497b8e4 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -173,6 +173,7 @@ export const utils = { }, waitForWebsocketEvent: async ({ event, assetId, timeout: ms }: WaitOptions): Promise => { + console.log(`Waiting for ${event} [${assetId}]`); const set = events[event]; if (set.has(assetId)) { return; @@ -232,6 +233,10 @@ export const utils = { const assetData = dto?.assetData?.bytes || makeRandomImage(); const filename = dto?.assetData?.filename || 'example.png'; + if (dto?.assetData?.bytes) { + console.log(`Uploading ${filename}`); + } + const builder = request(app) .post(`/asset/upload`) .attach('assetData', assetData, filename) diff --git a/server/e2e/api/specs/search.e2e-spec.ts b/server/e2e/api/specs/search.e2e-spec.ts deleted file mode 100644 index 0e5cc428cc..0000000000 --- a/server/e2e/api/specs/search.e2e-spec.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { - AssetResponseDto, - IAssetRepository, - ISearchRepository, - LibraryResponseDto, - LoginResponseDto, - mapAsset, -} from '@app/domain'; -import { SearchController } from '@app/immich'; -import { INestApplication } from '@nestjs/common'; -import { errorStub, searchStub } from '@test/fixtures'; -import request from 'supertest'; -import { api } from '../../client'; -import { generateAsset, testApp } from '../utils'; - -describe(`${SearchController.name}`, () => { - let app: INestApplication; - let server: any; - let loginResponse: LoginResponseDto; - let accessToken: string; - let libraries: LibraryResponseDto[]; - let assetRepository: IAssetRepository; - let smartInfoRepository: ISearchRepository; - let asset1: AssetResponseDto; - - beforeAll(async () => { - app = await testApp.create(); - server = app.getHttpServer(); - assetRepository = app.get(IAssetRepository); - smartInfoRepository = app.get(ISearchRepository); - }); - - 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); - }); - - describe('GET /search (exif)', () => { - beforeEach(async () => { - const { id: assetId } = await assetRepository.create(generateAsset(loginResponse.userId, libraries)); - await assetRepository.upsertExif({ assetId, ...searchStub.exif }); - - const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true }); - if (!assetWithMetadata) { - throw new Error('Asset not found'); - } - asset1 = mapAsset(assetWithMetadata); - }); - - 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: [], - }, - }); - }); - }); - - describe('GET /search (smart info)', () => { - beforeEach(async () => { - const { id: assetId } = await assetRepository.create(generateAsset(loginResponse.userId, libraries)); - await assetRepository.upsertExif({ assetId, ...searchStub.exif }); - await smartInfoRepository.upsert({ assetId, ...searchStub.smartInfo }, 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); - }); - - 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: [], - }, - }); - }); - }); - - describe('GET /search (file name)', () => { - beforeEach(async () => { - const { id: assetId } = await assetRepository.create(generateAsset(loginResponse.userId, libraries)); - await assetRepository.upsertExif({ assetId, ...searchStub.exif }); - - const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true }); - if (!assetWithMetadata) { - throw new Error('Asset not found'); - } - asset1 = mapAsset(assetWithMetadata); - }); - - it('should return assets when searching by file name', async () => { - if (asset1?.originalFileName.length === 0) { - throw new Error('Asset 1 does not have an original file name'); - } - - const { status, body } = await request(server) - .get('/search') - .set('Authorization', `Bearer ${accessToken}`) - .query({ q: asset1.originalFileName }); - - expect(status).toBe(200); - expect(body).toMatchObject({ - albums: { - total: 0, - count: 0, - items: [], - facets: [], - }, - assets: { - total: 1, - count: 1, - items: [ - { - id: asset1.id, - originalFileName: asset1.originalFileName, - }, - ], - facets: [], - }, - }); - }); - - it('should return assets when searching by file name with extension', async () => { - if (asset1?.originalFileName.length === 0) { - throw new Error('Asset 1 does not have an original file name'); - } - - const { status, body } = await request(server) - .get('/search') - .set('Authorization', `Bearer ${accessToken}`) - .query({ q: asset1.originalFileName + '.jpg' }); - - expect(status).toBe(200); - expect(body).toMatchObject({ - albums: { - total: 0, - count: 0, - items: [], - facets: [], - }, - assets: { - total: 1, - count: 1, - items: [ - { - id: asset1.id, - originalFileName: asset1.originalFileName, - }, - ], - facets: [], - }, - }); - }); - }); -}); diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts index 70d8ee2884..9fa7d8e8ba 100644 --- a/server/src/domain/search/dto/search.dto.ts +++ b/server/src/domain/search/dto/search.dto.ts @@ -123,7 +123,7 @@ class BaseSearchDto { @ValidateBoolean({ optional: true }) isNotInAlbum?: boolean; - @Optional() + @ValidateUUID({ each: true, optional: true }) personIds?: string[]; } diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index 8dce8434c7..00c5e883ec 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -1,5 +1,4 @@ import { AssetEntity } from '@app/infra/entities'; -import { ImmichLogger } from '@app/infra/logger'; import { Inject, Injectable } from '@nestjs/common'; import { AssetOrder, AssetResponseDto, mapAsset } from '../asset'; import { AuthDto } from '../auth'; @@ -30,7 +29,6 @@ import { SearchResponseDto } from './response-dto'; @Injectable() export class SearchService { - private logger = new ImmichLogger(SearchService.name); private configCore: SystemConfigCore; constructor( diff --git a/server/src/immich/controllers/search.controller.ts b/server/src/immich/controllers/search.controller.ts index b807da9665..df1bec7c63 100644 --- a/server/src/immich/controllers/search.controller.ts +++ b/server/src/immich/controllers/search.controller.ts @@ -12,7 +12,7 @@ import { SmartSearchDto, } from '@app/domain'; import { SearchSuggestionRequestDto } from '@app/domain/search/dto/search-suggestion.dto'; -import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, HttpStatus, Post, Query } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { Auth, Authenticated } from '../app.guard'; import { UseValidation } from '../app.utils'; @@ -24,22 +24,24 @@ import { UseValidation } from '../app.utils'; export class SearchController { constructor(private service: SearchService) {} + @Get() + @ApiOperation({ deprecated: true }) + search(@Auth() auth: AuthDto, @Query() dto: SearchDto): Promise { + return this.service.search(auth, dto); + } + @Post('metadata') + @HttpCode(HttpStatus.OK) searchMetadata(@Auth() auth: AuthDto, @Body() dto: MetadataSearchDto): Promise { return this.service.searchMetadata(auth, dto); } @Post('smart') + @HttpCode(HttpStatus.OK) searchSmart(@Auth() auth: AuthDto, @Body() dto: SmartSearchDto): Promise { return this.service.searchSmart(auth, dto); } - @Get() - @ApiOperation({ deprecated: true }) - search(@Auth() auth: AuthDto, @Query() dto: SearchDto): Promise { - return this.service.search(auth, dto); - } - @Get('explore') getExploreData(@Auth() auth: AuthDto): Promise { return this.service.getExploreData(auth) as Promise; diff --git a/server/test/fixtures/index.ts b/server/test/fixtures/index.ts index 7a25c159a3..2217c9b1ff 100644 --- a/server/test/fixtures/index.ts +++ b/server/test/fixtures/index.ts @@ -10,7 +10,6 @@ export * from './library.stub'; export * from './media.stub'; export * from './partner.stub'; export * from './person.stub'; -export * from './search.stub'; export * from './shared-link.stub'; export * from './system-config.stub'; export * from './tag.stub'; diff --git a/server/test/fixtures/search.stub.ts b/server/test/fixtures/search.stub.ts deleted file mode 100644 index fc197d94f4..0000000000 --- a/server/test/fixtures/search.stub.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { SearchResult } from '@app/domain'; -import { AssetEntity, ExifEntity, SmartInfoEntity } from '@app/infra/entities'; -import { assetStub } from '.'; - -export const searchStub = { - emptyResults: Object.freeze>({ - total: 0, - count: 0, - page: 1, - items: [], - facets: [], - distances: [], - }), - - withImage: Object.freeze>({ - total: 1, - count: 1, - page: 1, - items: [assetStub.image], - facets: [], - distances: [], - }), - - exif: Object.freeze>({ - latitude: 90, - longitude: 90, - city: 'Immich', - state: 'Nebraska', - country: 'United States', - make: 'Canon', - model: 'EOS Rebel T7', - lensModel: 'Fancy lens', - }), - - smartInfo: Object.freeze>({ objects: ['car', 'tree'], tags: ['accident'] }), -};