chore: bump version to 1.7.22 and update CHANGELOG, tests and docs
Docs Check / Markdown lint (push) Successful in 1m14s
Build and Push Docker Image / build (push) Successful in 1m52s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m55s
CI / Security audit (push) Successful in 2m25s
Docs Check / Mermaid diagram parse check (push) Successful in 3m6s
CI / Swagger Validation & Coverage (push) Successful in 3m22s
CI / Tests & coverage (push) Successful in 3m45s
Docs Check / Markdown lint (push) Successful in 1m14s
Build and Push Docker Image / build (push) Successful in 1m52s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m55s
CI / Security audit (push) Successful in 2m25s
Docs Check / Mermaid diagram parse check (push) Successful in 3m6s
CI / Swagger Validation & Coverage (push) Successful in 3m22s
CI / Tests & coverage (push) Successful in 3m45s
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
* Tests for client/src/ui/requests.js
|
||||
*
|
||||
* Verifies requests dashboard rendering, tooltips, dates, and deep links.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { renderRequests } from '../../../client/src/ui/requests.js';
|
||||
import { state } from '../../../client/src/state.js';
|
||||
|
||||
vi.mock('../../../client/src/state.js', () => {
|
||||
return {
|
||||
state: {
|
||||
ombiRequests: { movie: [], tv: [] },
|
||||
selectedRequestTypes: ['movie', 'tv'],
|
||||
selectedRequestStatuses: ['pending', 'approved', 'available', 'denied'],
|
||||
requestSortMode: 'requestedDate_desc',
|
||||
requestSearchQuery: '',
|
||||
ombiBaseUrl: 'https://ombi.test',
|
||||
isAdmin: false
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
describe('requests rendering', () => {
|
||||
let requestsList, noRequests;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
document.body.innerHTML = `
|
||||
<div id="requests-list"></div>
|
||||
<div id="no-requests" style="display: none;"><p></p></div>
|
||||
`;
|
||||
|
||||
requestsList = document.getElementById('requests-list');
|
||||
noRequests = document.getElementById('no-requests');
|
||||
|
||||
state.ombiRequests = { movie: [], tv: [] };
|
||||
state.isAdmin = false;
|
||||
state.ombiBaseUrl = 'https://ombi.test';
|
||||
});
|
||||
|
||||
it('renders "No requests found." when request arrays are empty', () => {
|
||||
renderRequests();
|
||||
|
||||
expect(requestsList.childNodes.length).toBe(0);
|
||||
expect(noRequests.style.display).toBe('block');
|
||||
expect(noRequests.querySelector('p').textContent).toBe('No requests found.');
|
||||
});
|
||||
|
||||
it('renders request card with correctly formatted date, media type, and requester', () => {
|
||||
state.ombiRequests = {
|
||||
movie: [
|
||||
{
|
||||
id: 101,
|
||||
title: 'Movie Test',
|
||||
year: '2026',
|
||||
requestedUser: { alias: 'john_doe' },
|
||||
requestedDate: '2026-05-27T10:15:30.000Z',
|
||||
quality: '1080p',
|
||||
theMovieDbId: 555,
|
||||
requested: true
|
||||
}
|
||||
],
|
||||
tv: []
|
||||
};
|
||||
|
||||
renderRequests();
|
||||
|
||||
expect(requestsList.childNodes.length).toBe(1);
|
||||
const card = requestsList.childNodes[0];
|
||||
expect(card.querySelector('.request-title').textContent).toBe('Movie Test');
|
||||
expect(card.querySelector('.request-year').textContent).toBe('2026');
|
||||
expect(card.querySelector('.request-user').textContent).toBe('Requested by: john_doe');
|
||||
|
||||
// Check formatted date
|
||||
const dateEl = card.querySelector('.request-date');
|
||||
expect(dateEl).toBeTruthy();
|
||||
expect(dateEl.textContent).toContain('Date: 2026-05-27');
|
||||
|
||||
// Check view in Ombi link
|
||||
const ombiLink = card.querySelector('.ombi-link');
|
||||
expect(ombiLink).toBeTruthy();
|
||||
expect(ombiLink.href).toBe('https://ombi.test/details/movie/555');
|
||||
});
|
||||
|
||||
it('renders "Unknown (Ombi)" with tooltip when requester is missing', () => {
|
||||
state.ombiRequests = {
|
||||
movie: [],
|
||||
tv: [
|
||||
{
|
||||
id: 201,
|
||||
title: 'TV Test No User',
|
||||
requestedDate: '2026-05-27T12:00:00.000Z',
|
||||
requested: true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
renderRequests();
|
||||
|
||||
expect(requestsList.childNodes.length).toBe(1);
|
||||
const card = requestsList.childNodes[0];
|
||||
const userEl = card.querySelector('.request-user');
|
||||
expect(userEl).toBeTruthy();
|
||||
expect(userEl.textContent).toBe('Requested by: Unknown (Ombi)');
|
||||
expect(userEl.title).toBe('No user information received from Ombi');
|
||||
expect(userEl.style.textDecoration).toBe('underline dotted');
|
||||
});
|
||||
|
||||
it('does NOT render Sonarr/Radarr deep links for non-admin users', () => {
|
||||
state.isAdmin = false;
|
||||
state.ombiRequests = {
|
||||
movie: [
|
||||
{
|
||||
id: 101,
|
||||
title: 'Movie Test',
|
||||
theMovieDbId: 555,
|
||||
arrLink: 'http://radarr:7878/movie/slug',
|
||||
arrType: 'radarr',
|
||||
requested: true
|
||||
}
|
||||
],
|
||||
tv: []
|
||||
};
|
||||
|
||||
renderRequests();
|
||||
|
||||
const card = requestsList.childNodes[0];
|
||||
expect(card.querySelector('.radarr-link')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders Sonarr/Radarr deep links next to Ombi link for administrators', () => {
|
||||
state.isAdmin = true;
|
||||
state.ombiRequests = {
|
||||
movie: [
|
||||
{
|
||||
id: 101,
|
||||
title: 'Movie Test',
|
||||
theMovieDbId: 555,
|
||||
arrLink: 'http://radarr:7878/movie/slug',
|
||||
arrType: 'radarr',
|
||||
requested: true
|
||||
}
|
||||
],
|
||||
tv: [
|
||||
{
|
||||
id: 202,
|
||||
title: 'TV Show Test',
|
||||
theMovieDbId: 666,
|
||||
arrLink: 'http://sonarr:8989/series/slug',
|
||||
arrType: 'sonarr',
|
||||
requested: true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
renderRequests();
|
||||
|
||||
expect(requestsList.childNodes.length).toBe(2);
|
||||
|
||||
// Check Radarr link
|
||||
const movieCard = requestsList.childNodes[0];
|
||||
const radarrLink = movieCard.querySelector('.radarr-link');
|
||||
expect(radarrLink).toBeTruthy();
|
||||
expect(radarrLink.href).toBe('http://radarr:7878/movie/slug');
|
||||
expect(radarrLink.title).toBe('View in Radarr');
|
||||
|
||||
// Check Sonarr link
|
||||
const tvCard = requestsList.childNodes[1];
|
||||
const sonarrLink = tvCard.querySelector('.sonarr-link');
|
||||
expect(sonarrLink).toBeTruthy();
|
||||
expect(sonarrLink.href).toBe('http://sonarr:8989/series/slug');
|
||||
expect(sonarrLink.title).toBe('View in Sonarr');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
* Tests for client/src/ui/theme.js
|
||||
*
|
||||
* Verifies DOM actions for theme switcher button clicks, attributes, and storage calls.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { initThemeSwitcher, setTheme } from '../../../client/src/ui/theme.js';
|
||||
import * as storage from '../../../client/src/utils/storage.js';
|
||||
|
||||
vi.mock('../../../client/src/utils/storage.js', () => {
|
||||
let store = {};
|
||||
return {
|
||||
getTheme: vi.fn(() => store.theme || 'light'),
|
||||
saveTheme: vi.fn((theme) => { store.theme = theme; })
|
||||
};
|
||||
});
|
||||
|
||||
describe('theme switcher', () => {
|
||||
let lightBtn, darkBtn, monoBtn;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
|
||||
// Create mock theme buttons
|
||||
document.body.innerHTML = `
|
||||
<div class="theme-switcher">
|
||||
<button class="theme-btn" data-theme="light">Light</button>
|
||||
<button class="theme-btn" data-theme="dark">Dark</button>
|
||||
<button class="theme-btn" data-theme="mono">Mono</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
lightBtn = document.querySelector('[data-theme="light"]');
|
||||
darkBtn = document.querySelector('[data-theme="dark"]');
|
||||
monoBtn = document.querySelector('[data-theme="mono"]');
|
||||
});
|
||||
|
||||
it('initThemeSwitcher sets active class based on saved theme on load', () => {
|
||||
vi.spyOn(storage, 'getTheme').mockReturnValue('dark');
|
||||
|
||||
initThemeSwitcher();
|
||||
|
||||
expect(storage.getTheme).toHaveBeenCalled();
|
||||
expect(darkBtn.classList.contains('active')).toBe(true);
|
||||
expect(lightBtn.classList.contains('active')).toBe(false);
|
||||
expect(monoBtn.classList.contains('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('initThemeSwitcher defaults to light theme if no theme is saved', () => {
|
||||
vi.spyOn(storage, 'getTheme').mockReturnValue(null);
|
||||
|
||||
initThemeSwitcher();
|
||||
|
||||
expect(lightBtn.classList.contains('active')).toBe(true);
|
||||
expect(darkBtn.classList.contains('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('clicking theme button switches the document theme and persists choice', () => {
|
||||
initThemeSwitcher();
|
||||
|
||||
// Initial active button should be light
|
||||
expect(lightBtn.classList.contains('active')).toBe(true);
|
||||
|
||||
// Click Dark
|
||||
darkBtn.click();
|
||||
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
expect(storage.saveTheme).toHaveBeenCalledWith('dark');
|
||||
expect(darkBtn.classList.contains('active')).toBe(true);
|
||||
expect(lightBtn.classList.contains('active')).toBe(false);
|
||||
|
||||
// Click Mono
|
||||
monoBtn.click();
|
||||
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('mono');
|
||||
expect(storage.saveTheme).toHaveBeenCalledWith('mono');
|
||||
expect(monoBtn.classList.contains('active')).toBe(true);
|
||||
expect(darkBtn.classList.contains('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('setTheme directly sets document attribute and updates button classes if present', () => {
|
||||
initThemeSwitcher(); // binds buttons
|
||||
|
||||
setTheme('mono');
|
||||
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('mono');
|
||||
expect(storage.saveTheme).toHaveBeenCalledWith('mono');
|
||||
expect(monoBtn.classList.contains('active')).toBe(true);
|
||||
expect(lightBtn.classList.contains('active')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,57 @@ describe('ombiHelpers', () => {
|
||||
};
|
||||
expect(extractRequestedUser(req)).toBe('');
|
||||
});
|
||||
|
||||
it('returns userName from nested user object', () => {
|
||||
const req = { user: { userName: 'user_val' } };
|
||||
expect(extractRequestedUser(req)).toBe('user_val');
|
||||
});
|
||||
|
||||
it('returns alias from nested requestedBy object', () => {
|
||||
const req = { requestedBy: { alias: 'req_alias' } };
|
||||
expect(extractRequestedUser(req)).toBe('req_alias');
|
||||
});
|
||||
|
||||
it('returns normalizedUserName from nested ombiUser object', () => {
|
||||
const req = { ombiUser: { normalizedUserName: 'norm_ombi' } };
|
||||
expect(extractRequestedUser(req)).toBe('norm_ombi');
|
||||
});
|
||||
|
||||
it('returns userAlias from nested requestedByUser object', () => {
|
||||
const req = { requestedByUser: { userAlias: 'alias_user' } };
|
||||
expect(extractRequestedUser(req)).toBe('alias_user');
|
||||
});
|
||||
|
||||
it('returns username from a string source value', () => {
|
||||
const req = { requestedBy: 'direct_string' };
|
||||
expect(extractRequestedUser(req)).toBe('direct_string');
|
||||
});
|
||||
|
||||
it('returns username from root fallbacks (requestedByUsername, requester, requestedByEmail)', () => {
|
||||
expect(extractRequestedUser({ requestedByUsername: 'user_uname' })).toBe('user_uname');
|
||||
expect(extractRequestedUser({ requester: 'req_val' })).toBe('req_val');
|
||||
expect(extractRequestedUser({ requestedByEmail: 'test@email.com' })).toBe('test@email.com');
|
||||
});
|
||||
|
||||
it('recursively extracts user from seasons array requests', () => {
|
||||
const req = {
|
||||
seasons: [
|
||||
{},
|
||||
{ requestedUser: { alias: 'season_user' } }
|
||||
]
|
||||
};
|
||||
expect(extractRequestedUser(req)).toBe('season_user');
|
||||
});
|
||||
|
||||
it('recursively extracts user from childRequests array', () => {
|
||||
const req = {
|
||||
childRequests: [
|
||||
{},
|
||||
{ user: { userName: 'child_user' } }
|
||||
]
|
||||
};
|
||||
expect(extractRequestedUser(req)).toBe('child_user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterRequestsByUser', () => {
|
||||
|
||||
Reference in New Issue
Block a user