mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-09-01 05:08:38 +00:00
Fix search result limiting after ranking (#277)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user