Files
immich/server/src/services/search.service.spec.ts
T
2026-07-25 16:11:42 +02:00

363 lines
15 KiB
TypeScript

import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { SearchSuggestionType } from 'src/dtos/search.dto';
import { AssetVisibility } from 'src/enum';
import { SearchService } from 'src/services/search.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { getForAsset } from 'test/mappers';
import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
import { beforeEach, vitest } from 'vitest';
vitest.useFakeTimers();
describe(SearchService.name, () => {
let sut: SearchService;
let mocks: ServiceMocks;
beforeEach(() => {
({ sut, mocks } = newTestService(SearchService));
mocks.partner.getAll.mockResolvedValue([]);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('searchPerson', () => {
it('should pass options to search', async () => {
const auth = AuthFactory.create();
const name = 'foo';
mocks.person.getByName.mockResolvedValue([]);
await sut.searchPerson(auth, { name, withHidden: false });
expect(mocks.person.getByName).toHaveBeenCalledWith(auth.user.id, name, { withHidden: false });
await sut.searchPerson(auth, { name, withHidden: true });
expect(mocks.person.getByName).toHaveBeenCalledWith(auth.user.id, name, { withHidden: true });
});
});
describe('searchPlaces', () => {
it('should search places', async () => {
mocks.search.searchPlaces.mockResolvedValue([
{
id: 42,
name: 'my place',
latitude: 420,
longitude: 69,
admin1Code: null,
admin1Name: null,
admin2Code: null,
admin2Name: null,
alternateNames: null,
countryCode: 'US',
modificationDate: new Date(),
},
]);
await sut.searchPlaces({ name: 'place' });
expect(mocks.search.searchPlaces).toHaveBeenCalledWith('place');
});
});
describe('getExploreData', () => {
it('should get recent assets and assets by city and tag', async () => {
const auth = AuthFactory.create();
const asset = AssetFactory.from()
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
.build();
mocks.asset.getAssetIdByCity.mockResolvedValue({
fieldName: 'exifInfo.city',
items: [{ value: 'city', data: asset.id }],
});
mocks.asset.getRecentlyCreatedAssetIds.mockResolvedValue({
fieldName: 'createdAt',
items: [{ value: asset.createdAt, data: asset.id }],
});
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([asset as never]);
const expectedResponse = [
{ fieldName: 'exifInfo.city', items: [{ value: 'city', data: mapAsset(getForAsset(asset)) }] },
{
fieldName: 'createdAt',
items: [{ value: asset.createdAt.toISOString(), data: mapAsset(getForAsset(asset)) }],
},
];
const result = await sut.getExploreData(auth);
expect(result).toEqual(expectedResponse);
});
});
describe('getSearchSuggestions', () => {
it('should return search suggestions for country', async () => {
mocks.search.getCountries.mockResolvedValue(['USA']);
mocks.partner.getAll.mockResolvedValue([]);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }),
).resolves.toEqual(['USA']);
expect(mocks.search.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
});
it('should return search suggestions for country (including null)', async () => {
mocks.search.getCountries.mockResolvedValue(['USA']);
mocks.partner.getAll.mockResolvedValue([]);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.COUNTRY }),
).resolves.toEqual(['USA', null]);
expect(mocks.search.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
});
it('should return search suggestions for state', async () => {
mocks.search.getStates.mockResolvedValue(['California']);
mocks.partner.getAll.mockResolvedValue([]);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.STATE }),
).resolves.toEqual(['California']);
expect(mocks.search.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
});
it('should return search suggestions for state (including null)', async () => {
mocks.search.getStates.mockResolvedValue(['California']);
mocks.partner.getAll.mockResolvedValue([]);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.STATE }),
).resolves.toEqual(['California', null]);
expect(mocks.search.getStates).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
});
it('should return search suggestions for city', async () => {
mocks.search.getCities.mockResolvedValue(['Denver']);
mocks.partner.getAll.mockResolvedValue([]);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CITY }),
).resolves.toEqual(['Denver']);
expect(mocks.search.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
});
it('should return search suggestions for city (including null)', async () => {
mocks.search.getCities.mockResolvedValue(['Denver']);
mocks.partner.getAll.mockResolvedValue([]);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CITY }),
).resolves.toEqual(['Denver', null]);
expect(mocks.search.getCities).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
});
it('should return search suggestions for camera make', async () => {
mocks.search.getCameraMakes.mockResolvedValue(['Nikon']);
mocks.partner.getAll.mockResolvedValue([]);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_MAKE }),
).resolves.toEqual(['Nikon']);
expect(mocks.search.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
});
it('should return search suggestions for camera make (including null)', async () => {
mocks.search.getCameraMakes.mockResolvedValue(['Nikon']);
mocks.partner.getAll.mockResolvedValue([]);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_MAKE }),
).resolves.toEqual(['Nikon', null]);
expect(mocks.search.getCameraMakes).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
});
it('should return search suggestions for camera model', async () => {
mocks.search.getCameraModels.mockResolvedValue(['Fujifilm X100VI']);
mocks.partner.getAll.mockResolvedValue([]);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_MODEL }),
).resolves.toEqual(['Fujifilm X100VI']);
expect(mocks.search.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
});
it('should return search suggestions for camera model (including null)', async () => {
mocks.search.getCameraModels.mockResolvedValue(['Fujifilm X100VI']);
mocks.partner.getAll.mockResolvedValue([]);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_MODEL }),
).resolves.toEqual(['Fujifilm X100VI', null]);
expect(mocks.search.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
});
it('should return search suggestions for camera lens model', async () => {
mocks.search.getCameraLensModels.mockResolvedValue(['10-24mm']);
mocks.partner.getAll.mockResolvedValue([]);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_LENS_MODEL }),
).resolves.toEqual(['10-24mm']);
expect(mocks.search.getCameraLensModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
});
it('should return search suggestions for camera lens model (including null)', async () => {
mocks.search.getCameraLensModels.mockResolvedValue(['10-24mm']);
mocks.partner.getAll.mockResolvedValue([]);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_LENS_MODEL }),
).resolves.toEqual(['10-24mm', null]);
expect(mocks.search.getCameraLensModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
});
});
describe('new shape routing', () => {
it('should route a filter request to the V3 search and a flat request to the legacy search', async () => {
const auth = AuthFactory.create();
mocks.search.searchMetadataV3.mockResolvedValue({ hasNextPage: false, items: [] });
await sut.searchMetadata(auth, { size: 250, filter: {} });
expect(mocks.search.searchMetadataV3).toHaveBeenCalled();
expect(mocks.search.searchMetadata).not.toHaveBeenCalled();
mocks.search.searchMetadata.mockResolvedValue({ hasNextPage: false, items: [] });
await sut.searchMetadata(auth, { size: 250, city: 'Oslo' });
expect(mocks.search.searchMetadata).toHaveBeenCalled();
});
it('should route statistics, random, and smart filter requests to their V3 search', async () => {
const auth = AuthFactory.create();
mocks.search.searchStatisticsV3.mockResolvedValue({ total: 0 });
await expect(sut.searchStatistics(auth, { filter: {} })).resolves.toEqual({ total: 0 });
mocks.search.searchRandomV3.mockResolvedValue([]);
await expect(sut.searchRandom(auth, { size: 250, filter: {} })).resolves.toEqual([]);
mocks.search.searchSmartV3.mockResolvedValue({ hasNextPage: false, items: [] });
mocks.machineLearning.encodeText.mockResolvedValue('[1, 2, 3]');
await sut.searchSmart(auth, { size: 100, filter: {}, query: 'test' });
expect(mocks.search.searchSmartV3).toHaveBeenCalledWith(
{ size: 100, offset: 0 },
expect.objectContaining({ embedding: '[1, 2, 3]' }),
);
});
it('should reject an invalid cursor', async () => {
await expect(sut.searchMetadata(AuthFactory.create(), { size: 250, cursor: '???' })).rejects.toThrowError(
new BadRequestException('Invalid cursor'),
);
});
it('should reject an unelevated session whose filter could match locked assets', async () => {
const filter = { visibility: { in: [AssetVisibility.Locked, AssetVisibility.Timeline] } };
await expect(sut.searchMetadata(AuthFactory.create(), { size: 250, filter })).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('should reject a shared link without a top-level album constraint', async () => {
const auth = AuthFactory.from().sharedLink().build();
await expect(sut.searchMetadata(auth, { size: 250, filter: {} })).rejects.toThrowError(
new BadRequestException('Shared link access is only allowed in combination with an albumIds filter'),
);
const albumId = newUuid();
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([albumId]));
await expect(
sut.searchMetadata(auth, { size: 250, filter: { or: [{ albumIds: { any: [albumId] } }] } }),
).rejects.toThrowError(
new BadRequestException('Shared link access is only allowed in combination with an albumIds filter'),
);
});
});
describe('searchSmart', () => {
beforeEach(() => {
mocks.search.searchSmart.mockResolvedValue({ hasNextPage: false, items: [] });
mocks.machineLearning.encodeText.mockResolvedValue('[1, 2, 3]');
});
it('should raise a BadRequestException if machine learning is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({
machineLearning: { enabled: false },
});
await expect(sut.searchSmart(authStub.user1, { size: 100, query: 'test' })).rejects.toThrowError(
new BadRequestException('Smart search is not enabled'),
);
});
it('should raise a BadRequestException if smart search is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({
machineLearning: { clip: { enabled: false } },
});
await expect(sut.searchSmart(authStub.user1, { size: 100, query: 'test' })).rejects.toThrowError(
new BadRequestException('Smart search is not enabled'),
);
});
it('should work', async () => {
await sut.searchSmart(authStub.user1, { size: 100, query: 'test' });
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
'test',
expect.objectContaining({ modelName: expect.any(String) }),
);
expect(mocks.search.searchSmart).toHaveBeenCalledWith(
{ page: 1, size: 100 },
{
query: 'test',
size: 100,
embedding: '[1, 2, 3]',
userIds: [authStub.user1.user.id],
visibility: 'not-locked',
},
);
});
it('should consider page and size parameters', async () => {
await sut.searchSmart(authStub.user1, { query: 'test', page: 2, size: 50 });
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
'test',
expect.objectContaining({ modelName: expect.any(String) }),
);
expect(mocks.search.searchSmart).toHaveBeenCalledWith(
{ page: 2, size: 50 },
expect.objectContaining({ query: 'test', embedding: '[1, 2, 3]', userIds: [authStub.user1.user.id] }),
);
});
it('should use clip model specified in config', async () => {
mocks.systemMetadata.get.mockResolvedValue({
machineLearning: { clip: { modelName: 'ViT-B-16-SigLIP__webli' } },
});
await sut.searchSmart(authStub.user1, { size: 100, query: 'test' });
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
'test',
expect.objectContaining({ modelName: 'ViT-B-16-SigLIP__webli' }),
);
});
it('should use language specified in request', async () => {
await sut.searchSmart(authStub.user1, { size: 100, query: 'test', language: 'de' });
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
'test',
expect.objectContaining({ language: 'de' }),
);
});
});
});