From fe0f4e21407d79649e6645542404c1fd15ef8d0a Mon Sep 17 00:00:00 2001 From: timonrieger Date: Fri, 24 Jul 2026 00:20:06 +0200 Subject: [PATCH] tests --- .../src/controllers/search.controller.spec.ts | 10 ++ server/src/services/search.service.spec.ts | 64 ++++++- .../specs/services/search.service.spec.ts | 162 +++++++++++++++++- 3 files changed, 234 insertions(+), 2 deletions(-) diff --git a/server/src/controllers/search.controller.spec.ts b/server/src/controllers/search.controller.spec.ts index a1fed4c7ae..d8a1f319d8 100644 --- a/server/src/controllers/search.controller.spec.ts +++ b/server/src/controllers/search.controller.spec.ts @@ -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'); diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index c0f5a3614d..2ccdc1b80b 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -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: [] }); diff --git a/server/test/medium/specs/services/search.service.spec.ts b/server/test/medium/specs/services/search.service.spec.ts index 044cd8d4f5..d21294d906 100644 --- a/server/test/medium/specs/services/search.service.spec.ts +++ b/server/test/medium/specs/services/search.service.spec.ts @@ -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; +const unitVector = (index: number) => JSON.stringify(Array.from({ length: 512 }, (_, i) => (i === index ? 1 : 0))); + const setup = (db?: Kysely) => { 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); + }); + }); });