1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-08 12:56:48 +01:00
immich/server/e2e/api/specs/search.e2e-spec.ts

293 lines
8 KiB
TypeScript
Raw Normal View History

2023-12-08 17:15:46 +01:00
import {
AssetResponseDto,
IAssetRepository,
ISmartInfoRepository,
LibraryResponseDto,
LoginResponseDto,
mapAsset,
} from '@app/domain';
import { SearchController } from '@app/immich';
import { INestApplication } from '@nestjs/common';
import { errorStub, searchStub } from '@test/fixtures';
2023-12-08 17:15:46 +01:00
import request from 'supertest';
2024-01-24 23:24:53 +01:00
import { api } from '../../client';
import { generateAsset, testApp } from '../utils';
2023-12-08 17:15:46 +01:00
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 () => {
test(cli): e2e testing (#5101) * Allow building and installing cli * feat: add format fix * docs: remove cli folder * feat: use immich scoped package * feat: rewrite cli readme * docs: add info on running without building * cleanup * chore: remove import functionality from cli * feat: add logout to cli * docs: add todo for file format from server * docs: add compilation step to cli * fix: success message spacing * feat: can create albums * fix: add check step to cli * fix: typos * feat: pull file formats from server * chore: use crawl service from server * chore: fix lint * docs: add cli documentation * chore: rename ignore pattern * chore: add version number to cli * feat: use sdk * fix: cleanup * feat: album name on windows * chore: remove skipped asset field * feat: add more info to server-info command * chore: cleanup * wip * chore: remove unneeded packages * e2e test can start * git ignore for geocode in cli * add cli e2e to github actions * can do e2e tests in the cli * simplify e2e test * cleanup * set matrix strategy in workflow * run npm ci in server * choose different working directory * check out submodules too * increase test timeout * set node version * cli docker e2e tests * fix cli docker file * run cli e2e in correct folder * set docker context * correct docker build * remove cli from dockerignore * chore: fix docs links * feat: add cli v2 milestone * fix: set correct cli date * remove submodule * chore: add npmignore * chore(cli): push to npm * fix: server e2e * run npm ci in server * remove state from e2e * run npm ci in server * reshuffle docker compose files * use new e2e composes in makefile * increase test timeout to 10 minutes * make github actions run makefile e2e tests * cleanup github test names * assert on server version * chore: split cli e2e tests into one file per command * chore: set cli release working dir * chore: add repo url to npmjs * chore: bump node setup to v4 * chore: normalize the github url * check e2e code in lint * fix lint * test key login flow * feat: allow configurable config dir * fix session service tests * create missing dir * cleanup * bump cli version to 2.0.4 * remove form-data * feat: allow single files as argument * add version option * bump dependencies * fix lint * wip use axios as upload * version bump * cApiTALiZaTiON * don't touch package lock * wip: don't use job queues * don't use make for cli e2e * fix server e2e * chore: remove old gha step * add npm ci to server --------- Co-authored-by: Alex <alex.tran1502@gmail.com> Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-12-19 03:29:26 +01:00
app = await testApp.create();
server = app.getHttpServer();
2023-12-08 17:15:46 +01:00
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);
});
describe('GET /search (exif)', () => {
beforeEach(async () => {
const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
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);
});
2023-12-08 17:15:46 +01:00
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 assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
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);
});
2023-12-08 17:15:46 +01:00
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: [],
},
});
});
});
fix(server): add filename search (#6394) Fixes https://github.com/immich-app/immich/issues/5982. There are basically three options: 1. Search `originalFileName` by dropping a file extension from the query (if present). Lower fidelity but very easy - just a standard index & equality. 2. Search `originalPath` by adding an index on `reverse(originalPath)` and using `starts_with(reverse(query) + "/", reverse(originalPath)`. A weird index & query but high fidelity. 3. Add a new generated column called `originalFileNameWithExtension` or something. More storage, kinda jank. TBH, I think (1) is good enough and easy to make better in the future. For example, if I search "DSC_4242.jpg", I don't really think it matters if "DSC_4242.mov" also shows up. edit: There's a fourth approach that we discussed a bit in Discord and decided we could switch to it in the future: using a GIN. The minor issue is that Postgres doesn't tokenize paths in a useful (they're a single token and it won't match against partial components). We can solve that by tokenizing it ourselves. For example: ``` immich=# with vecs as (select to_tsvector('simple', array_to_string(string_to_array('upload/library/sushain/2015/2015-08-09/IMG_275.JPG', '/'), ' ')) as vec) select * from vecs where vec @@ phraseto_tsquery('simple', array_to_string(string_to_array('library/sushain', '/'), ' ')); vec ------------------------------------------------------------------------------- '-08':6 '-09':7 '2015':4,5 'img_275.jpg':8 'library':2 'sushain':3 'upload':1 (1 row) ``` The query is also tokenized with the 'split-by-slash-join-with-space' strategy. This strategy results in `IMG_275.JPG`, `2015`, `sushain` and `library/sushain` matching. But, `08` and `IMG_275` do not match. The former is because the token is `-08` and the latter because the `img_275.jpg` token is matched against exactly.
2024-01-15 21:40:28 +01:00
describe('GET /search (file name)', () => {
beforeEach(async () => {
const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
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: [],
},
});
});
});
2023-12-08 17:15:46 +01:00
});