mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Add release blocklist feature
Introduce a per-request release blocklist to auto-block permanently failing releases and provide admin management. Changes include: - Database: add BlockedRelease model (blocked_releases) to Prisma schema with unique (requestId, releaseKey) and indexes; documented in backend database docs. - Service & utils: new blocklist.service, release-key and filter helpers for normalization and matching; processors updated to emit auto-blocks (monitor-download, organize-files, search processors, RSS). - HTTP API: add admin endpoints GET/DELETE /api/admin/blocklist, DELETE /api/admin/blocklist/[id], and GET /api/admin/blocklist/by-request/[requestId]. - Admin UI: new /admin/blocklist page and numerous React components (toolbar, filters, table, rows, pagination, skeleton, chips, date picker) with URL-driven state hook and per-row unblock UX. - Tests: add unit/integration tests for service, routes, utils, and updated processor tests. The blocklist is idempotent (upsert), filters search results before ranking (interactive search shows badges only), and admin-only APIs require auth. This commit wires docs, API, DB, frontend and tests for the new feature.
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Component: Admin Blocklist API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const clearBlocklistMock = vi.hoisted(() => vi.fn());
|
||||
const removeBlockMock = vi.hoisted(() => vi.fn());
|
||||
const getBlocklistForRequestMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/blocklist.service', () => ({
|
||||
clearBlocklist: clearBlocklistMock,
|
||||
removeBlock: removeBlockMock,
|
||||
getBlocklistForRequest: getBlocklistForRequestMock,
|
||||
}));
|
||||
|
||||
async function callList(query: string = '') {
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.blockedRelease.count.mockResolvedValueOnce(0);
|
||||
const { GET } = await import('@/app/api/admin/blocklist/route');
|
||||
const url = `http://localhost/api/admin/blocklist${query ? `?${query}` : ''}`;
|
||||
const response = await GET({ url } as any);
|
||||
const payload = await response.json();
|
||||
const findManyArgs = prismaMock.blockedRelease.findMany.mock.calls[0][0];
|
||||
const countArgs = prismaMock.blockedRelease.count.mock.calls[0][0];
|
||||
return { response, payload, findManyArgs, countArgs };
|
||||
}
|
||||
|
||||
async function callBulkDelete(query: string = '') {
|
||||
clearBlocklistMock.mockResolvedValueOnce({ count: 0 });
|
||||
const { DELETE } = await import('@/app/api/admin/blocklist/route');
|
||||
const url = `http://localhost/api/admin/blocklist${query ? `?${query}` : ''}`;
|
||||
const response = await DELETE({ url } as any);
|
||||
const payload = await response.json();
|
||||
const where = clearBlocklistMock.mock.calls[0]?.[0];
|
||||
return { response, payload, where };
|
||||
}
|
||||
|
||||
describe('Admin blocklist list route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
});
|
||||
|
||||
it('returns paginated entries', async () => {
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValueOnce([{ id: 'b1' }]);
|
||||
prismaMock.blockedRelease.count.mockResolvedValueOnce(1);
|
||||
|
||||
const { GET } = await import('@/app/api/admin/blocklist/route');
|
||||
const response = await GET({ url: 'http://localhost/api/admin/blocklist?page=1&limit=25' } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.entries).toHaveLength(1);
|
||||
expect(payload.pagination.total).toBe(1);
|
||||
});
|
||||
|
||||
describe('where composition', () => {
|
||||
it('builds empty where when no filters provided', async () => {
|
||||
const { findManyArgs } = await callList();
|
||||
expect(findManyArgs.where).toEqual({});
|
||||
});
|
||||
|
||||
it('applies requestId filter', async () => {
|
||||
const { findManyArgs } = await callList('requestId=req-123');
|
||||
expect(findManyArgs.where).toEqual({ requestId: 'req-123' });
|
||||
});
|
||||
|
||||
it('applies source filter when valid', async () => {
|
||||
const { findManyArgs } = await callList('source=organize_fail');
|
||||
expect(findManyArgs.where).toEqual({ source: 'organize_fail' });
|
||||
});
|
||||
|
||||
it('drops source filter when invalid', async () => {
|
||||
const { findManyArgs } = await callList('source=bogus');
|
||||
expect(findManyArgs.where).toEqual({});
|
||||
});
|
||||
|
||||
it('drops source filter when "all"', async () => {
|
||||
const { findManyArgs } = await callList('source=all');
|
||||
expect(findManyArgs.where).toEqual({});
|
||||
});
|
||||
|
||||
it('applies dateFrom and dateTo as createdAt range', async () => {
|
||||
const { findManyArgs } = await callList(
|
||||
'dateFrom=2026-01-01T00:00:00.000Z&dateTo=2026-02-01T00:00:00.000Z'
|
||||
);
|
||||
expect(findManyArgs.where.createdAt).toEqual({
|
||||
gte: new Date('2026-01-01T00:00:00.000Z'),
|
||||
lte: new Date('2026-02-01T00:00:00.000Z'),
|
||||
});
|
||||
});
|
||||
|
||||
it('silently drops invalid date strings', async () => {
|
||||
const { findManyArgs } = await callList('dateFrom=not-a-date&dateTo=also-not-a-date');
|
||||
expect(findManyArgs.where.createdAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies search as case-insensitive OR over releaseName + reason', async () => {
|
||||
const { findManyArgs } = await callList('search=epub');
|
||||
expect(findManyArgs.where.OR).toEqual([
|
||||
{ releaseName: { contains: 'epub', mode: 'insensitive' } },
|
||||
{ reason: { contains: 'epub', mode: 'insensitive' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats whitespace-only search as no search', async () => {
|
||||
const { findManyArgs } = await callList('search=%20%20%20');
|
||||
expect(findManyArgs.where.OR).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats whitespace-only requestId as no filter', async () => {
|
||||
const { findManyArgs } = await callList('requestId=%20');
|
||||
expect(findManyArgs.where.requestId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('combines all filters together', async () => {
|
||||
const { findManyArgs } = await callList(
|
||||
'requestId=r-1&source=download_fail&dateFrom=2026-01-01T00:00:00.000Z&dateTo=2026-02-01T00:00:00.000Z&search=par2'
|
||||
);
|
||||
const where = findManyArgs.where;
|
||||
expect(where.requestId).toBe('r-1');
|
||||
expect(where.source).toBe('download_fail');
|
||||
expect(where.createdAt.gte).toEqual(new Date('2026-01-01T00:00:00.000Z'));
|
||||
expect(where.createdAt.lte).toEqual(new Date('2026-02-01T00:00:00.000Z'));
|
||||
expect(where.OR).toEqual([
|
||||
{ releaseName: { contains: 'par2', mode: 'insensitive' } },
|
||||
{ reason: { contains: 'par2', mode: 'insensitive' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses identical where for findMany and count', async () => {
|
||||
const { findManyArgs, countArgs } = await callList('source=download_fail&search=par2');
|
||||
expect(countArgs.where).toEqual(findManyArgs.where);
|
||||
});
|
||||
});
|
||||
|
||||
describe('limit clamp', () => {
|
||||
const cases: Array<[string | null, number]> = [
|
||||
['25', 25],
|
||||
['50', 50],
|
||||
['100', 100],
|
||||
['24', 50],
|
||||
['75', 50],
|
||||
['101', 50],
|
||||
['abc', 50],
|
||||
[null, 50],
|
||||
];
|
||||
|
||||
for (const [raw, expected] of cases) {
|
||||
it(`limit=${raw} → take ${expected}`, async () => {
|
||||
const query = raw === null ? '' : `limit=${raw}`;
|
||||
const { findManyArgs, payload } = await callList(query);
|
||||
expect(findManyArgs.take).toBe(expected);
|
||||
expect(payload.pagination.limit).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('sort', () => {
|
||||
it('defaults to createdAt desc', async () => {
|
||||
const { findManyArgs } = await callList();
|
||||
expect(findManyArgs.orderBy).toEqual({ createdAt: 'desc' });
|
||||
});
|
||||
|
||||
it('applies sortBy=releaseName sortOrder=asc', async () => {
|
||||
const { findManyArgs } = await callList('sortBy=releaseName&sortOrder=asc');
|
||||
expect(findManyArgs.orderBy).toEqual({ releaseName: 'asc' });
|
||||
});
|
||||
|
||||
it('falls back to createdAt for unknown sortBy', async () => {
|
||||
const { findManyArgs } = await callList('sortBy=bogus');
|
||||
expect(findManyArgs.orderBy).toEqual({ createdAt: 'desc' });
|
||||
});
|
||||
|
||||
it('falls back to desc for unknown sortOrder', async () => {
|
||||
const { findManyArgs } = await callList('sortBy=reason&sortOrder=sideways');
|
||||
expect(findManyArgs.orderBy).toEqual({ reason: 'desc' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination math', () => {
|
||||
it('page=2 with limit=50 and total=75 returns totalPages=2 and skip=50', async () => {
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.blockedRelease.count.mockResolvedValueOnce(75);
|
||||
const { GET } = await import('@/app/api/admin/blocklist/route');
|
||||
const response = await GET({
|
||||
url: 'http://localhost/api/admin/blocklist?page=2&limit=50',
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
const findManyArgs = prismaMock.blockedRelease.findMany.mock.calls[0][0];
|
||||
|
||||
expect(findManyArgs.skip).toBe(50);
|
||||
expect(findManyArgs.take).toBe(50);
|
||||
expect(payload.pagination.page).toBe(2);
|
||||
expect(payload.pagination.limit).toBe(50);
|
||||
expect(payload.pagination.total).toBe(75);
|
||||
expect(payload.pagination.totalPages).toBe(2);
|
||||
});
|
||||
|
||||
it('coerces invalid page to 1', async () => {
|
||||
const { findManyArgs, payload } = await callList('page=-3');
|
||||
expect(findManyArgs.skip).toBe(0);
|
||||
expect(payload.pagination.page).toBe(1);
|
||||
});
|
||||
|
||||
it('totalPages is at least 1 when total is 0', async () => {
|
||||
const { payload } = await callList();
|
||||
expect(payload.pagination.totalPages).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin blocklist bulk-clear DELETE', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
});
|
||||
|
||||
it('returns count from clearBlocklist', async () => {
|
||||
clearBlocklistMock.mockResolvedValueOnce({ count: 7 });
|
||||
const { DELETE } = await import('@/app/api/admin/blocklist/route');
|
||||
const response = await DELETE({ url: 'http://localhost/api/admin/blocklist' } as any);
|
||||
const payload = await response.json();
|
||||
expect(payload).toEqual({ count: 7 });
|
||||
});
|
||||
|
||||
it('passes filter-scoped where to clearBlocklist', async () => {
|
||||
const { where } = await callBulkDelete('source=organize_fail&requestId=r-1');
|
||||
expect(where).toEqual({ requestId: 'r-1', source: 'organize_fail' });
|
||||
});
|
||||
|
||||
it('passes empty where when no filters given (admin UI gates with typed token)', async () => {
|
||||
const { where } = await callBulkDelete();
|
||||
expect(where).toEqual({});
|
||||
});
|
||||
|
||||
it('returns 500 when clearBlocklist throws', async () => {
|
||||
clearBlocklistMock.mockRejectedValueOnce(new Error('db down'));
|
||||
const { DELETE } = await import('@/app/api/admin/blocklist/route');
|
||||
const response = await DELETE({ url: 'http://localhost/api/admin/blocklist' } as any);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin blocklist single-unblock DELETE', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
});
|
||||
|
||||
it('calls removeBlock with the route param id', async () => {
|
||||
removeBlockMock.mockResolvedValueOnce(undefined);
|
||||
const { DELETE } = await import('@/app/api/admin/blocklist/[id]/route');
|
||||
const response = await DELETE(
|
||||
{ url: 'http://localhost/api/admin/blocklist/abc-123' } as any,
|
||||
{ params: Promise.resolve({ id: 'abc-123' }) }
|
||||
);
|
||||
expect(removeBlockMock).toHaveBeenCalledWith('abc-123');
|
||||
const payload = await response.json();
|
||||
expect(payload).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('rejects whitespace-only id with 400', async () => {
|
||||
const { DELETE } = await import('@/app/api/admin/blocklist/[id]/route');
|
||||
const response = await DELETE(
|
||||
{ url: 'http://localhost/api/admin/blocklist/' } as any,
|
||||
{ params: Promise.resolve({ id: ' ' }) }
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(removeBlockMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps Prisma P2025 to 404 NotFound', async () => {
|
||||
const { Prisma } = await import('@/generated/prisma');
|
||||
const notFound = new Prisma.PrismaClientKnownRequestError('not found', {
|
||||
code: 'P2025',
|
||||
clientVersion: 'test',
|
||||
});
|
||||
removeBlockMock.mockRejectedValueOnce(notFound);
|
||||
const { DELETE } = await import('@/app/api/admin/blocklist/[id]/route');
|
||||
const response = await DELETE(
|
||||
{ url: 'http://localhost/api/admin/blocklist/missing' } as any,
|
||||
{ params: Promise.resolve({ id: 'missing' }) }
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('maps unknown errors to 500', async () => {
|
||||
removeBlockMock.mockRejectedValueOnce(new Error('boom'));
|
||||
const { DELETE } = await import('@/app/api/admin/blocklist/[id]/route');
|
||||
const response = await DELETE(
|
||||
{ url: 'http://localhost/api/admin/blocklist/some-id' } as any,
|
||||
{ params: Promise.resolve({ id: 'some-id' }) }
|
||||
);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin blocklist by-request GET', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
});
|
||||
|
||||
it('returns entries + count for the request', async () => {
|
||||
const rows = [
|
||||
{ id: 'b1', releaseName: 'Foo' },
|
||||
{ id: 'b2', releaseName: 'Bar' },
|
||||
];
|
||||
getBlocklistForRequestMock.mockResolvedValueOnce(rows);
|
||||
const { GET } = await import('@/app/api/admin/blocklist/by-request/[requestId]/route');
|
||||
const response = await GET(
|
||||
{ url: 'http://localhost/api/admin/blocklist/by-request/r-1' } as any,
|
||||
{ params: Promise.resolve({ requestId: 'r-1' }) }
|
||||
);
|
||||
const payload = await response.json();
|
||||
expect(getBlocklistForRequestMock).toHaveBeenCalledWith('r-1');
|
||||
expect(payload).toEqual({ entries: rows, count: 2 });
|
||||
});
|
||||
|
||||
it('rejects whitespace-only requestId with 400', async () => {
|
||||
const { GET } = await import('@/app/api/admin/blocklist/by-request/[requestId]/route');
|
||||
const response = await GET(
|
||||
{ url: 'http://localhost/api/admin/blocklist/by-request/' } as any,
|
||||
{ params: Promise.resolve({ requestId: ' ' }) }
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(getBlocklistForRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,7 @@ export const createPrismaMock = () => ({
|
||||
userHomeSection: createModelMock(),
|
||||
audibleCacheCategory: createModelMock(),
|
||||
ignoredAudiobook: createModelMock(),
|
||||
blockedRelease: createModelMock(),
|
||||
$queryRaw: vi.fn(),
|
||||
$transaction: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
|
||||
@@ -156,7 +156,7 @@ describe('processMonitorDownload', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('marks request failed when download fails', async () => {
|
||||
it('marks request failed when download fails and auto-blocks the release', async () => {
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
@@ -180,6 +180,20 @@ describe('processMonitorDownload', () => {
|
||||
audiobook: { title: 'Book', author: 'Author' },
|
||||
user: { plexUsername: 'user' },
|
||||
});
|
||||
prismaMock.downloadHistory.findUnique.mockResolvedValue({
|
||||
id: 'dh-3',
|
||||
torrentName: 'Book - Author [M4B]',
|
||||
torrentHash: 'hash-3',
|
||||
nzbId: null,
|
||||
indexerName: 'TestIndexer',
|
||||
indexerId: 4,
|
||||
});
|
||||
prismaMock.blockedRelease.upsert.mockResolvedValue({
|
||||
id: 'block-3',
|
||||
releaseName: 'Book - Author [M4B]',
|
||||
releaseKey: 'book - author [m4b]',
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
|
||||
const result = await processMonitorDownload({
|
||||
@@ -196,6 +210,60 @@ describe('processMonitorDownload', () => {
|
||||
data: expect.objectContaining({ status: 'failed' }),
|
||||
})
|
||||
);
|
||||
expect(prismaMock.blockedRelease.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { requestId_releaseKey: { requestId: 'req-3', releaseKey: 'book - author [m4b]' } },
|
||||
create: expect.objectContaining({
|
||||
requestId: 'req-3',
|
||||
releaseName: 'Book - Author [M4B]',
|
||||
releaseHash: 'hash-3',
|
||||
source: 'download_fail',
|
||||
downloadHistoryId: 'dh-3',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not auto-block when permanent failure is from connection-exhaustion path', async () => {
|
||||
// Simulate the connection-failure-exhausted fallthrough: getDownload rejects
|
||||
// with a transient connection error AND prevConnectionFailureCount is already
|
||||
// at the cap, so the processor enters PATH 3 (permanent error) without the
|
||||
// client ever reporting `failed`. That path must NOT auto-block.
|
||||
const econn = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:8080'), {
|
||||
code: 'ECONNREFUSED',
|
||||
});
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
getDownload: vi.fn().mockRejectedValue(econn),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.request.findUnique.mockResolvedValue({
|
||||
id: 'req-conn',
|
||||
audiobook: { title: 'Book', author: 'Author' },
|
||||
user: { plexUsername: 'user' },
|
||||
});
|
||||
|
||||
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
|
||||
await expect(processMonitorDownload({
|
||||
requestId: 'req-conn',
|
||||
downloadHistoryId: 'dh-conn',
|
||||
downloadClientId: 'hash-conn',
|
||||
downloadClient: 'qbittorrent',
|
||||
jobId: 'job-conn',
|
||||
// Already at the cap — next call enters PATH 3 (permanent), not the
|
||||
// self-rescheduling retry branch.
|
||||
connectionFailureCount: 30,
|
||||
})).rejects.toThrow();
|
||||
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'failed' }),
|
||||
})
|
||||
);
|
||||
// CRITICAL: connection exhaustion is transient infra, not a release problem.
|
||||
expect(prismaMock.blockedRelease.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles SABnzbd completion and queues organize job', async () => {
|
||||
|
||||
@@ -35,6 +35,8 @@ function futureDate(days = 30): Date {
|
||||
describe('processMonitorRssFeeds', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default to empty blocklist so the filter is a no-op unless a test overrides.
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('matches RSS items and queues search jobs', async () => {
|
||||
@@ -108,6 +110,42 @@ describe('processMonitorRssFeeds', () => {
|
||||
expect(prismaMock.request.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not queue a search when the matching RSS release is on the request blocklist', async () => {
|
||||
configMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'prowlarr_indexers') {
|
||||
return JSON.stringify([{ id: 1, name: 'Indexer', rssEnabled: true }]);
|
||||
}
|
||||
if (key === 'indexer.skip_unreleased') return null;
|
||||
return null;
|
||||
});
|
||||
|
||||
prowlarrMock.getAllRssFeeds.mockResolvedValue([
|
||||
{ title: 'Great Book - Author Name' },
|
||||
]);
|
||||
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-blocked',
|
||||
type: 'audiobook',
|
||||
status: 'awaiting_search',
|
||||
releaseDate: null,
|
||||
audiobook: { id: 'a1', title: 'Great Book', author: 'Author Name', audibleAsin: 'ASIN1' },
|
||||
},
|
||||
]);
|
||||
|
||||
// The RSS torrent's normalized name is on the request's blocklist.
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValue([
|
||||
{ id: 'b1', releaseKey: 'great book - author name', releaseHash: null },
|
||||
]);
|
||||
|
||||
const { processMonitorRssFeeds } = await import('@/lib/processors/monitor-rss-feeds.processor');
|
||||
const result = await processMonitorRssFeeds({ jobId: 'job-rss-blocked' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
|
||||
expect(jobQueueMock.addSearchEbookJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs RSS search when matched book is unreleased but setting is OFF', async () => {
|
||||
configMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'prowlarr_indexers') {
|
||||
|
||||
@@ -256,9 +256,11 @@ describe('processOrganizeFiles', () => {
|
||||
data: expect.objectContaining({ status: 'awaiting_import' }),
|
||||
})
|
||||
);
|
||||
// Auto-block must NOT fire on a retry — only on the terminal warn transition.
|
||||
expect(prismaMock.blockedRelease.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks request as warn when max retries exceeded and notifies user', async () => {
|
||||
it('marks request as warn when max retries exceeded, auto-blocks the release, and notifies user', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.audiobook.findUnique.mockResolvedValue({
|
||||
id: 'a6',
|
||||
@@ -285,6 +287,20 @@ describe('processOrganizeFiles', () => {
|
||||
audiobook: { title: 'Book', author: 'Author' },
|
||||
user: { plexUsername: 'user' },
|
||||
});
|
||||
prismaMock.downloadHistory.findFirst.mockResolvedValue({
|
||||
id: 'dh-6',
|
||||
torrentName: 'Book by Author [M4B]',
|
||||
torrentHash: 'hash-6',
|
||||
nzbId: null,
|
||||
indexerName: 'TestIndexer',
|
||||
indexerId: 7,
|
||||
});
|
||||
prismaMock.blockedRelease.upsert.mockResolvedValue({
|
||||
id: 'block-6',
|
||||
releaseName: 'Book by Author [M4B]',
|
||||
releaseKey: 'book by author [m4b]',
|
||||
createdAt: new Date(),
|
||||
});
|
||||
configMock.get.mockResolvedValue(null);
|
||||
|
||||
const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor');
|
||||
@@ -309,6 +325,23 @@ describe('processOrganizeFiles', () => {
|
||||
'user',
|
||||
expect.stringContaining('Max retries')
|
||||
);
|
||||
// Terminal warn writes a single blocklist row keyed on the selected download.
|
||||
expect(prismaMock.blockedRelease.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { requestId_releaseKey: { requestId: 'req-6', releaseKey: 'book by author [m4b]' } },
|
||||
create: expect.objectContaining({
|
||||
requestId: 'req-6',
|
||||
releaseName: 'Book by Author [M4B]',
|
||||
releaseKey: 'book by author [m4b]',
|
||||
releaseHash: 'hash-6',
|
||||
indexerName: 'TestIndexer',
|
||||
indexerId: 7,
|
||||
source: 'organize_fail',
|
||||
reason: 'No audiobook files found',
|
||||
downloadHistoryId: 'dh-6',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('marks request failed for non-retryable errors and notifies user', async () => {
|
||||
@@ -357,6 +390,8 @@ describe('processOrganizeFiles', () => {
|
||||
'user',
|
||||
expect.stringContaining('File organization failed')
|
||||
);
|
||||
// Non-retryable failures do not auto-block — only terminal warn does.
|
||||
expect(prismaMock.blockedRelease.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queues retry when organizer returns EPERM copy failure', async () => {
|
||||
|
||||
@@ -36,6 +36,8 @@ describe('processSearchIndexers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
configMock.getAudibleRegion.mockResolvedValue('us');
|
||||
// Default to empty blocklist so the filter is a no-op unless a test overrides.
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('marks request awaiting_search when no results found', async () => {
|
||||
@@ -124,6 +126,178 @@ describe('processSearchIndexers', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('filters out blocklisted releases by name (case-insensitive) before ranking', 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;
|
||||
});
|
||||
|
||||
prowlarrMock.searchWithVariations.mockResolvedValue([
|
||||
{
|
||||
indexer: 'Indexer',
|
||||
indexerId: 1,
|
||||
title: 'BAD Release - Author',
|
||||
size: 50 * 1024 * 1024,
|
||||
seeders: 10,
|
||||
publishDate: new Date(),
|
||||
downloadUrl: 'magnet:?xt=urn:btih:bad',
|
||||
guid: 'guid-bad',
|
||||
format: 'M4B',
|
||||
},
|
||||
{
|
||||
indexer: 'Indexer',
|
||||
indexerId: 1,
|
||||
title: 'Good Release - Author',
|
||||
size: 50 * 1024 * 1024,
|
||||
seeders: 20,
|
||||
publishDate: new Date(),
|
||||
downloadUrl: 'magnet:?xt=urn:btih:good',
|
||||
guid: 'guid-good',
|
||||
format: 'M4B',
|
||||
},
|
||||
]);
|
||||
|
||||
// Blocklist contains the bad release with lowercased key — must match case-insensitively.
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValue([
|
||||
{ id: 'b1', releaseKey: 'bad release - author', releaseHash: null },
|
||||
]);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processSearchIndexers } = await import('@/lib/processors/search-indexers.processor');
|
||||
const result = await processSearchIndexers({
|
||||
requestId: 'req-filter-name',
|
||||
audiobook: { id: 'a-filter', title: 'Good Release', author: 'Author' },
|
||||
jobId: 'job-filter-name',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(jobQueueMock.addDownloadJob).toHaveBeenCalledTimes(1);
|
||||
expect(jobQueueMock.addDownloadJob).toHaveBeenCalledWith(
|
||||
'req-filter-name',
|
||||
expect.objectContaining({ id: 'a-filter' }),
|
||||
expect.objectContaining({ title: 'Good Release - Author' })
|
||||
);
|
||||
});
|
||||
|
||||
it('filters out blocklisted releases by infoHash even when title differs', 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;
|
||||
});
|
||||
|
||||
prowlarrMock.searchWithVariations.mockResolvedValue([
|
||||
{
|
||||
indexer: 'Indexer',
|
||||
indexerId: 1,
|
||||
title: 'Some Other Title - Author',
|
||||
size: 50 * 1024 * 1024,
|
||||
seeders: 10,
|
||||
publishDate: new Date(),
|
||||
downloadUrl: 'magnet:?xt=urn:btih:abc',
|
||||
guid: 'guid-hash-bad',
|
||||
infoHash: 'abc123',
|
||||
format: 'M4B',
|
||||
},
|
||||
{
|
||||
indexer: 'Indexer',
|
||||
indexerId: 1,
|
||||
title: 'Good Release - Author',
|
||||
size: 50 * 1024 * 1024,
|
||||
seeders: 20,
|
||||
publishDate: new Date(),
|
||||
downloadUrl: 'magnet:?xt=urn:btih:def',
|
||||
guid: 'guid-hash-good',
|
||||
infoHash: 'def456',
|
||||
format: 'M4B',
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValue([
|
||||
{ id: 'b2', releaseKey: 'unrelated key', releaseHash: 'abc123' },
|
||||
]);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processSearchIndexers } = await import('@/lib/processors/search-indexers.processor');
|
||||
const result = await processSearchIndexers({
|
||||
requestId: 'req-filter-hash',
|
||||
audiobook: { id: 'a-filter-hash', title: 'Good Release', author: 'Author' },
|
||||
jobId: 'job-filter-hash',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(jobQueueMock.addDownloadJob).toHaveBeenCalledWith(
|
||||
'req-filter-hash',
|
||||
expect.anything(),
|
||||
expect.objectContaining({ title: 'Good Release - Author' })
|
||||
);
|
||||
});
|
||||
|
||||
it('uses blocklist-exhaustion message when every candidate is blocked', 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;
|
||||
});
|
||||
|
||||
prowlarrMock.searchWithVariations.mockResolvedValue([
|
||||
{
|
||||
indexer: 'Indexer',
|
||||
indexerId: 1,
|
||||
title: 'Bad Release One',
|
||||
size: 50 * 1024 * 1024,
|
||||
seeders: 10,
|
||||
publishDate: new Date(),
|
||||
downloadUrl: 'magnet:?xt=urn:btih:1',
|
||||
guid: 'g1',
|
||||
format: 'M4B',
|
||||
},
|
||||
{
|
||||
indexer: 'Indexer',
|
||||
indexerId: 1,
|
||||
title: 'Bad Release Two',
|
||||
size: 50 * 1024 * 1024,
|
||||
seeders: 5,
|
||||
publishDate: new Date(),
|
||||
downloadUrl: 'magnet:?xt=urn:btih:2',
|
||||
guid: 'g2',
|
||||
format: 'M4B',
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValue([
|
||||
{ id: 'b1', releaseKey: 'bad release one', releaseHash: null },
|
||||
{ id: 'b2', releaseKey: 'bad release two', releaseHash: null },
|
||||
]);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processSearchIndexers } = await import('@/lib/processors/search-indexers.processor');
|
||||
const result = await processSearchIndexers({
|
||||
requestId: 'req-exhausted',
|
||||
audiobook: { id: 'a-exhausted', title: 'Bad Release', author: 'Author' },
|
||||
jobId: 'job-exhausted',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toMatch(/No usable releases — 2 candidates tried, all blocked/);
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: 'awaiting_search',
|
||||
errorMessage: 'No usable releases — 2 candidates tried, all blocked',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(jobQueueMock.addDownloadJob).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Component: Blocklist Service Tests
|
||||
* Documentation: documentation/backend/database.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
const jobLoggerInfo = vi.fn();
|
||||
const jobLoggerWarn = vi.fn();
|
||||
const jobLoggerError = vi.fn();
|
||||
const createdLoggerInfo = vi.fn();
|
||||
const createdLoggerError = vi.fn();
|
||||
const forJobSpy = vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
info: jobLoggerInfo,
|
||||
warn: jobLoggerWarn,
|
||||
error: jobLoggerError,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/logger', () => ({
|
||||
RMABLogger: {
|
||||
create: () => ({
|
||||
debug: vi.fn(),
|
||||
info: createdLoggerInfo,
|
||||
warn: vi.fn(),
|
||||
error: createdLoggerError,
|
||||
}),
|
||||
forJob: forJobSpy,
|
||||
},
|
||||
}));
|
||||
|
||||
function baseInput() {
|
||||
return {
|
||||
requestId: 'req-1',
|
||||
releaseName: 'Some.Release.Name',
|
||||
source: 'organize_fail' as const,
|
||||
reason: 'No audiobook files found',
|
||||
};
|
||||
}
|
||||
|
||||
function fakeRow(overrides: Partial<{ id: string; releaseKey: string; createdAt: Date }> = {}) {
|
||||
return {
|
||||
id: overrides.id ?? 'block-1',
|
||||
requestId: 'req-1',
|
||||
releaseName: 'Some.Release.Name',
|
||||
releaseKey: overrides.releaseKey ?? 'some.release.name',
|
||||
releaseHash: null,
|
||||
indexerName: null,
|
||||
indexerId: null,
|
||||
source: 'organize_fail',
|
||||
reason: 'No audiobook files found',
|
||||
reasonDetail: null,
|
||||
downloadHistoryId: null,
|
||||
jobId: null,
|
||||
createdAt: overrides.createdAt ?? new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('addAutoBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('upserts on (requestId, releaseKey) with the normalized key', async () => {
|
||||
prismaMock.blockedRelease.upsert.mockResolvedValue(fakeRow());
|
||||
|
||||
const { addAutoBlock } = await import('@/lib/services/blocklist.service');
|
||||
await addAutoBlock({ ...baseInput(), releaseName: ' Some.Release.NAME ' });
|
||||
|
||||
expect(prismaMock.blockedRelease.upsert).toHaveBeenCalledTimes(1);
|
||||
const callArg = prismaMock.blockedRelease.upsert.mock.calls[0][0];
|
||||
expect(callArg.where).toEqual({
|
||||
requestId_releaseKey: { requestId: 'req-1', releaseKey: 'some.release.name' },
|
||||
});
|
||||
expect(callArg.create.releaseKey).toBe('some.release.name');
|
||||
expect(callArg.create.releaseName).toBe(' Some.Release.NAME ');
|
||||
expect(callArg.update).toEqual({});
|
||||
});
|
||||
|
||||
it('passes all metadata fields through to create', async () => {
|
||||
prismaMock.blockedRelease.upsert.mockResolvedValue(fakeRow());
|
||||
|
||||
const { addAutoBlock } = await import('@/lib/services/blocklist.service');
|
||||
await addAutoBlock({
|
||||
requestId: 'req-1',
|
||||
releaseName: 'Foo',
|
||||
source: 'download_fail',
|
||||
reason: 'Download failed (par2)',
|
||||
releaseHash: 'abc123',
|
||||
indexerName: 'NZBgeek',
|
||||
indexerId: 7,
|
||||
reasonDetail: 'Status: FAILURE/PAR; Par: FAILURE',
|
||||
downloadHistoryId: 'dh-9',
|
||||
jobId: 'job-42',
|
||||
});
|
||||
|
||||
const create = prismaMock.blockedRelease.upsert.mock.calls[0][0].create;
|
||||
expect(create).toMatchObject({
|
||||
requestId: 'req-1',
|
||||
releaseName: 'Foo',
|
||||
releaseKey: 'foo',
|
||||
releaseHash: 'abc123',
|
||||
indexerName: 'NZBgeek',
|
||||
indexerId: 7,
|
||||
source: 'download_fail',
|
||||
reason: 'Download failed (par2)',
|
||||
reasonDetail: 'Status: FAILURE/PAR; Par: FAILURE',
|
||||
downloadHistoryId: 'dh-9',
|
||||
jobId: 'job-42',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns wasNew=true when the row was just created', async () => {
|
||||
// createdAt in the future relative to before-call timestamp
|
||||
const future = new Date(Date.now() + 1000);
|
||||
prismaMock.blockedRelease.upsert.mockResolvedValue(fakeRow({ createdAt: future }));
|
||||
|
||||
const { addAutoBlock } = await import('@/lib/services/blocklist.service');
|
||||
const result = await addAutoBlock(baseInput());
|
||||
|
||||
expect(result.wasNew).toBe(true);
|
||||
expect(result.blocked).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns wasNew=false when the row already existed (idempotent second call)', async () => {
|
||||
// createdAt before the call started
|
||||
const past = new Date(Date.now() - 10_000);
|
||||
prismaMock.blockedRelease.upsert.mockResolvedValue(fakeRow({ createdAt: past }));
|
||||
|
||||
const { addAutoBlock } = await import('@/lib/services/blocklist.service');
|
||||
const result = await addAutoBlock(baseInput());
|
||||
|
||||
expect(result.wasNew).toBe(false);
|
||||
expect(result.blocked).not.toBeNull();
|
||||
});
|
||||
|
||||
it('emits a JobEvent via RMABLogger.forJob when jobId is provided', async () => {
|
||||
prismaMock.blockedRelease.upsert.mockResolvedValue(
|
||||
fakeRow({ createdAt: new Date(Date.now() + 1000) })
|
||||
);
|
||||
|
||||
const { addAutoBlock } = await import('@/lib/services/blocklist.service');
|
||||
await addAutoBlock({ ...baseInput(), jobId: 'job-42' });
|
||||
|
||||
expect(forJobSpy).toHaveBeenCalledWith('job-42', 'Blocklist.AutoBlock');
|
||||
expect(jobLoggerInfo).toHaveBeenCalledTimes(1);
|
||||
const [message, metadata] = jobLoggerInfo.mock.calls[0];
|
||||
expect(message).toContain('Some.Release.Name');
|
||||
expect(metadata).toMatchObject({
|
||||
requestId: 'req-1',
|
||||
source: 'organize_fail',
|
||||
reason: 'No audiobook files found',
|
||||
wasNew: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT emit a JobEvent when jobId is null', async () => {
|
||||
prismaMock.blockedRelease.upsert.mockResolvedValue(fakeRow());
|
||||
|
||||
const { addAutoBlock } = await import('@/lib/services/blocklist.service');
|
||||
await addAutoBlock(baseInput());
|
||||
|
||||
expect(forJobSpy).not.toHaveBeenCalled();
|
||||
expect(jobLoggerInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows DB errors and returns { blocked: null, wasNew: false }', async () => {
|
||||
prismaMock.blockedRelease.upsert.mockRejectedValue(new Error('DB exploded'));
|
||||
|
||||
const { addAutoBlock } = await import('@/lib/services/blocklist.service');
|
||||
const result = await addAutoBlock({ ...baseInput(), jobId: 'job-42' });
|
||||
|
||||
expect(result).toEqual({ blocked: null, wasNew: false });
|
||||
expect(createdLoggerError).toHaveBeenCalledTimes(1);
|
||||
// Failure path must NOT attempt the job-log either (no row to describe).
|
||||
expect(forJobSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isReleaseBlocked', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns true on normalized name match', async () => {
|
||||
prismaMock.blockedRelease.findFirst.mockResolvedValue({ id: 'block-1' });
|
||||
|
||||
const { isReleaseBlocked } = await import('@/lib/services/blocklist.service');
|
||||
const result = await isReleaseBlocked('req-1', ' The.Templar.LEGACY ');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prismaMock.blockedRelease.findFirst).toHaveBeenCalledWith({
|
||||
where: { requestId: 'req-1', OR: [{ releaseKey: 'the.templar.legacy' }] },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns true on hash match even when name differs', async () => {
|
||||
prismaMock.blockedRelease.findFirst.mockResolvedValue({ id: 'block-2' });
|
||||
|
||||
const { isReleaseBlocked } = await import('@/lib/services/blocklist.service');
|
||||
const result = await isReleaseBlocked('req-1', 'A different name', 'abc-hash');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prismaMock.blockedRelease.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
requestId: 'req-1',
|
||||
OR: [{ releaseKey: 'a different name' }, { releaseHash: 'abc-hash' }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns false when nothing matches', async () => {
|
||||
prismaMock.blockedRelease.findFirst.mockResolvedValue(null);
|
||||
|
||||
const { isReleaseBlocked } = await import('@/lib/services/blocklist.service');
|
||||
const result = await isReleaseBlocked('req-1', 'name', 'hash');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('does not include a hash clause when hash is null or undefined', async () => {
|
||||
prismaMock.blockedRelease.findFirst.mockResolvedValue(null);
|
||||
|
||||
const { isReleaseBlocked } = await import('@/lib/services/blocklist.service');
|
||||
await isReleaseBlocked('req-1', 'name');
|
||||
await isReleaseBlocked('req-1', 'name', null);
|
||||
|
||||
for (const call of prismaMock.blockedRelease.findFirst.mock.calls) {
|
||||
expect(call[0].where.OR).toEqual([{ releaseKey: 'name' }]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBlocklistForRequest', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('queries by requestId ordered by createdAt desc', async () => {
|
||||
const rows = [fakeRow({ id: 'a' }), fakeRow({ id: 'b' })];
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValue(rows);
|
||||
|
||||
const { getBlocklistForRequest } = await import('@/lib/services/blocklist.service');
|
||||
const result = await getBlocklistForRequest('req-1');
|
||||
|
||||
expect(result).toBe(rows);
|
||||
expect(prismaMock.blockedRelease.findMany).toHaveBeenCalledWith({
|
||||
where: { requestId: 'req-1' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('deletes a single row by id', async () => {
|
||||
const { removeBlock } = await import('@/lib/services/blocklist.service');
|
||||
await removeBlock('block-1');
|
||||
|
||||
expect(prismaMock.blockedRelease.delete).toHaveBeenCalledWith({
|
||||
where: { id: 'block-1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearBlocklist', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('deleteMany with the provided where clause and returns the count', async () => {
|
||||
prismaMock.blockedRelease.deleteMany.mockResolvedValue({ count: 7 });
|
||||
|
||||
const { clearBlocklist } = await import('@/lib/services/blocklist.service');
|
||||
const result = await clearBlocklist({ requestId: 'req-1' });
|
||||
|
||||
expect(prismaMock.blockedRelease.deleteMany).toHaveBeenCalledWith({
|
||||
where: { requestId: 'req-1' },
|
||||
});
|
||||
expect(result).toEqual({ count: 7 });
|
||||
});
|
||||
|
||||
it('passes an arbitrary filter through unchanged', async () => {
|
||||
prismaMock.blockedRelease.deleteMany.mockResolvedValue({ count: 0 });
|
||||
|
||||
const { clearBlocklist } = await import('@/lib/services/blocklist.service');
|
||||
await clearBlocklist({ source: 'organize_fail', requestId: 'req-1' });
|
||||
|
||||
expect(prismaMock.blockedRelease.deleteMany).toHaveBeenCalledWith({
|
||||
where: { source: 'organize_fail', requestId: 'req-1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Component: Blocked Results Filter Tests
|
||||
* Documentation: documentation/backend/database.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const getBlocklistForRequestMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/services/blocklist.service', () => ({
|
||||
getBlocklistForRequest: getBlocklistForRequestMock,
|
||||
}));
|
||||
|
||||
describe('filterBlockedResults', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns input unchanged when results array is empty', async () => {
|
||||
const { filterBlockedResults } = await import('@/lib/utils/filter-blocked-results');
|
||||
const { kept, blockedCount } = await filterBlockedResults('req-1', []);
|
||||
expect(kept).toEqual([]);
|
||||
expect(blockedCount).toBe(0);
|
||||
// Empty results should short-circuit before hitting the DB.
|
||||
expect(getBlocklistForRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns input unchanged when blocklist is empty', async () => {
|
||||
getBlocklistForRequestMock.mockResolvedValueOnce([]);
|
||||
const { filterBlockedResults } = await import('@/lib/utils/filter-blocked-results');
|
||||
const results = [{ title: 'Some Release' }];
|
||||
const { kept, blockedCount } = await filterBlockedResults('req-1', results);
|
||||
expect(kept).toBe(results);
|
||||
expect(blockedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('removes results that match a blocked release name case-insensitively', async () => {
|
||||
getBlocklistForRequestMock.mockResolvedValueOnce([
|
||||
{ releaseKey: 'foo bar [m4b]', releaseHash: null },
|
||||
]);
|
||||
const { filterBlockedResults } = await import('@/lib/utils/filter-blocked-results');
|
||||
const { kept, blockedCount } = await filterBlockedResults('req-1', [
|
||||
{ title: ' FOO BAR [M4B] ' },
|
||||
{ title: 'Other Release' },
|
||||
]);
|
||||
expect(kept).toEqual([{ title: 'Other Release' }]);
|
||||
expect(blockedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('removes results that match by infoHash even when title differs', async () => {
|
||||
getBlocklistForRequestMock.mockResolvedValueOnce([
|
||||
{ releaseKey: 'something else', releaseHash: 'abc123' },
|
||||
]);
|
||||
const { filterBlockedResults } = await import('@/lib/utils/filter-blocked-results');
|
||||
const { kept, blockedCount } = await filterBlockedResults('req-1', [
|
||||
{ title: 'Different Title', infoHash: 'abc123' },
|
||||
{ title: 'Keep Me', infoHash: 'zzz999' },
|
||||
]);
|
||||
expect(kept).toEqual([{ title: 'Keep Me', infoHash: 'zzz999' }]);
|
||||
expect(blockedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('does not filter by hash when the result has no infoHash', async () => {
|
||||
getBlocklistForRequestMock.mockResolvedValueOnce([
|
||||
{ releaseKey: 'unrelated', releaseHash: 'abc123' },
|
||||
]);
|
||||
const { filterBlockedResults } = await import('@/lib/utils/filter-blocked-results');
|
||||
const results = [{ title: 'No Hash Result' }];
|
||||
const { kept, blockedCount } = await filterBlockedResults('req-1', results);
|
||||
expect(kept).toEqual(results);
|
||||
expect(blockedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Component: Release Key Normalizer Tests
|
||||
* Documentation: documentation/backend/database.md
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeReleaseKey } from '@/lib/utils/release-key';
|
||||
|
||||
describe('normalizeReleaseKey', () => {
|
||||
it('lowercases ASCII characters', () => {
|
||||
expect(normalizeReleaseKey('SomeReleaseName')).toBe('somereleasename');
|
||||
});
|
||||
|
||||
it('trims leading and trailing whitespace', () => {
|
||||
expect(normalizeReleaseKey(' hello ')).toBe('hello');
|
||||
});
|
||||
|
||||
it('combines trim and lowercase', () => {
|
||||
expect(normalizeReleaseKey(' MIXED.Case Release ')).toBe('mixed.case release');
|
||||
});
|
||||
|
||||
it('preserves internal whitespace', () => {
|
||||
expect(normalizeReleaseKey('the templar legacy')).toBe('the templar legacy');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(normalizeReleaseKey('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for whitespace-only input', () => {
|
||||
expect(normalizeReleaseKey(' ')).toBe('');
|
||||
});
|
||||
|
||||
it('passes through unicode characters (with native lowercasing)', () => {
|
||||
expect(normalizeReleaseKey('Éclair')).toBe('éclair');
|
||||
});
|
||||
|
||||
it('is idempotent — normalizing a normalized value is a no-op', () => {
|
||||
const once = normalizeReleaseKey(' Some Release ');
|
||||
expect(normalizeReleaseKey(once)).toBe(once);
|
||||
});
|
||||
|
||||
it('treats different-case variants of the same release as the same key', () => {
|
||||
expect(normalizeReleaseKey('THE.TEMPLAR.LEGACY')).toBe(
|
||||
normalizeReleaseKey('the.templar.legacy')
|
||||
);
|
||||
expect(normalizeReleaseKey(' The.Templar.Legacy ')).toBe(
|
||||
normalizeReleaseKey('the.templar.legacy')
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user