added e2e tests for metadata search

fixed fileCreatedAt
This commit is contained in:
mertalev 2023-11-24 16:07:45 -05:00
parent 8a8da5f5c8
commit c18affaad1
No known key found for this signature in database
GPG key ID: 9181CD92C0A1C5E3
7 changed files with 304 additions and 49 deletions

View file

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

View file

@ -54,9 +54,15 @@ export class SearchService {
async search(authUser: AuthUserDto, dto: SearchDto): Promise<SearchResponseDto> { async search(authUser: AuthUserDto, dto: SearchDto): Promise<SearchResponseDto> {
const { machineLearning } = await this.configCore.getConfig(); 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 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[] = []; let assets: AssetEntity[] = [];
@ -74,7 +80,7 @@ export class SearchService {
default: default:
break; break;
} }
return { return {
albums: { albums: {
total: 0, total: 0,

View file

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

View file

@ -10,19 +10,15 @@ import {
usePagination, usePagination,
} from '@app/domain'; } from '@app/domain';
import { AssetController } from '@app/immich'; 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 { AssetRepository } from '@app/infra/repositories';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { api } from '@test/api'; import { api } from '@test/api';
import { errorStub, userDto, uuidStub } from '@test/fixtures'; 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 { randomBytes } from 'crypto';
import { DateTime } from 'luxon';
import request from 'supertest'; 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 makeUploadDto = (options?: { omit: string }): Record<string, any> => {
const dto: Record<string, any> = { const dto: Record<string, any> = {
deviceAssetId: 'example-image', deviceAssetId: 'example-image',
@ -54,30 +50,14 @@ describe(`${AssetController.name} (e2e)`, () => {
let asset4: AssetResponseDto; let asset4: AssetResponseDto;
let asset5: AssetResponseDto; let asset5: AssetResponseDto;
let assetCount = 0; const createAsset = async (
const createAsset = async (loginResponse: LoginResponseDto, createdAt: Date, other: Partial<AssetEntity> = {}) => { loginResponse: LoginResponseDto,
const id = assetCount++; fileCreatedAt: Date,
const asset = await assetRepository.create({ other: Partial<AssetEntity> = {},
createdAt: today.toJSDate(), ) => {
updatedAt: today.toJSDate(), const asset = await assetRepository.create(
ownerId: loginResponse.userId, generateAsset(loginResponse.userId, libraries, { fileCreatedAt, ...other }),
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,
});
return mapAsset(asset); return mapAsset(asset);
}; };
@ -764,7 +744,11 @@ describe(`${AssetController.name} (e2e)`, () => {
const personRepository = app.get<IPersonRepository>(IPersonRepository); const personRepository = app.get<IPersonRepository>(IPersonRepository);
const person = await personRepository.create({ ownerId: asset1.ownerId, name: 'Test Person' }); 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) const { status, body } = await request(server)
.put(`/asset/${asset1.id}`) .put(`/asset/${asset1.id}`)
@ -1339,7 +1323,11 @@ describe(`${AssetController.name} (e2e)`, () => {
beforeEach(async () => { beforeEach(async () => {
const personRepository = app.get<IPersonRepository>(IPersonRepository); const personRepository = app.get<IPersonRepository>(IPersonRepository);
const person = await personRepository.create({ ownerId: asset1.ownerId, name: 'Test Person' }); 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 () => { it('should not return asset with facesRecognizedAt unset', async () => {

View file

@ -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: [],
},
});
});
});
});

View file

@ -35,7 +35,7 @@ export default async () => {
if (process.env.DB_HOSTNAME === undefined) { 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. // 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) .withExposedPorts(5432)
.withDatabase('immich') .withDatabase('immich')
.withUsername('postgres') .withUsername('postgres')

View file

@ -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 { AppModule } from '@app/immich';
import { dataSource } from '@app/infra'; import { dataSource } from '@app/infra';
import { AssetEntity, AssetType, LibraryType } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { randomBytes } from 'crypto';
import * as fs from 'fs'; import * as fs from 'fs';
import { DateTime } from 'luxon';
import path from 'path'; import path from 'path';
import { EntityTarget, ObjectLiteral } from 'typeorm'; import { EntityTarget, ObjectLiteral } from 'typeorm';
import { AppService } from '../src/microservices/app.service'; 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_PATH = process.env.IMMICH_TEST_ASSET_PATH;
export const IMMICH_TEST_ASSET_TEMP_PATH = path.normalize(`${IMMICH_TEST_ASSET_PATH}/temp/`); 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 { export interface ResetOptions {
entities?: EntityTarget<ObjectLiteral>[]; entities?: EntityTarget<ObjectLiteral>[];
} }
@ -115,3 +121,37 @@ export async function restoreTempFolder(): Promise<void> {
// Create temp folder // Create temp folder
await fs.promises.mkdir(IMMICH_TEST_ASSET_TEMP_PATH); 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,
};
}