This commit is contained in:
timonrieger 2026-07-24 00:20:06 +02:00
parent cd07b596e6
commit fe0f4e2140
No known key found for this signature in database
3 changed files with 234 additions and 2 deletions

View file

@ -120,6 +120,16 @@ describe(SearchController.name, () => {
);
});
it('should reject a deprecated field combined with a new structure field', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/search/metadata')
.send({ filter: {}, city: 'Oslo' });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['city'], message: 'Deprecated field city cannot be combined with filter' }]),
);
});
describe('POST /search/random', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/search/random');

View file

@ -1,11 +1,13 @@
import { BadRequestException } from '@nestjs/common';
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';
@ -215,6 +217,66 @@ describe(SearchService.name, () => {
});
});
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, { filter: {} });
expect(mocks.search.searchMetadataV3).toHaveBeenCalled();
expect(mocks.search.searchMetadata).not.toHaveBeenCalled();
mocks.search.searchMetadata.mockResolvedValue({ hasNextPage: false, items: [] });
await sut.searchMetadata(auth, { 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, { filter: {} })).resolves.toEqual([]);
mocks.search.searchSmartV3.mockResolvedValue({ hasNextPage: false, items: [] });
mocks.machineLearning.encodeText.mockResolvedValue('[1, 2, 3]');
await sut.searchSmart(auth, { 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(), { 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(), { 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, { 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, { 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: [] });

View file

@ -1,6 +1,6 @@
import { Kysely } from 'kysely';
import { SearchSuggestionType } from 'src/dtos/search.dto';
import { AlbumUserRole, AssetVisibility } from 'src/enum';
import { AlbumUserRole, AssetOrder, AssetVisibility, SearchOrderField } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
@ -16,6 +16,8 @@ import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const unitVector = (index: number) => JSON.stringify(Array.from({ length: 512 }, (_, i) => (i === index ? 1 : 0)));
const setup = (db?: Kysely<DB>) => {
return newMediumService(SearchService, {
database: db || defaultDatabase,
@ -227,4 +229,162 @@ describe(SearchService.name, () => {
expect(response.length).toBe(0);
});
});
describe('new search shape', () => {
it('should filter by an exif field and return a cursor-less single page', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, city: 'Oslo' });
const { asset: other } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: other.id, city: 'Bergen' });
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, { filter: { city: { eq: 'Oslo' } } });
expect(response.assets.items).toEqual([expect.objectContaining({ id: asset.id })]);
expect(response.assets.nextPage).toBeNull();
expect(response.assets.nextCursor).toBeNull();
});
it('should combine OR branches', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset: oslo } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: oslo.id, city: 'Oslo' });
const { asset: favorite } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });
await ctx.newAsset({ ownerId: user.id });
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, {
filter: { or: [{ city: { eq: 'Oslo' } }, { isFavorite: { eq: true } }] },
});
expect(response.assets.items.map(({ id }) => id).toSorted()).toEqual([oslo.id, favorite.id].toSorted());
});
it('should widen to album assets only for a top-level album constraint', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: otherUser.id });
const { album } = await ctx.newAlbum({ ownerId: user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
const auth = factory.auth({ user: { id: user.id } });
const topLevel = await sut.searchMetadata(auth, { filter: { albumIds: { any: [album.id] } } });
expect(topLevel.assets.items).toEqual([expect.objectContaining({ id: asset.id })]);
const branchOnly = await sut.searchMetadata(auth, { filter: { or: [{ albumIds: { any: [album.id] } }] } });
expect(branchOnly.assets.items).toEqual([]);
});
it('should reject an inaccessible album anywhere in the filter', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: otherUser.id });
const auth = factory.auth({ user: { id: user.id } });
await expect(sut.searchMetadata(auth, { filter: { or: [{ albumIds: { none: [album.id] } }] } })).rejects.toThrow(
'Not found or no album.read access',
);
});
it('should return locked assets only to an elevated session that asks for them', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset: timeline } = await ctx.newAsset({ ownerId: user.id });
const { asset: locked } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
const unelevated = await sut.searchMetadata(factory.auth({ user: { id: user.id } }), { filter: {} });
expect(unelevated.assets.items).toEqual([expect.objectContaining({ id: timeline.id })]);
const elevatedAuth = factory.auth({ user: { id: user.id }, session: { hasElevatedPermission: true } });
const elevated = await sut.searchMetadata(elevatedAuth, {
filter: { visibility: { eq: AssetVisibility.Locked } },
});
expect(elevated.assets.items).toEqual([expect.objectContaining({ id: locked.id })]);
});
it('should exclude partner assets from a locked-only search', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: partner } = await ctx.newUser();
await ctx.newPartner({ sharedById: partner.id, sharedWithId: user.id });
const { asset: ownLocked } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
await ctx.newAsset({ ownerId: partner.id, visibility: AssetVisibility.Locked });
const auth = factory.auth({ user: { id: user.id }, session: { hasElevatedPermission: true } });
const response = await sut.searchMetadata(auth, { filter: { visibility: { eq: AssetVisibility.Locked } } });
expect(response.assets.items).toEqual([expect.objectContaining({ id: ownLocked.id })]);
});
it('should paginate with an opaque cursor', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
for (let i = 0; i < 3; i++) {
await ctx.newAsset({ ownerId: user.id });
}
const auth = factory.auth({ user: { id: user.id } });
const firstPage = await sut.searchMetadata(auth, { filter: {}, size: 2 });
expect(firstPage.assets.items.length).toBe(2);
expect(firstPage.assets.nextPage).toBeNull();
expect(firstPage.assets.nextCursor).toEqual(expect.any(String));
const secondPage = await sut.searchMetadata(auth, { cursor: firstPage.assets.nextCursor!, size: 2 });
expect(secondPage.assets.items.length).toBe(1);
expect(secondPage.assets.nextCursor).toBeNull();
const ids = [...firstPage.assets.items, ...secondPage.assets.items].map(({ id }) => id);
expect(new Set(ids).size).toBe(3);
});
it('should order by fileSizeInBytes', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const sizes = [12_334, 599, 123_456];
const assetIds: string[] = [];
for (const fileSizeInByte of sizes) {
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, fileSizeInByte });
assetIds.push(asset.id);
}
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, {
orderBy: { field: SearchOrderField.FileSizeInBytes, direction: AssetOrder.Asc },
});
expect(response.assets.items.map(({ id }) => id)).toEqual([assetIds[1], assetIds[0], assetIds[2]]);
});
it('should order smart search results by embedding distance with cursor offsets', async () => {
const { ctx } = setup();
const { user } = await ctx.newUser();
const searchRepository = ctx.get(SearchRepository);
const assetIds: string[] = [];
for (let i = 0; i < 3; i++) {
const { asset } = await ctx.newAsset({ ownerId: user.id });
await searchRepository.upsert(asset.id, unitVector(i));
assetIds.push(asset.id);
}
const options = { filter: {}, userIds: [user.id], embedding: unitVector(0) };
const firstPage = await searchRepository.searchSmartV3({ size: 2 }, options);
expect(firstPage.items.length).toBe(2);
expect(firstPage.items[0].id).toBe(assetIds[0]);
expect(firstPage.hasNextPage).toBe(true);
const secondPage = await searchRepository.searchSmartV3({ size: 2, offset: 2 }, options);
expect(secondPage.items.length).toBe(1);
expect(secondPage.hasNextPage).toBe(false);
});
});
});