fix: rTorrent null-safety, configurable SAB_HISTORY_LIMIT, lastError visibility (#68)
Build and Push Docker Image / build (push) Successful in 59s
Docs Check / Markdown lint (push) Failing after 1m45s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m7s
CI / Security audit (push) Successful in 2m33s
Docs Check / Mermaid diagram parse check (push) Successful in 2m55s
CI / Swagger Validation & Coverage (push) Successful in 3m19s
CI / Tests & coverage (push) Successful in 3m29s

- RTorrentClient: guard d.multicall2 returning non-array, per-row try/catch,
  explicit Number()/String() coercions, _extractArrInfo null-safe
- RTorrentClient.getClientStatus: coerce rates through Number.isFinite
- SABnzbdClient: history limit now reads SAB_HISTORY_LIMIT env var (default 10)
- DownloadClient: added _recordLastError, _clearLastError, getLastError on base
- All four clients call _recordLastError on failure, _clearLastError on success
- DownloadClientRegistry.getAllClientStatuses: includes lastError in result
- GET /api/status/status: exposes downloadClients[] array with per-client lastError
- Tests: RTorrentClient null-safety + lastError, SABnzbd history limit + lastError,
  downloadClients.test expectation updated for new lastError field
This commit is contained in:
2026-05-28 16:22:11 +01:00
parent 3d49c926dc
commit 6fa9c79a7d
11 changed files with 284 additions and 22 deletions
+64
View File
@@ -420,4 +420,68 @@ describe('RTorrentClient', () => {
expect(status).toBeNull();
});
});
describe('Null-safety (Issue #68)', () => {
it('should return [] when d.multicall2 returns a non-array', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
callback(null, null);
});
const downloads = await client.getActiveDownloads();
expect(downloads).toEqual([]);
});
it('should skip malformed individual torrent rows instead of throwing', async () => {
const torrents = [
// valid row
['hashA', 'Name A', 100, 50, 0, 0, 1, 1, 0, '/dl', ''],
// malformed row (not an array)
'not-an-array',
// row with null/undefined fields
['hashB', null, null, null, null, null, null, null, null, null, null]
];
mockMethodCall.mockImplementation((method, params, callback) => {
callback(null, torrents);
});
const downloads = await client.getActiveDownloads();
expect(downloads).toHaveLength(2);
expect(downloads[0].id).toBe('hashA');
expect(downloads[1].id).toBe('hashB');
expect(downloads[1].title).toBe('');
expect(downloads[1].size).toBe(0);
});
it('_extractArrInfo should return {} for non-string filename', () => {
expect(client._extractArrInfo(null)).toEqual({});
expect(client._extractArrInfo(undefined)).toEqual({});
expect(client._extractArrInfo(123)).toEqual({});
});
});
describe('lastError tracking (Issue #68)', () => {
it('should record lastError on getActiveDownloads failure', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
callback(new Error('boom'));
});
await client.getActiveDownloads();
expect(client.getLastError()).not.toBeNull();
expect(client.getLastError().operation).toBe('getActiveDownloads');
expect(client.getLastError().message).toBe('boom');
});
it('should clear lastError on successful call', async () => {
// First, fail.
mockMethodCall.mockImplementationOnce((method, params, callback) => {
callback(new Error('boom'));
});
await client.getActiveDownloads();
expect(client.getLastError()).not.toBeNull();
// Then, succeed.
mockMethodCall.mockImplementation((method, params, callback) => {
callback(null, []);
});
await client.getActiveDownloads();
expect(client.getLastError()).toBeNull();
});
});
});
+59
View File
@@ -299,4 +299,63 @@ describe('SABnzbdClient', () => {
expect(status).toBeNull();
});
});
describe('History limit configuration (Issue #68)', () => {
const ORIG_ENV = process.env.SAB_HISTORY_LIMIT;
afterEach(() => {
if (ORIG_ENV === undefined) delete process.env.SAB_HISTORY_LIMIT;
else process.env.SAB_HISTORY_LIMIT = ORIG_ENV;
});
it('defaults historyLimit to 10 when SAB_HISTORY_LIMIT is unset', () => {
delete process.env.SAB_HISTORY_LIMIT;
const c = new SABnzbdClient(mockConfig);
expect(c.historyLimit).toBe(10);
});
it('honors SAB_HISTORY_LIMIT when set to a valid integer', () => {
process.env.SAB_HISTORY_LIMIT = '25';
const c = new SABnzbdClient(mockConfig);
expect(c.historyLimit).toBe(25);
});
it('falls back to default on invalid SAB_HISTORY_LIMIT', () => {
process.env.SAB_HISTORY_LIMIT = 'not-a-number';
const c = new SABnzbdClient(mockConfig);
expect(c.historyLimit).toBe(10);
});
it('passes historyLimit through to the history API call', async () => {
process.env.SAB_HISTORY_LIMIT = '42';
const c = new SABnzbdClient(mockConfig);
const makeRequest = vi.fn()
.mockResolvedValueOnce({ data: { queue: { slots: [], kbpersec: 0 } } })
.mockResolvedValueOnce({ data: { history: { slots: [] } } });
c.makeRequest = makeRequest;
await c.getActiveDownloads();
expect(makeRequest).toHaveBeenCalledWith({ mode: 'history', limit: 42 });
});
});
describe('lastError tracking (Issue #68)', () => {
it('records lastError when getActiveDownloads fails', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('boom'));
await client.getActiveDownloads();
expect(client.getLastError()).not.toBeNull();
expect(client.getLastError().operation).toBe('getActiveDownloads');
expect(client.getLastError().message).toBe('boom');
});
it('clears lastError after a subsequent successful call', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('boom'));
await client.getActiveDownloads();
expect(client.getLastError()).not.toBeNull();
client.makeRequest = vi.fn()
.mockResolvedValueOnce({ data: { queue: { slots: [], kbpersec: 0 } } })
.mockResolvedValueOnce({ data: { history: { slots: [] } } });
await client.getActiveDownloads();
expect(client.getLastError()).toBeNull();
});
});
});