mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +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,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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user