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
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user