Fix search result limiting after ranking (#277)

This commit is contained in:
kikootwo
2026-08-18 17:26:27 -04:00
committed by GitHub
parent ae75c914c9
commit bc371860d0
7 changed files with 181 additions and 15 deletions
@@ -20,6 +20,10 @@ const rankTorrentsMock = vi.hoisted(() => vi.fn());
const groupIndexersMock = vi.hoisted(() => vi.fn());
const groupDescriptionMock = vi.hoisted(() => vi.fn(() => 'Group'));
vi.mock('@/lib/db', () => ({
prisma: {},
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
}));
@@ -94,6 +98,45 @@ describe('Audiobooks search torrents route', () => {
expect(payload.results[0].rank).toBe(1);
expect(rankTorrentsMock).toHaveBeenCalled();
});
it('ranks all candidates before returning the top 100 with truncation metadata', async () => {
authRequest.json.mockResolvedValue({ title: 'Title', author: 'Author' });
configServiceMock.get
.mockResolvedValueOnce(JSON.stringify([{ id: 1, name: 'Indexer', protocol: 'torrent', priority: 10 }]))
.mockResolvedValueOnce(null);
groupIndexersMock.mockReturnValue({ groups: [{ categories: [3030], indexerIds: [1] }], skippedIndexers: [] });
const candidates = Array.from({ length: 101 }, (_, index) => ({
title: index === 100 ? 'Best result' : `Result ${index}`,
size: 100,
indexer: 'Indexer',
indexerId: 1,
}));
const rankedCandidates = [candidates[100], ...candidates.slice(0, 100)].map((result, index) => ({
...result,
score: 100 - index,
breakdown: { matchScore: 50, formatScore: 0, sizeScore: 0, seederScore: 0, notes: [] },
bonusPoints: 0,
bonusModifiers: [],
finalScore: 100 - index,
}));
prowlarrMock.searchWithVariations.mockResolvedValue(candidates);
rankTorrentsMock.mockReturnValue(rankedCandidates);
const { POST } = await import('@/app/api/audiobooks/search-torrents/route');
const response = await POST({} as any);
const payload = await response.json();
expect(rankTorrentsMock.mock.calls[0][0]).toHaveLength(101);
expect(prowlarrMock.searchWithVariations).toHaveBeenCalledWith('Title', 'Author', {
categories: [3030],
indexerIds: [1],
});
expect(payload.results).toHaveLength(100);
expect(payload.results[0]).toEqual(expect.objectContaining({ title: 'Best result', rank: 1 }));
expect(payload.truncated).toBe(true);
expect(payload.rawCount).toBe(101);
});
});
+45
View File
@@ -153,6 +153,51 @@ describe('Request action routes', () => {
);
});
it('ranks the complete interactive result set before returning the top 100', async () => {
authRequest.json.mockResolvedValue({});
prismaMock.request.findUnique.mockResolvedValueOnce({
id: 'req-complete-ranking',
userId: 'user-1',
audiobook: { title: 'Title', author: 'Author', audibleAsin: null },
});
prismaMock.user.findUnique.mockResolvedValueOnce({
role: 'user',
interactiveSearchAccess: null,
});
configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, priority: 10, categories: [3030] }]));
configServiceMock.get.mockResolvedValueOnce(null);
groupIndexersMock.mockReturnValue({ groups: [{ categories: [3030], indexerIds: [1] }], skippedIndexers: [] });
const candidates = Array.from({ length: 101 }, (_, index) => ({
title: index === 100 ? 'Best result' : `Result ${index}`,
size: 100,
}));
const rankedCandidates = [candidates[100], ...candidates.slice(0, 100)].map((result, index) => ({
...result,
score: 100 - index,
breakdown: { matchScore: 50, formatScore: 0, sizeScore: 0, seederScore: 0, notes: [] },
bonusPoints: 0,
bonusModifiers: [],
finalScore: 100 - index,
}));
prowlarrMock.searchWithVariations.mockResolvedValueOnce(candidates);
rankTorrentsMock.mockReturnValueOnce(rankedCandidates);
const { POST } = await import('@/app/api/requests/[id]/interactive-search/route');
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-complete-ranking' }) });
const payload = await response.json();
expect(rankTorrentsMock.mock.calls[0][0]).toHaveLength(101);
expect(prowlarrMock.searchWithVariations).toHaveBeenCalledWith('Title', 'Author', {
categories: [3030],
indexerIds: [1],
});
expect(payload.results).toHaveLength(100);
expect(payload.results[0]).toEqual(expect.objectContaining({ title: 'Best result', rank: 1 }));
expect(payload.truncated).toBe(true);
expect(payload.rawCount).toBe(101);
});
it('performs interactive search gracefully when runtime fetch fails', async () => {
authRequest.json.mockResolvedValue({});
prismaMock.request.findUnique.mockResolvedValueOnce({
@@ -117,6 +117,52 @@ describe('processSearchIndexers', () => {
expect.objectContaining({ title: 'Book - Author' }),
[expect.objectContaining({ title: 'Book - Author - Alternate' })]
);
expect(prowlarrMock.searchWithVariations).toHaveBeenCalledWith('Book', 'Author', {
categories: [3030],
indexerIds: [1],
minSeeders: 1,
});
});
it('ranks all candidates before limiting automatic search to 100 results', async () => {
configMock.get.mockImplementation(async (key: string) => {
if (key === 'prowlarr_indexers') {
return JSON.stringify([{ id: 1, name: 'Indexer', protocol: 'torrent', priority: 10, categories: [3030] }]);
}
if (key === 'indexer_flag_config') return JSON.stringify([]);
return null;
});
const candidates = Array.from({ length: 101 }, (_, index) => ({
indexer: 'Indexer',
indexerId: 1,
title: 'Book - Author',
size: 50 * 1024 * 1024,
seeders: index === 100 ? 500 : 1,
publishDate: new Date(),
downloadUrl: `magnet:?xt=urn:btih:${index}`,
guid: `guid-${index}`,
format: 'M4B',
}));
prowlarrMock.searchWithVariations.mockResolvedValue(candidates);
prismaMock.request.update.mockResolvedValue({});
const { processSearchIndexers } = await import('@/lib/processors/search-indexers.processor');
const result = await processSearchIndexers({
requestId: 'req-rank-before-limit',
audiobook: { id: 'a-rank-before-limit', title: 'Book', author: 'Author' },
jobId: 'job-rank-before-limit',
});
expect(result.success).toBe(true);
expect(jobQueueMock.addDownloadJob).toHaveBeenCalledWith(
'req-rank-before-limit',
expect.anything(),
expect.objectContaining({ guid: 'guid-100' }),
expect.any(Array)
);
const fallbackResults = jobQueueMock.addDownloadJob.mock.calls[0][3];
expect(fallbackResults).toHaveLength(99);
});
it('fails when no indexers are configured', async () => {