feat: Add Ombi request filtering and search
Build and Push Docker Image / build (push) Successful in 1m29s
Docs Check / Markdown lint (push) Successful in 1m51s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m3s
CI / Security audit (push) Successful in 2m54s
CI / Swagger Validation & Coverage (push) Successful in 3m6s
Docs Check / Mermaid diagram parse check (push) Successful in 3m13s
CI / Tests & coverage (push) Successful in 3m31s
Build and Push Docker Image / build (push) Successful in 1m29s
Docs Check / Markdown lint (push) Successful in 1m51s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m3s
CI / Security audit (push) Successful in 2m54s
CI / Swagger Validation & Coverage (push) Successful in 3m6s
Docs Check / Mermaid diagram parse check (push) Successful in 3m13s
CI / Tests & coverage (push) Successful in 3m31s
- Add request filters UI (type, status, sort, search) - Implement dual-layer filtering (server + client) - Add ombiFilters utility for consistent filtering logic - Persist filter preferences in localStorage - Add SSE support for real-time Ombi request updates - Add webhook endpoints for Ombi integration - Update OpenAPI spec for new endpoints - Add unit tests for filter logic and UI - Add integration tests for Ombi routes
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
* Tests for client/src/ui/requestFilters.js
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { state } from '../../../client/src/state.js';
|
||||
import { initRequestFilters } from '../../../client/src/ui/requestFilters.js';
|
||||
import { renderRequests } from '../../../client/src/ui/requests.js';
|
||||
|
||||
// Mock renderRequests to verify re-render triggers
|
||||
vi.mock('../../../client/src/ui/requests.js', () => ({
|
||||
renderRequests: vi.fn()
|
||||
}));
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store = {};
|
||||
return {
|
||||
getItem: (key) => store[key] || null,
|
||||
setItem: (key, value) => { store[key] = value; },
|
||||
removeItem: (key) => { delete store[key]; },
|
||||
clear: () => { store = {}; }
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock
|
||||
});
|
||||
|
||||
function setupDOM() {
|
||||
document.body.innerHTML = `
|
||||
<div class="requests-controls">
|
||||
<div class="request-filter" id="request-type-filter">
|
||||
<button class="request-filter-btn" id="request-type-filter-btn" type="button">
|
||||
<span id="request-type-selected-text">All</span>
|
||||
<span class="dropdown-arrow">▼</span>
|
||||
</button>
|
||||
<div class="request-filter-dropdown" id="request-type-filter-dropdown">
|
||||
<div class="request-filter-dropdown-header">
|
||||
<button id="request-type-select-all" type="button">Select All</button>
|
||||
<button id="request-type-deselect-all" type="button">Deselect All</button>
|
||||
</div>
|
||||
<div class="request-filter-options" id="request-type-options">
|
||||
<div class="request-filter-option" data-value="movie">
|
||||
<input type="checkbox" id="request-type-movie" class="request-filter-checkbox" checked>
|
||||
<label for="request-type-movie">Movies</label>
|
||||
</div>
|
||||
<div class="request-filter-option" data-value="tv">
|
||||
<input type="checkbox" id="request-type-tv" class="request-filter-checkbox" checked>
|
||||
<label for="request-type-tv">TV Shows</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="request-filter" id="request-status-filter">
|
||||
<button class="request-filter-btn" id="request-status-filter-btn" type="button">
|
||||
<span id="request-status-selected-text">All</span>
|
||||
<span class="dropdown-arrow">▼</span>
|
||||
</button>
|
||||
<div class="request-filter-dropdown" id="request-status-filter-dropdown">
|
||||
<div class="request-filter-dropdown-header">
|
||||
<button id="request-status-select-all" type="button">Select All</button>
|
||||
<button id="request-status-deselect-all" type="button">Deselect All</button>
|
||||
</div>
|
||||
<div class="request-filter-options" id="request-status-options">
|
||||
<div class="request-filter-option" data-value="pending">
|
||||
<input type="checkbox" id="request-status-pending" class="request-filter-checkbox">
|
||||
<label for="request-status-pending">Pending</label>
|
||||
</div>
|
||||
<div class="request-filter-option" data-value="approved">
|
||||
<input type="checkbox" id="request-status-approved" class="request-filter-checkbox">
|
||||
<label for="request-status-approved">Approved</label>
|
||||
</div>
|
||||
<div class="request-filter-option" data-value="available">
|
||||
<input type="checkbox" id="request-status-available" class="request-filter-checkbox">
|
||||
<label for="request-status-available">Available</label>
|
||||
</div>
|
||||
<div class="request-filter-option" data-value="denied">
|
||||
<input type="checkbox" id="request-status-denied" class="request-filter-checkbox">
|
||||
<label for="request-status-denied">Denied</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="request-sort">
|
||||
<select id="request-sort-select" class="request-sort-select">
|
||||
<option value="requestedDate_desc">Newest to oldest</option>
|
||||
<option value="requestedDate_asc">Oldest to newest</option>
|
||||
<option value="title_asc">A–Z</option>
|
||||
<option value="title_desc">Z–A</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="request-search">
|
||||
<input type="text" id="request-search-input" class="request-search-input" placeholder="Search by title...">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
describe('initRequestFilters', () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
state.selectedRequestTypes = ['movie', 'tv'];
|
||||
state.selectedRequestStatuses = [];
|
||||
state.requestSortMode = 'requestedDate_desc';
|
||||
state.requestSearchQuery = '';
|
||||
vi.clearAllMocks();
|
||||
setupDOM();
|
||||
initRequestFilters();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('restores saved type selections from localStorage', () => {
|
||||
localStorageMock.setItem('sofarr-request-types', JSON.stringify(['tv']));
|
||||
state.selectedRequestTypes = ['tv'];
|
||||
setupDOM();
|
||||
initRequestFilters();
|
||||
|
||||
const movieCb = document.getElementById('request-type-movie');
|
||||
const tvCb = document.getElementById('request-type-tv');
|
||||
expect(movieCb.checked).toBe(false);
|
||||
expect(tvCb.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('restores saved status selections from localStorage', () => {
|
||||
localStorageMock.setItem('sofarr-request-statuses', JSON.stringify(['pending', 'approved']));
|
||||
state.selectedRequestStatuses = ['pending', 'approved'];
|
||||
setupDOM();
|
||||
initRequestFilters();
|
||||
|
||||
expect(document.getElementById('request-status-pending').checked).toBe(true);
|
||||
expect(document.getElementById('request-status-approved').checked).toBe(true);
|
||||
expect(document.getElementById('request-status-available').checked).toBe(false);
|
||||
});
|
||||
|
||||
it('restores saved sort mode', () => {
|
||||
localStorageMock.setItem('sofarr-request-sort', 'title_asc');
|
||||
state.requestSortMode = 'title_asc';
|
||||
setupDOM();
|
||||
initRequestFilters();
|
||||
|
||||
expect(document.getElementById('request-sort-select').value).toBe('title_asc');
|
||||
});
|
||||
|
||||
it('restores saved search query', () => {
|
||||
localStorageMock.setItem('sofarr-request-search', 'batman');
|
||||
state.requestSearchQuery = 'batman';
|
||||
setupDOM();
|
||||
initRequestFilters();
|
||||
|
||||
expect(document.getElementById('request-search-input').value).toBe('batman');
|
||||
});
|
||||
|
||||
it('toggles type checkbox and updates state', () => {
|
||||
const movieCb = document.getElementById('request-type-movie');
|
||||
movieCb.click();
|
||||
|
||||
expect(state.selectedRequestTypes).toEqual(['tv']);
|
||||
expect(localStorageMock.getItem('sofarr-request-types')).toBe(JSON.stringify(['tv']));
|
||||
expect(renderRequests).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggles status checkbox and updates state', () => {
|
||||
const pendingCb = document.getElementById('request-status-pending');
|
||||
pendingCb.click();
|
||||
|
||||
expect(state.selectedRequestStatuses).toEqual(['pending']);
|
||||
expect(localStorageMock.getItem('sofarr-request-statuses')).toBe(JSON.stringify(['pending']));
|
||||
expect(renderRequests).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('select all sets all types', () => {
|
||||
state.selectedRequestTypes = [];
|
||||
setupDOM();
|
||||
initRequestFilters();
|
||||
|
||||
document.getElementById('request-type-select-all').click();
|
||||
|
||||
expect(state.selectedRequestTypes).toEqual(['movie', 'tv']);
|
||||
expect(renderRequests).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deselect all clears all types', () => {
|
||||
document.getElementById('request-type-deselect-all').click();
|
||||
|
||||
expect(state.selectedRequestTypes).toEqual([]);
|
||||
expect(renderRequests).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('select all sets all statuses', () => {
|
||||
document.getElementById('request-status-select-all').click();
|
||||
|
||||
expect(state.selectedRequestStatuses).toEqual(['pending', 'approved', 'available', 'denied']);
|
||||
expect(renderRequests).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deselect all clears all statuses', () => {
|
||||
state.selectedRequestStatuses = ['pending', 'approved'];
|
||||
setupDOM();
|
||||
initRequestFilters();
|
||||
|
||||
document.getElementById('request-status-deselect-all').click();
|
||||
|
||||
expect(state.selectedRequestStatuses).toEqual([]);
|
||||
expect(renderRequests).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('changing sort select updates state', () => {
|
||||
const select = document.getElementById('request-sort-select');
|
||||
select.value = 'title_asc';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
|
||||
expect(state.requestSortMode).toBe('title_asc');
|
||||
expect(localStorageMock.getItem('sofarr-request-sort')).toBe('title_asc');
|
||||
expect(renderRequests).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('typing in search input updates state after debounce', async () => {
|
||||
const input = document.getElementById('request-search-input');
|
||||
input.value = 'bat';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
|
||||
// State shouldn't update immediately due to debounce
|
||||
expect(state.requestSearchQuery).toBe('');
|
||||
expect(renderRequests).not.toHaveBeenCalled();
|
||||
|
||||
// Wait for debounce
|
||||
await new Promise(r => setTimeout(r, 250));
|
||||
|
||||
expect(state.requestSearchQuery).toBe('bat');
|
||||
expect(localStorageMock.getItem('sofarr-request-search')).toBe('bat');
|
||||
expect(renderRequests).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clicking outside closes dropdowns', () => {
|
||||
const typeDropdown = document.getElementById('request-type-filter-dropdown');
|
||||
const typeBtn = document.getElementById('request-type-filter-btn');
|
||||
|
||||
typeBtn.click();
|
||||
expect(typeDropdown.classList.contains('open')).toBe(true);
|
||||
|
||||
document.body.click();
|
||||
expect(typeDropdown.classList.contains('open')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -143,6 +143,9 @@ afterEach(() => {
|
||||
// GET /api/ombi/requests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TODO: Unskip these tests once arrRetrieverRegistry can be properly mocked.
|
||||
// The tests need to control the return value of getOmbiRequests() to test
|
||||
// user filtering, showAll parameter, and edge cases reliably.
|
||||
describe.skip('GET /api/ombi/requests', () => {
|
||||
let app;
|
||||
|
||||
@@ -387,6 +390,163 @@ describe.skip('GET /api/ombi/requests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/ombi/requests — query param filtering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FILTERED_MOVIE_REQUESTS = [
|
||||
{ id: 1, title: 'The Batman', requestedDate: '2026-05-21T10:00:00.000Z', available: false, approved: true, denied: false, requested: true, theMovieDbId: 414906 },
|
||||
{ id: 2, title: 'Batman Returns', requestedDate: '2026-05-10T10:00:00.000Z', available: true, approved: true, denied: false, requested: true, theMovieDbId: 414907 }
|
||||
];
|
||||
|
||||
const FILTERED_TV_REQUESTS = [
|
||||
{ id: 3, title: 'Superman Show', requestedDate: '2026-05-15T10:00:00.000Z', available: false, approved: false, denied: false, requested: true, theMovieDbId: 101 }
|
||||
];
|
||||
|
||||
describe('GET /api/ombi/requests query params', () => {
|
||||
let app;
|
||||
let arrRetrieverRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
app = makeApp();
|
||||
setupOmbiRequestMocks(FILTERED_MOVIE_REQUESTS, FILTERED_TV_REQUESTS);
|
||||
|
||||
// Reset the singleton registry so it re-initializes on each request
|
||||
arrRetrieverRegistry = require('../../server/utils/arrRetrievers');
|
||||
arrRetrieverRegistry.retrievers.clear();
|
||||
arrRetrieverRegistry.initialized = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (arrRetrieverRegistry) {
|
||||
arrRetrieverRegistry.retrievers.clear();
|
||||
arrRetrieverRegistry.initialized = false;
|
||||
}
|
||||
});
|
||||
|
||||
it('filters by type=movie', async () => {
|
||||
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/ombi/requests?type=movie&showAll=true')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.requests.movie.length, `Body was: ${JSON.stringify(res.body)}`).toBe(2);
|
||||
expect(res.body.requests.tv).toHaveLength(0);
|
||||
expect(res.body.total).toBe(2);
|
||||
});
|
||||
|
||||
it('filters by type=tv', async () => {
|
||||
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/ombi/requests?type=tv&showAll=true')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.requests.movie).toHaveLength(0);
|
||||
expect(res.body.requests.tv).toHaveLength(1);
|
||||
expect(res.body.total).toBe(1);
|
||||
});
|
||||
|
||||
it('filters by status=pending', async () => {
|
||||
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/ombi/requests?status=pending&showAll=true')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.total).toBe(1);
|
||||
expect(res.body.requests.tv[0].title).toBe('Superman Show');
|
||||
});
|
||||
|
||||
it('filters by status=available', async () => {
|
||||
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/ombi/requests?status=available&showAll=true')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.total).toBe(1);
|
||||
expect(res.body.requests.movie[0].title).toBe('Batman Returns');
|
||||
});
|
||||
|
||||
it('sorts by title_asc', async () => {
|
||||
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/ombi/requests?sort=title_asc&showAll=true')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
|
||||
const all = [...res.body.requests.movie, ...res.body.requests.tv];
|
||||
expect(all.map(r => r.title)).toEqual(['Batman Returns', 'The Batman', 'Superman Show']);
|
||||
});
|
||||
|
||||
it('sorts by title_desc', async () => {
|
||||
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/ombi/requests?sort=title_desc&showAll=true')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
|
||||
const all = [...res.body.requests.movie, ...res.body.requests.tv];
|
||||
expect(all.map(r => r.title)).toEqual(['The Batman', 'Batman Returns', 'Superman Show']);
|
||||
});
|
||||
|
||||
it('sorts by requestedDate_desc (default)', async () => {
|
||||
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/ombi/requests?showAll=true')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
|
||||
const all = [...res.body.requests.movie, ...res.body.requests.tv];
|
||||
expect(all.map(r => r.title)).toEqual(['The Batman', 'Batman Returns', 'Superman Show']);
|
||||
});
|
||||
|
||||
it('searches by title substring', async () => {
|
||||
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/ombi/requests?search=bat&showAll=true')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.total).toBe(2);
|
||||
expect(res.body.requests.movie).toHaveLength(2);
|
||||
expect(res.body.requests.tv).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('combines multiple query params', async () => {
|
||||
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/ombi/requests?type=movie&status=approved&search=bat&sort=title_asc&showAll=true')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.total).toBe(1);
|
||||
expect(res.body.requests.movie[0].title).toBe('The Batman');
|
||||
});
|
||||
|
||||
it('invalid sort falls back to default', async () => {
|
||||
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/ombi/requests?sort=invalid&showAll=true')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.total).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/ombi/webhook/status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const {
|
||||
getRequestStatus,
|
||||
filterByType,
|
||||
filterByStatus,
|
||||
filterBySearch,
|
||||
sortRequests,
|
||||
applyRequestFilters
|
||||
} = require('../../server/utils/ombiFilters');
|
||||
|
||||
function makeRequest(overrides = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
title: 'Test Request',
|
||||
requestedDate: '2026-05-21T10:00:00.000Z',
|
||||
available: false,
|
||||
approved: false,
|
||||
denied: false,
|
||||
requested: true,
|
||||
mediaType: 'movie',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getRequestStatus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getRequestStatus', () => {
|
||||
it('returns available when available is true', () => {
|
||||
expect(getRequestStatus(makeRequest({ available: true }))).toBe('available');
|
||||
});
|
||||
|
||||
it('returns denied when denied is true', () => {
|
||||
expect(getRequestStatus(makeRequest({ denied: true }))).toBe('denied');
|
||||
});
|
||||
|
||||
it('returns approved when approved is true', () => {
|
||||
expect(getRequestStatus(makeRequest({ approved: true }))).toBe('approved');
|
||||
});
|
||||
|
||||
it('returns pending when requested is true', () => {
|
||||
expect(getRequestStatus(makeRequest({ requested: true }))).toBe('pending');
|
||||
});
|
||||
|
||||
it('returns unknown for empty object', () => {
|
||||
expect(getRequestStatus({})).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns unknown for null', () => {
|
||||
expect(getRequestStatus(null)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('follows priority: available > denied > approved > pending', () => {
|
||||
expect(getRequestStatus(makeRequest({ available: true, denied: true }))).toBe('available');
|
||||
expect(getRequestStatus(makeRequest({ denied: true, approved: true }))).toBe('denied');
|
||||
expect(getRequestStatus(makeRequest({ approved: true, requested: true }))).toBe('approved');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// filterByType
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('filterByType', () => {
|
||||
const movie = makeRequest({ mediaType: 'movie' });
|
||||
const tv = makeRequest({ mediaType: 'tv', id: 2 });
|
||||
|
||||
it('returns all when types is empty', () => {
|
||||
expect(filterByType([movie, tv], [])).toEqual([movie, tv]);
|
||||
});
|
||||
|
||||
it('returns all when types includes "all"', () => {
|
||||
expect(filterByType([movie, tv], ['all'])).toEqual([movie, tv]);
|
||||
});
|
||||
|
||||
it('filters to movies only', () => {
|
||||
expect(filterByType([movie, tv], ['movie'])).toEqual([movie]);
|
||||
});
|
||||
|
||||
it('filters to tv only', () => {
|
||||
expect(filterByType([movie, tv], ['tv'])).toEqual([tv]);
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(filterByType([movie, tv], ['MOVIE'])).toEqual([movie]);
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
expect(filterByType([], ['movie'])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// filterByStatus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('filterByStatus', () => {
|
||||
const pending = makeRequest({ requested: true });
|
||||
const approved = makeRequest({ approved: true, requested: true, id: 2 });
|
||||
const available = makeRequest({ available: true, id: 3 });
|
||||
|
||||
it('returns all when statuses is empty', () => {
|
||||
expect(filterByStatus([pending, approved], [])).toEqual([pending, approved]);
|
||||
});
|
||||
|
||||
it('filters by single status', () => {
|
||||
expect(filterByStatus([pending, approved], ['approved'])).toEqual([approved]);
|
||||
});
|
||||
|
||||
it('filters by multiple statuses', () => {
|
||||
expect(filterByStatus([pending, approved, available], ['pending', 'available'])).toEqual([pending, available]);
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(filterByStatus([pending], ['PENDING'])).toEqual([pending]);
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
expect(filterByStatus([], ['pending'])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// filterBySearch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('filterBySearch', () => {
|
||||
const batman = makeRequest({ title: 'The Batman' });
|
||||
const superman = makeRequest({ title: 'Superman', id: 2 });
|
||||
|
||||
it('returns all when query is empty', () => {
|
||||
expect(filterBySearch([batman, superman], '')).toEqual([batman, superman]);
|
||||
});
|
||||
|
||||
it('returns all when query is whitespace', () => {
|
||||
expect(filterBySearch([batman, superman], ' ')).toEqual([batman, superman]);
|
||||
});
|
||||
|
||||
it('filters by case-insensitive substring', () => {
|
||||
expect(filterBySearch([batman, superman], 'bat')).toEqual([batman]);
|
||||
expect(filterBySearch([batman, superman], 'BAT')).toEqual([batman]);
|
||||
});
|
||||
|
||||
it('handles missing title', () => {
|
||||
expect(filterBySearch([makeRequest({ title: undefined })], 'test')).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
expect(filterBySearch([], 'test')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sortRequests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('sortRequests', () => {
|
||||
const oldReq = makeRequest({ id: 1, title: 'Alpha', requestedDate: '2026-01-01T00:00:00.000Z' });
|
||||
const midReq = makeRequest({ id: 2, title: 'Beta', requestedDate: '2026-05-01T00:00:00.000Z' });
|
||||
const newReq = makeRequest({ id: 3, title: 'Charlie', requestedDate: '2026-10-01T00:00:00.000Z' });
|
||||
|
||||
it('sorts newest to oldest by default', () => {
|
||||
const sorted = sortRequests([oldReq, newReq, midReq], 'requestedDate_desc');
|
||||
expect(sorted.map(r => r.id)).toEqual([3, 2, 1]);
|
||||
});
|
||||
|
||||
it('sorts oldest to newest', () => {
|
||||
const sorted = sortRequests([oldReq, newReq, midReq], 'requestedDate_asc');
|
||||
expect(sorted.map(r => r.id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('sorts A-Z', () => {
|
||||
const sorted = sortRequests([midReq, oldReq, newReq], 'title_asc');
|
||||
expect(sorted.map(r => r.title)).toEqual(['Alpha', 'Beta', 'Charlie']);
|
||||
});
|
||||
|
||||
it('sorts Z-A', () => {
|
||||
const sorted = sortRequests([midReq, oldReq, newReq], 'title_desc');
|
||||
expect(sorted.map(r => r.title)).toEqual(['Charlie', 'Beta', 'Alpha']);
|
||||
});
|
||||
|
||||
it('defaults to requestedDate_desc for unknown sort mode', () => {
|
||||
const sorted = sortRequests([oldReq, newReq], 'invalid');
|
||||
expect(sorted.map(r => r.id)).toEqual([3, 1]);
|
||||
});
|
||||
|
||||
it('handles missing requestedDate by treating as epoch 0', () => {
|
||||
const noDate = makeRequest({ id: 4, requestedDate: undefined });
|
||||
const sorted = sortRequests([midReq, noDate], 'requestedDate_desc');
|
||||
expect(sorted[0]).toBe(midReq);
|
||||
expect(sorted[1]).toBe(noDate);
|
||||
});
|
||||
|
||||
it('handles missing title', () => {
|
||||
const noTitle = makeRequest({ id: 4, title: undefined });
|
||||
const withTitle = makeRequest({ id: 5, title: 'Zebra' });
|
||||
const sorted = sortRequests([noTitle, withTitle], 'title_asc');
|
||||
expect(sorted[0]).toBe(noTitle);
|
||||
expect(sorted[1]).toBe(withTitle);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyRequestFilters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applyRequestFilters', () => {
|
||||
const moviePending = makeRequest({ id: 1, title: 'The Batman', mediaType: 'movie', requested: true, approved: false });
|
||||
const tvApproved = makeRequest({ id: 2, title: 'Superman Show', mediaType: 'tv', approved: true, requested: false });
|
||||
const movieAvailable = makeRequest({ id: 3, title: 'Batman Returns', mediaType: 'movie', available: true });
|
||||
|
||||
it('applies all filters together', () => {
|
||||
const result = applyRequestFilters(
|
||||
[moviePending, tvApproved, movieAvailable],
|
||||
{ types: ['movie'], statuses: ['pending', 'available'], sort: 'title_asc', search: 'bat' }
|
||||
);
|
||||
expect(result.map(r => r.id)).toEqual([3, 1]);
|
||||
});
|
||||
|
||||
it('returns unfiltered when no options provided', () => {
|
||||
const result = applyRequestFilters([moviePending, tvApproved], {});
|
||||
expect(result).toEqual([moviePending, tvApproved]);
|
||||
});
|
||||
|
||||
it('returns empty array when no matches', () => {
|
||||
const result = applyRequestFilters(
|
||||
[moviePending],
|
||||
{ types: ['tv'] }
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user