mirror of
https://github.com/mediacms-io/mediacms.git
synced 2026-01-20 07:12:58 -05:00
feat: frontend unit tests
This commit is contained in:
@@ -1,12 +1,28 @@
|
||||
{
|
||||
"presets": [
|
||||
"@babel/react", ["@babel/env", {
|
||||
"modules": false,
|
||||
"useBuiltIns": "usage",
|
||||
"corejs": 3,
|
||||
"targets": {
|
||||
"browsers": ["defaults"]
|
||||
}
|
||||
}]
|
||||
]
|
||||
}
|
||||
"presets": [
|
||||
"@babel/react",
|
||||
[
|
||||
"@babel/env",
|
||||
{
|
||||
"modules": false,
|
||||
"useBuiltIns": "usage",
|
||||
"corejs": 3,
|
||||
"targets": {
|
||||
"browsers": ["defaults"]
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"env": {
|
||||
"test": {
|
||||
"presets": [
|
||||
[
|
||||
"@babel/env",
|
||||
{
|
||||
"targets": { "node": "current" }
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,3 +27,39 @@ Open in browser: [http://localhost:8088](http://localhost:8088)
|
||||
Generates the folder "**_frontend/dist_**".
|
||||
|
||||
Copy folders and files from "**_frontend/dist/static_**" into "**_static_**".
|
||||
|
||||
---
|
||||
|
||||
### Test Scripts
|
||||
|
||||
#### test
|
||||
|
||||
Run all unit tests once.
|
||||
|
||||
```sh
|
||||
npm run test
|
||||
```
|
||||
|
||||
#### test-watch
|
||||
|
||||
Run tests in watch mode for development.
|
||||
|
||||
```sh
|
||||
npm run test-watch
|
||||
```
|
||||
|
||||
#### test-coverage
|
||||
|
||||
Run tests with coverage reporting in `./coverage` folder.
|
||||
|
||||
```sh
|
||||
npm run test-coverage
|
||||
```
|
||||
|
||||
#### test-coverage-watch
|
||||
|
||||
Run tests with coverage in watch mode.
|
||||
|
||||
```sh
|
||||
npm run test-coverage-watch
|
||||
```
|
||||
|
||||
9
frontend/jest.config.js
Normal file
9
frontend/jest.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/** @type {import("jest").Config} **/
|
||||
module.exports = {
|
||||
testEnvironment: 'jsdom',
|
||||
transform: {
|
||||
'^.+\\.tsx?$': 'ts-jest',
|
||||
'^.+\\.jsx?$': 'babel-jest',
|
||||
},
|
||||
collectCoverageFrom: ['src/**'],
|
||||
};
|
||||
@@ -1,57 +1,69 @@
|
||||
{
|
||||
"name": "mediacms-frontend",
|
||||
"version": "0.9.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"license": "",
|
||||
"keywords": [],
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "mediacms-scripts development --config=./config/mediacms.config.js --host=0.0.0.0 --port=8088",
|
||||
"dist": "mediacms-scripts rimraf ./dist && mediacms-scripts build --config=./config/mediacms.config.js --env=dist"
|
||||
},
|
||||
"browserslist": [
|
||||
"cover 99.5%"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.9",
|
||||
"@babel/preset-env": "^7.26.9",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"babel-loader": "^10.0.0",
|
||||
"compass-mixins": "^0.12.12",
|
||||
"copy-webpack-plugin": "^13.0.0",
|
||||
"core-js": "^3.41.0",
|
||||
"css-loader": "^7.1.2",
|
||||
"dotenv": "^16.4.7",
|
||||
"ejs": "^3.1.10",
|
||||
"ejs-compiled-loader": "^3.1.0",
|
||||
"mediacms-scripts": "file:packages/scripts",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"prettier": "^3.5.3",
|
||||
"prop-types": "^15.8.1",
|
||||
"sass": "^1.85.1",
|
||||
"sass-loader": "^16.0.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"typescript": "^5.8.2",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^5.98.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-pdf-viewer/core": "^3.9.0",
|
||||
"@react-pdf-viewer/default-layout": "^3.9.0",
|
||||
"axios": "^1.8.2",
|
||||
"flux": "^4.0.4",
|
||||
"normalize.css": "^8.0.1",
|
||||
"pdfjs-dist": "3.4.120",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-mentions": "^4.3.1",
|
||||
"sortablejs": "^1.13.0",
|
||||
"timeago.js": "^4.0.2",
|
||||
"url-parse": "^1.5.10"
|
||||
}
|
||||
"name": "mediacms-frontend",
|
||||
"version": "0.9.2",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"license": "",
|
||||
"keywords": [],
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "mediacms-scripts development --config=./config/mediacms.config.js --host=0.0.0.0 --port=8088",
|
||||
"dist": "mediacms-scripts rimraf ./dist && mediacms-scripts build --config=./config/mediacms.config.js --env=dist",
|
||||
"test": "jest",
|
||||
"test-coverage": "npx rimraf ./coverage && jest --coverage",
|
||||
"test-coverage-watch": "npm run test-coverage -- --watchAll",
|
||||
"test-watch": "jest --watch"
|
||||
},
|
||||
"browserslist": [
|
||||
"cover 99.5%"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.9",
|
||||
"@babel/preset-env": "^7.26.9",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@types/flux": "^3.1.15",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/url-parse": "^1.4.11",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"babel-jest": "^30.2.0",
|
||||
"babel-loader": "^10.0.0",
|
||||
"compass-mixins": "^0.12.12",
|
||||
"copy-webpack-plugin": "^13.0.0",
|
||||
"core-js": "^3.41.0",
|
||||
"css-loader": "^7.1.2",
|
||||
"dotenv": "^16.4.7",
|
||||
"ejs": "^3.1.10",
|
||||
"ejs-compiled-loader": "^3.1.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"jsdom": "^27.3.0",
|
||||
"mediacms-scripts": "file:packages/scripts",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"prettier": "^3.5.3",
|
||||
"prop-types": "^15.8.1",
|
||||
"sass": "^1.85.1",
|
||||
"sass-loader": "^16.0.5",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"typescript": "^5.9.3",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^5.98.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-pdf-viewer/core": "^3.9.0",
|
||||
"@react-pdf-viewer/default-layout": "^3.9.0",
|
||||
"axios": "^1.8.2",
|
||||
"flux": "^4.0.4",
|
||||
"normalize.css": "^8.0.1",
|
||||
"pdfjs-dist": "3.4.120",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-mentions": "^4.3.1",
|
||||
"sortablejs": "^1.13.0",
|
||||
"timeago.js": "^4.0.2",
|
||||
"url-parse": "^1.5.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
import { logErrorAndReturnError } from './errors';
|
||||
import { isPositiveInteger, isPositiveIntegerOrZero } from './math';
|
||||
|
||||
export const PositiveIntegerOrZero = (function () {
|
||||
return function (obj, key, comp) {
|
||||
return void 0 === obj[key] || isPositiveIntegerOrZero(obj[key])
|
||||
? null
|
||||
: logErrorAndReturnError([
|
||||
'Invalid prop `' +
|
||||
key +
|
||||
'` of type `' +
|
||||
typeof obj[key] +
|
||||
'` supplied to `' +
|
||||
(comp || 'N/A') +
|
||||
'`, expected `positive integer or zero` (' +
|
||||
obj[key] +
|
||||
').',
|
||||
]);
|
||||
};
|
||||
const isPositiveIntegerOrZero = (x) => x === Math.trunc(x) && x >= 0;
|
||||
|
||||
return function (obj, key, comp) {
|
||||
return void 0 === obj[key] || isPositiveIntegerOrZero(obj[key])
|
||||
? null
|
||||
: logErrorAndReturnError([
|
||||
'Invalid prop `' +
|
||||
key +
|
||||
'` of type `' +
|
||||
typeof obj[key] +
|
||||
'` supplied to `' +
|
||||
(comp || 'N/A') +
|
||||
'`, expected `positive integer or zero` (' +
|
||||
obj[key] +
|
||||
').',
|
||||
]);
|
||||
};
|
||||
})();
|
||||
|
||||
export const PositiveInteger = (function () {
|
||||
return function (obj, key, comp) {
|
||||
return void 0 === obj[key] || isPositiveInteger(obj[key])
|
||||
? null
|
||||
: logErrorAndReturnError([
|
||||
'Invalid prop `' +
|
||||
key +
|
||||
'` of type `' +
|
||||
typeof obj[key] +
|
||||
'` supplied to `' +
|
||||
(comp || 'N/A') +
|
||||
'`, expected `positive integer` (' +
|
||||
obj[key] +
|
||||
').',
|
||||
]);
|
||||
};
|
||||
const isPositiveInteger = (x) => x === Math.trunc(x) && x > 0;
|
||||
|
||||
return function (obj, key, comp) {
|
||||
return void 0 === obj[key] || isPositiveInteger(obj[key])
|
||||
? null
|
||||
: logErrorAndReturnError([
|
||||
'Invalid prop `' +
|
||||
key +
|
||||
'` of type `' +
|
||||
typeof obj[key] +
|
||||
'` supplied to `' +
|
||||
(comp || 'N/A') +
|
||||
'`, expected `positive integer` (' +
|
||||
obj[key] +
|
||||
').',
|
||||
]);
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
// check templates/config/installation/translations.html for more
|
||||
|
||||
export function replaceString(string) {
|
||||
for (const key in window.REPLACEMENTS) {
|
||||
string = string.replace(key, window.REPLACEMENTS[key]);
|
||||
export function replaceString(word) {
|
||||
if (!window.REPLACEMENTS) {
|
||||
return word;
|
||||
}
|
||||
return string;
|
||||
|
||||
let result = word;
|
||||
|
||||
for (const [search, replacement] of Object.entries(window.REPLACEMENTS)) {
|
||||
result = result.split(search).join(replacement);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ export async function getRequest(url, sync, callback, errorCallback) {
|
||||
};
|
||||
|
||||
function responseHandler(result) {
|
||||
if (callback instanceof Function) {
|
||||
if (callback instanceof Function || typeof callback === 'function') {
|
||||
callback(result);
|
||||
}
|
||||
}
|
||||
|
||||
function errorHandler(error) {
|
||||
if (errorCallback instanceof Function) {
|
||||
if (errorCallback instanceof Function || typeof errorCallback === 'function') {
|
||||
let err = error;
|
||||
if (void 0 === error.response) {
|
||||
err = {
|
||||
@@ -58,13 +58,13 @@ export async function postRequest(url, postData, configData, sync, callback, err
|
||||
postData = postData || {};
|
||||
|
||||
function responseHandler(result) {
|
||||
if (callback instanceof Function) {
|
||||
if (callback instanceof Function || typeof callback === 'function') {
|
||||
callback(result);
|
||||
}
|
||||
}
|
||||
|
||||
function errorHandler(error) {
|
||||
if (errorCallback instanceof Function) {
|
||||
if (errorCallback instanceof Function || typeof errorCallback === 'function') {
|
||||
errorCallback(error);
|
||||
}
|
||||
}
|
||||
@@ -84,13 +84,13 @@ export async function putRequest(url, putData, configData, sync, callback, error
|
||||
putData = putData || {};
|
||||
|
||||
function responseHandler(result) {
|
||||
if (callback instanceof Function) {
|
||||
if (callback instanceof Function || typeof callback === 'function') {
|
||||
callback(result);
|
||||
}
|
||||
}
|
||||
|
||||
function errorHandler(error) {
|
||||
if (errorCallback instanceof Function) {
|
||||
if (errorCallback instanceof Function || typeof errorCallback === 'function') {
|
||||
errorCallback(error);
|
||||
}
|
||||
}
|
||||
@@ -110,13 +110,13 @@ export async function deleteRequest(url, configData, sync, callback, errorCallba
|
||||
configData = configData || {};
|
||||
|
||||
function responseHandler(result) {
|
||||
if (callback instanceof Function) {
|
||||
if (callback instanceof Function || typeof callback === 'function') {
|
||||
callback(result);
|
||||
}
|
||||
}
|
||||
|
||||
function errorHandler(error) {
|
||||
if (errorCallback instanceof Function) {
|
||||
if (errorCallback instanceof Function || typeof errorCallback === 'function') {
|
||||
errorCallback(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
// check templates/config/installation/translations.html for more
|
||||
|
||||
export function translateString(string) {
|
||||
if (window.TRANSLATION && window.TRANSLATION[string]) {
|
||||
return window.TRANSLATION[string];
|
||||
} else {
|
||||
return string;
|
||||
}
|
||||
export function translateString(str) {
|
||||
return window.TRANSLATION?.[str] ?? str;
|
||||
}
|
||||
|
||||
56
frontend/tests/utils/helpers/csrfToken.test.ts
Normal file
56
frontend/tests/utils/helpers/csrfToken.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { csrfToken } from '../../../src/static/js/utils/helpers/csrfToken';
|
||||
|
||||
const setupDocumentCookie = () => {
|
||||
if (typeof document === 'undefined') {
|
||||
globalThis.document = { cookie: '' } as unknown as Document;
|
||||
}
|
||||
};
|
||||
|
||||
const setDocumentCookie = (value: string) => {
|
||||
if (typeof document !== 'undefined') {
|
||||
Object.defineProperty(document, 'cookie', { value, writable: true, configurable: true });
|
||||
}
|
||||
};
|
||||
|
||||
describe('js/utils/helpers', () => {
|
||||
describe('csrfToken', () => {
|
||||
const originalCookie = document.cookie;
|
||||
|
||||
beforeAll(() => {
|
||||
// Initialize document environment
|
||||
setupDocumentCookie();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original cookie string
|
||||
setDocumentCookie(originalCookie);
|
||||
});
|
||||
|
||||
test('Returns null when document.cookie is empty', () => {
|
||||
setDocumentCookie('');
|
||||
expect(csrfToken()).toBeNull();
|
||||
});
|
||||
|
||||
test('Returns null when csrftoken is not present', () => {
|
||||
setDocumentCookie('sessionid=abc; theme=dark');
|
||||
expect(csrfToken()).toBeNull();
|
||||
});
|
||||
|
||||
test('Finds and decodes the csrftoken cookie value', () => {
|
||||
const token = encodeURIComponent('a b+c%20');
|
||||
setDocumentCookie(`sessionid=abc; csrftoken=${token}; theme=dark`);
|
||||
expect(csrfToken()).toBe('a b+c%20');
|
||||
});
|
||||
|
||||
test('Ignores leading spaces and matches exact prefix csrftoken=', () => {
|
||||
setDocumentCookie(' sessionid=xyz; csrftoken=secure123; other=value');
|
||||
expect(csrfToken()).toBe('secure123');
|
||||
});
|
||||
|
||||
test('Stops scanning once csrftoken is found', () => {
|
||||
// Ensure csrftoken occurs before other long tail cookies
|
||||
setDocumentCookie('csrftoken=first; a=1; b=2; c=3; d=4; e=5');
|
||||
expect(csrfToken()).toBe('first');
|
||||
});
|
||||
});
|
||||
});
|
||||
220
frontend/tests/utils/helpers/dom.test.ts
Normal file
220
frontend/tests/utils/helpers/dom.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
supportsSvgAsImg,
|
||||
removeClassname,
|
||||
addClassname,
|
||||
hasClassname,
|
||||
BrowserEvents,
|
||||
} from '../../../src/static/js/utils/helpers/dom';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
mozRequestAnimationFrame?: Window['requestAnimationFrame'];
|
||||
webkitRequestAnimationFrame?: Window['requestAnimationFrame'];
|
||||
msRequestAnimationFrame?: Window['requestAnimationFrame'];
|
||||
mozCancelAnimationFrame?: Window['cancelAnimationFrame'];
|
||||
}
|
||||
}
|
||||
|
||||
describe('js/utils/helpers', () => {
|
||||
describe('dom', () => {
|
||||
describe('supportsSvgAsImg', () => {
|
||||
test('Delegates to document.implementation.hasFeature', () => {
|
||||
const spy = jest.spyOn(document.implementation as any, 'hasFeature').mockReturnValueOnce(true);
|
||||
expect(supportsSvgAsImg()).toBe(true);
|
||||
expect(spy).toHaveBeenCalledWith('http://www.w3.org/TR/SVG11/feature#Image', '1.1');
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('Returns false when feature detection fails', () => {
|
||||
const spy = jest.spyOn(document.implementation as any, 'hasFeature').mockReturnValueOnce(false);
|
||||
expect(supportsSvgAsImg()).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BrowserEvents', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(document, 'addEventListener').mockClear();
|
||||
jest.spyOn(window, 'addEventListener').mockClear();
|
||||
document.addEventListener = jest.fn();
|
||||
window.addEventListener = jest.fn();
|
||||
});
|
||||
|
||||
test('Registers global listeners on construction and invokes callbacks on events', () => {
|
||||
const be = BrowserEvents();
|
||||
|
||||
const visCb = jest.fn();
|
||||
const resizeCb = jest.fn();
|
||||
const scrollCb = jest.fn();
|
||||
|
||||
// Register callbacks
|
||||
be.doc(visCb);
|
||||
be.win(resizeCb, scrollCb);
|
||||
|
||||
// Capture the callback passed to addEventListener for each event
|
||||
const docHandler = (document.addEventListener as jest.Mock).mock.calls.find(
|
||||
(c) => c[0] === 'visibilitychange'
|
||||
)?.[1] as Function;
|
||||
|
||||
const resizeHandler = (window.addEventListener as jest.Mock).mock.calls.find(
|
||||
(c) => c[0] === 'resize'
|
||||
)?.[1] as Function;
|
||||
|
||||
const scrollHandler = (window.addEventListener as jest.Mock).mock.calls.find(
|
||||
(c) => c[0] === 'scroll'
|
||||
)?.[1] as Function;
|
||||
|
||||
// Fire handlers to simulate events
|
||||
docHandler();
|
||||
resizeHandler();
|
||||
scrollHandler();
|
||||
|
||||
expect(visCb).toHaveBeenCalledTimes(1);
|
||||
expect(resizeCb).toHaveBeenCalledTimes(1);
|
||||
expect(scrollCb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
test('Does not register non-function callbacks', () => {
|
||||
const be = BrowserEvents();
|
||||
|
||||
be.win('not-a-fn', null);
|
||||
be.doc(undefined);
|
||||
|
||||
// Should still have registered the listeners on construction
|
||||
expect(
|
||||
(document.addEventListener as jest.Mock).mock.calls.some((c) => c[0] === 'visibilitychange')
|
||||
).toBe(true);
|
||||
expect((window.addEventListener as jest.Mock).mock.calls.some((c) => c[0] === 'resize')).toBe(true);
|
||||
expect((window.addEventListener as jest.Mock).mock.calls.some((c) => c[0] === 'scroll')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BrowserEvents (edge cases)', () => {
|
||||
beforeEach(() => {
|
||||
(document.addEventListener as jest.Mock).mockClear();
|
||||
(window.addEventListener as jest.Mock).mockClear();
|
||||
document.addEventListener = jest.fn();
|
||||
window.addEventListener = jest.fn();
|
||||
});
|
||||
|
||||
test('Multiple callbacks are invoked in order for each event type', () => {
|
||||
const be = BrowserEvents();
|
||||
|
||||
const v1 = jest.fn();
|
||||
const v2 = jest.fn();
|
||||
const r1 = jest.fn();
|
||||
const r2 = jest.fn();
|
||||
const s1 = jest.fn();
|
||||
const s2 = jest.fn();
|
||||
|
||||
be.doc(v1);
|
||||
be.doc(v2);
|
||||
be.win(r1, s1);
|
||||
be.win(r2, s2);
|
||||
|
||||
const docHandler = (document.addEventListener as jest.Mock).mock.calls.find(
|
||||
(c) => c[0] === 'visibilitychange'
|
||||
)?.[1] as Function;
|
||||
|
||||
const resizeHandler = (window.addEventListener as jest.Mock).mock.calls.find(
|
||||
(c) => c[0] === 'resize'
|
||||
)?.[1] as Function;
|
||||
|
||||
const scrollHandler = (window.addEventListener as jest.Mock).mock.calls.find(
|
||||
(c) => c[0] === 'scroll'
|
||||
)?.[1] as Function;
|
||||
|
||||
// Fire events twice to ensure each call triggers callbacks once per firing
|
||||
docHandler();
|
||||
resizeHandler();
|
||||
scrollHandler();
|
||||
|
||||
docHandler();
|
||||
resizeHandler();
|
||||
scrollHandler();
|
||||
|
||||
expect(v1).toHaveBeenCalledTimes(2);
|
||||
expect(v2).toHaveBeenCalledTimes(2);
|
||||
expect(r1).toHaveBeenCalledTimes(2);
|
||||
expect(r2).toHaveBeenCalledTimes(2);
|
||||
expect(s1).toHaveBeenCalledTimes(2);
|
||||
expect(s2).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Ensure order of invocation within each firing respects registration order
|
||||
// Jest mock call order grows monotonically; validate the first calls were in the expected sequence
|
||||
expect(v1.mock.invocationCallOrder[0]).toBeLessThan(v2.mock.invocationCallOrder[0]);
|
||||
expect(r1.mock.invocationCallOrder[0]).toBeLessThan(r2.mock.invocationCallOrder[0]);
|
||||
expect(s1.mock.invocationCallOrder[0]).toBeLessThan(s2.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
// @todo: Check again this behavior
|
||||
test('Ignores non-function values without throwing and still registers listeners once', () => {
|
||||
const be = BrowserEvents();
|
||||
|
||||
be.doc('noop');
|
||||
be.win(null, undefined);
|
||||
|
||||
const docCount = (document.addEventListener as jest.Mock).mock.calls.filter(
|
||||
(c) => c[0] === 'visibilitychange'
|
||||
).length;
|
||||
const resizeCount = (window.addEventListener as jest.Mock).mock.calls.filter(
|
||||
(c) => c[0] === 'resize'
|
||||
).length;
|
||||
const scrollCount = (window.addEventListener as jest.Mock).mock.calls.filter(
|
||||
(c) => c[0] === 'scroll'
|
||||
).length;
|
||||
|
||||
expect(docCount).toBe(1);
|
||||
expect(resizeCount).toBe(1);
|
||||
expect(scrollCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classname helpers', () => {
|
||||
test('addClassname uses classList.add when available', () => {
|
||||
const el = document.createElement('div') as any;
|
||||
const mockAdd = jest.fn();
|
||||
el.classList.add = mockAdd;
|
||||
|
||||
addClassname(el, 'active');
|
||||
expect(mockAdd).toHaveBeenCalledWith('active');
|
||||
});
|
||||
|
||||
test('removeClassname uses classList.remove when available', () => {
|
||||
const el = document.createElement('div') as any;
|
||||
const mockRemove = jest.fn();
|
||||
el.classList.remove = mockRemove;
|
||||
removeClassname(el, 'active');
|
||||
expect(mockRemove).toHaveBeenCalledWith('active');
|
||||
});
|
||||
|
||||
test('addClassname fallback appends class to className', () => {
|
||||
const el = document.createElement('div') as any;
|
||||
el.className = 'one';
|
||||
// Remove classList to test fallback behavior
|
||||
delete el.classList;
|
||||
addClassname(el, 'two');
|
||||
expect(el.className).toBe('one two');
|
||||
});
|
||||
|
||||
test('removeClassname fallback removes class via regex', () => {
|
||||
const el = document.createElement('div') as any;
|
||||
el.className = 'one two three two';
|
||||
// Remove classList to test fallback behavior
|
||||
delete el.classList;
|
||||
removeClassname(el, 'two');
|
||||
// The regex replacement may leave extra spaces
|
||||
expect(el.className.replaceAll(/\s+/g, ' ').trim()).toBe('one three');
|
||||
});
|
||||
|
||||
test('hasClassname checks for exact class match boundaries', () => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'one two-three';
|
||||
expect(hasClassname(el, 'one')).toBe(true);
|
||||
expect(hasClassname(el, 'two')).toBe(false); // Should not match within two-three
|
||||
expect(hasClassname(el, 'two-three')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
47
frontend/tests/utils/helpers/errors.test.ts
Normal file
47
frontend/tests/utils/helpers/errors.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// Mock the './log' module used by errors.ts to capture calls without console side effects
|
||||
jest.mock('../../../src/static/js/utils/helpers/log', () => ({ error: jest.fn(), warn: jest.fn() }));
|
||||
|
||||
import { logErrorAndReturnError, logWarningAndReturnError } from '../../../src/static/js/utils/helpers/errors';
|
||||
import { error as mockedError, warn as mockedWarn } from '../../../src/static/js/utils/helpers/log';
|
||||
|
||||
describe('js/utils/helpers', () => {
|
||||
describe('errors', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('logErrorAndReturnError returns Error with first message and logs with error', () => {
|
||||
const messages = ['Primary msg', 'details', 'more'];
|
||||
const err = logErrorAndReturnError(messages);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toBe('Primary msg');
|
||||
expect(mockedError).toHaveBeenCalledTimes(1);
|
||||
expect(mockedError).toHaveBeenCalledWith(...messages);
|
||||
});
|
||||
|
||||
test('logWarningAndReturnError returns Error with first message and logs with warn', () => {
|
||||
const messages = ['Primary msg', 'details', 'more'];
|
||||
const err = logWarningAndReturnError(messages);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toBe('Primary msg');
|
||||
expect(mockedWarn).toHaveBeenCalledTimes(1);
|
||||
expect(mockedWarn).toHaveBeenCalledWith(...messages);
|
||||
});
|
||||
|
||||
test('Handles empty array creating an Error with undefined message and logs called with no args', () => {
|
||||
const messages: string[] = [];
|
||||
|
||||
const err1 = logErrorAndReturnError(messages);
|
||||
expect(err1).toBeInstanceOf(Error);
|
||||
expect(err1.message).toBe('');
|
||||
expect(mockedError).toHaveBeenCalledWith('');
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
const err2 = logWarningAndReturnError(messages);
|
||||
expect(err2).toBeInstanceOf(Error);
|
||||
expect(err2.message).toBe('');
|
||||
expect(mockedWarn).toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
});
|
||||
44
frontend/tests/utils/helpers/exportStore.test.ts
Normal file
44
frontend/tests/utils/helpers/exportStore.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Mock the dispatcher module used by exportStore
|
||||
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ register: jest.fn() }));
|
||||
|
||||
import exportStore from '../../../src/static/js/utils/helpers/exportStore';
|
||||
|
||||
// Re-import the mocked dispatcher for assertions
|
||||
import * as dispatcher from '../../../src/static/js/utils/dispatcher';
|
||||
|
||||
describe('js/utils/helpers', () => {
|
||||
describe('exportStore', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('Registers store handler with dispatcher and binds context', () => {
|
||||
const ctx: { value: number; inc?: () => void } = { value: 0 };
|
||||
const handlerName = 'inc';
|
||||
const handler = function (this: typeof ctx) {
|
||||
this.value += 1;
|
||||
};
|
||||
ctx[handlerName] = handler as any;
|
||||
|
||||
const result = exportStore(ctx, handlerName);
|
||||
|
||||
// returns the same store instance
|
||||
expect(result).toBe(ctx);
|
||||
|
||||
// Ensure dispatcher.register was called once with a bound function
|
||||
expect((dispatcher as any).register).toHaveBeenCalledTimes(1);
|
||||
const registeredFn = (dispatcher as any).register.mock.calls[0][0] as Function;
|
||||
expect(typeof registeredFn).toBe('function');
|
||||
|
||||
// Verify the registered function is bound to the store context
|
||||
registeredFn();
|
||||
expect(ctx.value).toBe(1);
|
||||
});
|
||||
|
||||
test('Throws if handler name does not exist on store', () => {
|
||||
const store: any = {};
|
||||
// Accessing store[handler] would be undefined; calling .bind on undefined would throw
|
||||
expect(() => exportStore(store, 'missing')).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
23
frontend/tests/utils/helpers/formatInnerLink.test.ts
Normal file
23
frontend/tests/utils/helpers/formatInnerLink.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { formatInnerLink } from '../../../src/static/js/utils/helpers/formatInnerLink';
|
||||
|
||||
describe('js/utils/helpers', () => {
|
||||
describe('formatInnerLink', () => {
|
||||
test('Returns the same absolute URL unchanged', () => {
|
||||
const url = 'https://example.com/path?x=1#hash';
|
||||
const base = 'https://base.example.org';
|
||||
expect(formatInnerLink(url, base)).toBe(url);
|
||||
});
|
||||
|
||||
test('Constructs absolute URL from relative path with leading slash', () => {
|
||||
const url = '/images/picture.png';
|
||||
const base = 'https://media.example.com';
|
||||
expect(formatInnerLink(url, base)).toBe('https://media.example.com/images/picture.png');
|
||||
});
|
||||
|
||||
test('Constructs absolute URL from relative path without leading slash', () => {
|
||||
const url = 'assets/file.txt';
|
||||
const base = 'https://cdn.example.com';
|
||||
expect(formatInnerLink(url, base)).toBe('https://cdn.example.com/assets/file.txt');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { formatManagementTableDate } from '../../../src/static/js/utils/helpers/formatManagementTableDate';
|
||||
|
||||
describe('js/utils/helpers', () => {
|
||||
describe('formatManagementTableDate', () => {
|
||||
test('Formats date with zero-padded time components', () => {
|
||||
const d = new Date(2021, 0, 5, 3, 7, 9); // Jan=0, day 5, 03:07:09
|
||||
expect(formatManagementTableDate(d)).toBe('Jan 5, 2021 03:07:09');
|
||||
});
|
||||
|
||||
test('Formats date with double-digit time components and month abbreviation', () => {
|
||||
const d = new Date(1999, 11, 31, 23, 59, 58); // Dec=11
|
||||
expect(formatManagementTableDate(d)).toBe('Dec 31, 1999 23:59:58');
|
||||
});
|
||||
});
|
||||
});
|
||||
106
frontend/tests/utils/helpers/formatViewsNumber.test.ts
Normal file
106
frontend/tests/utils/helpers/formatViewsNumber.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import formatViewsNumber from '../../../src/static/js/utils/helpers/formatViewsNumber';
|
||||
|
||||
describe('js/utils/helpers', () => {
|
||||
describe('formatViewsNumber', () => {
|
||||
describe('fullNumber = false (default compact formatting)', () => {
|
||||
test('Formats values < 1,000 without suffix and with correct rounding', () => {
|
||||
expect(formatViewsNumber(0)).toBe('0');
|
||||
expect(formatViewsNumber(9)).toBe('9');
|
||||
expect(formatViewsNumber(12)).toBe('12');
|
||||
expect(formatViewsNumber(999)).toBe('999');
|
||||
});
|
||||
|
||||
test('Formats thousands to K with decimals for < 10K and none for >= 10K', () => {
|
||||
expect(formatViewsNumber(1000)).toBe('1K');
|
||||
expect(formatViewsNumber(1500)).toBe('1.5K');
|
||||
expect(formatViewsNumber(1499)).toBe('1.5K'); // rounds to 1 decimal
|
||||
expect(formatViewsNumber(10_000)).toBe('10K');
|
||||
expect(formatViewsNumber(10_400)).toBe('10K');
|
||||
expect(formatViewsNumber(10_500)).toBe('11K'); // rounds to nearest whole
|
||||
expect(formatViewsNumber(99_900)).toBe('100K'); // rounding up
|
||||
});
|
||||
|
||||
test('Formats millions to M with decimals for < 10M and none for >= 10M', () => {
|
||||
expect(formatViewsNumber(1_000_000)).toBe('1M');
|
||||
expect(formatViewsNumber(1_200_000)).toBe('1.2M');
|
||||
expect(formatViewsNumber(9_440_000)).toBe('9.4M');
|
||||
expect(formatViewsNumber(9_960_000)).toBe('10M'); // rounds to whole when >= 10M threshold after rounding
|
||||
expect(formatViewsNumber(10_000_000)).toBe('10M');
|
||||
});
|
||||
|
||||
test('Formats billions and trillions correctly', () => {
|
||||
expect(formatViewsNumber(1_000_000_000)).toBe('1B');
|
||||
expect(formatViewsNumber(1_500_000_000)).toBe('1.5B');
|
||||
expect(formatViewsNumber(10_000_000_000)).toBe('10B');
|
||||
expect(formatViewsNumber(1_000_000_000_000)).toBe('1T');
|
||||
expect(formatViewsNumber(1_230_000_000_000)).toBe('1.2T');
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
test('Beyond last unit keeps using the last unit with scaling', () => {
|
||||
// Implementation scales beyond units by increasing the value so that the last unit remains applicable
|
||||
// Here, expect a number in T with rounding behavior similar to others
|
||||
expect(formatViewsNumber(9_999_999_999_999)).toBe('10T');
|
||||
// With current rounding rules, this value rounds to whole trillions
|
||||
expect(formatViewsNumber(12_345_678_901_234)).toBe('12T');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fullNumber = true (locale formatting)', () => {
|
||||
test('Returns locale string representation of the full number', () => {
|
||||
// Use a fixed locale independent assertion by stripping non-digits except separators that could vary.
|
||||
// However, to avoid locale variance, check that it equals toLocaleString directly.
|
||||
const vals = [0, 12, 999, 1000, 1234567, 9876543210];
|
||||
for (const v of vals) {
|
||||
expect(formatViewsNumber(v, true)).toBe(v.toLocaleString());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Additional edge cases and robustness', () => {
|
||||
test('Handles negative values without unit suffix (no scaling applied)', () => {
|
||||
expect(formatViewsNumber(-999)).toBe('-999');
|
||||
expect(formatViewsNumber(-1000)).toBe('-1000');
|
||||
expect(formatViewsNumber(-1500)).toBe('-1500');
|
||||
expect(formatViewsNumber(-10_500)).toBe('-10500');
|
||||
expect(formatViewsNumber(-1_230_000_000_000)).toBe('-1230000000000');
|
||||
});
|
||||
|
||||
test('Handles non-integer inputs with correct rounding in compact mode', () => {
|
||||
expect(formatViewsNumber(1499.5)).toBe('1.5K');
|
||||
expect(formatViewsNumber(999.9)).toBe('1000');
|
||||
expect(formatViewsNumber(10_499.5)).toBe('10K');
|
||||
expect(formatViewsNumber(10_500.49)).toBe('11K');
|
||||
expect(formatViewsNumber(9_440_000.49)).toBe('9.4M');
|
||||
});
|
||||
|
||||
test('Respects locale formatting in fullNumber mode', () => {
|
||||
const values = [1_234_567, -2_345_678, 0, 10_000, 99_999_999];
|
||||
for (const v of values) {
|
||||
expect(formatViewsNumber(v, true)).toBe(v.toLocaleString());
|
||||
}
|
||||
});
|
||||
|
||||
test('Caps unit at trillions for extremely large numbers', () => {
|
||||
expect(formatViewsNumber(9_999_999_999_999)).toBe('10T');
|
||||
expect(formatViewsNumber(12_345_678_901_234)).toBe('12T');
|
||||
expect(formatViewsNumber(987_654_321_000_000)).toBe('988T');
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
test('Handles NaN and Infinity values gracefully', () => {
|
||||
expect(formatViewsNumber(Number.NaN, true)).toBe(Number.NaN.toLocaleString());
|
||||
expect(formatViewsNumber(Number.POSITIVE_INFINITY, true)).toBe(
|
||||
Number.POSITIVE_INFINITY.toLocaleString()
|
||||
);
|
||||
expect(formatViewsNumber(Number.NEGATIVE_INFINITY, true)).toBe(
|
||||
Number.NEGATIVE_INFINITY.toLocaleString()
|
||||
);
|
||||
|
||||
expect(formatViewsNumber(Number.NaN)).toBe('NaN');
|
||||
|
||||
// @note: We don't test compact Infinity cases due to infinite loop behavior from while (views >= compare)
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
47
frontend/tests/utils/helpers/imageExtension.test.ts
Normal file
47
frontend/tests/utils/helpers/imageExtension.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { imageExtension } from '../../../src/static/js/utils/helpers/imageExtension';
|
||||
|
||||
describe('js/utils/helpers', () => {
|
||||
describe('imageExtension', () => {
|
||||
// @todo: 'imageExtension' behaves as a 'fileExtension' function. It should be renamed...
|
||||
test('Returns the extension for a typical filename', () => {
|
||||
expect(imageExtension('photo.png')).toBe('png');
|
||||
expect(imageExtension('document.pdf')).toBe('pdf');
|
||||
});
|
||||
|
||||
test('Returns the last segment for filenames with multiple dots', () => {
|
||||
expect(imageExtension('archive.tar.gz')).toBe('gz');
|
||||
expect(imageExtension('backup.2024.12.31.zip')).toBe('zip');
|
||||
});
|
||||
|
||||
// @todo: It shouldn't happen. Fix that
|
||||
test('Returns the entire string when there is no dot in the filename', () => {
|
||||
expect(imageExtension('file')).toBe('file');
|
||||
expect(imageExtension('README')).toBe('README');
|
||||
});
|
||||
|
||||
test('Handles hidden files that start with a dot', () => {
|
||||
expect(imageExtension('.gitignore')).toBe('gitignore');
|
||||
expect(imageExtension('.env.local')).toBe('local');
|
||||
});
|
||||
|
||||
test('Returns undefined for falsy or empty inputs', () => {
|
||||
expect(imageExtension('')).toBeUndefined();
|
||||
expect(imageExtension(undefined as unknown as string)).toBeUndefined();
|
||||
expect(imageExtension(null as unknown as string)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Extracts the extension from URL-like paths', () => {
|
||||
expect(imageExtension('https://example.com/images/avatar.jpeg')).toBe('jpeg');
|
||||
expect(imageExtension('/static/assets/icons/favicon.ico')).toBe('ico');
|
||||
});
|
||||
|
||||
test('Preserves case of the extension', () => {
|
||||
expect(imageExtension('UPPER.CASE.JPG')).toBe('JPG');
|
||||
expect(imageExtension('Mixed.Extension.PnG')).toBe('PnG');
|
||||
});
|
||||
|
||||
test('Returns empty string when the filename ends with a trailing dot', () => {
|
||||
expect(imageExtension('weird.')).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
54
frontend/tests/utils/helpers/log.test.ts
Normal file
54
frontend/tests/utils/helpers/log.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { warn, error } from '../../../src/static/js/utils/helpers/log';
|
||||
|
||||
describe('js/utils/helpers', () => {
|
||||
describe('log', () => {
|
||||
beforeEach(() => {
|
||||
// Setup console mocks - replaces global console methods with jest mocks
|
||||
globalThis.console.warn = jest.fn();
|
||||
globalThis.console.error = jest.fn();
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original console methods
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('Warn proxies arguments to console.warn preserving order and count', () => {
|
||||
warn('a', 'b', 'c');
|
||||
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||
expect(console.warn).toHaveBeenCalledWith('a', 'b', 'c');
|
||||
});
|
||||
|
||||
test('Error proxies arguments to console.error preserving order and count', () => {
|
||||
error('x', 'y');
|
||||
expect(console.error).toHaveBeenCalledTimes(1);
|
||||
expect(console.error).toHaveBeenCalledWith('x', 'y');
|
||||
});
|
||||
|
||||
test('Warn supports zero arguments', () => {
|
||||
warn();
|
||||
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||
expect((console.warn as jest.Mock).mock.calls[0].length).toBe(0);
|
||||
});
|
||||
|
||||
test('Error supports zero arguments', () => {
|
||||
error();
|
||||
expect(console.error).toHaveBeenCalledTimes(1);
|
||||
expect((console.error as jest.Mock).mock.calls[0].length).toBe(0);
|
||||
});
|
||||
|
||||
test('Warn does not call console.error and error does not call console.warn', () => {
|
||||
warn('only-warn');
|
||||
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
error('only-error');
|
||||
expect(console.error).toHaveBeenCalledTimes(1);
|
||||
expect(console.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
156
frontend/tests/utils/helpers/math.test.ts
Normal file
156
frontend/tests/utils/helpers/math.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
isGt,
|
||||
isZero,
|
||||
isNumber,
|
||||
isInteger,
|
||||
isPositive,
|
||||
isPositiveNumber,
|
||||
isPositiveInteger,
|
||||
isPositiveIntegerOrZero,
|
||||
greaterCommonDivision,
|
||||
} from '../../../src/static/js/utils/helpers/math';
|
||||
|
||||
describe('js/utils/helpers', () => {
|
||||
describe('math', () => {
|
||||
describe('isGt', () => {
|
||||
test('Returns true when x > y', () => {
|
||||
expect(isGt(5, 3)).toBe(true);
|
||||
});
|
||||
|
||||
test('Returns false when x === y', () => {
|
||||
expect(isGt(3, 3)).toBe(false);
|
||||
});
|
||||
|
||||
test('Returns false when x < y', () => {
|
||||
expect(isGt(2, 3)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isZero', () => {
|
||||
test('Returns true for 0', () => {
|
||||
expect(isZero(0)).toBe(true);
|
||||
});
|
||||
|
||||
test('Returns false for non-zero numbers', () => {
|
||||
expect(isZero(1)).toBe(false);
|
||||
expect(isZero(-1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNumber', () => {
|
||||
test('Returns true for numbers', () => {
|
||||
expect(isNumber(0)).toBe(true);
|
||||
expect(isNumber(1)).toBe(true);
|
||||
expect(isNumber(-1)).toBe(true);
|
||||
expect(isNumber(1.5)).toBe(true);
|
||||
});
|
||||
|
||||
test('Returns false for NaN', () => {
|
||||
expect(isNumber(Number.NaN as unknown as number)).toBe(false);
|
||||
});
|
||||
|
||||
test('Returns false for non-number types (via casting)', () => {
|
||||
// TypeScript type guards prevent passing non-numbers directly; simulate via casting
|
||||
expect(isNumber('3' as unknown as number)).toBe(false);
|
||||
expect(isNumber(null as unknown as number)).toBe(false);
|
||||
expect(isNumber(undefined as unknown as number)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInteger', () => {
|
||||
test('Returns true for integers', () => {
|
||||
expect(isInteger(0)).toBe(true);
|
||||
expect(isInteger(1)).toBe(true);
|
||||
expect(isInteger(-1)).toBe(true);
|
||||
});
|
||||
|
||||
test('Returns false for non-integers', () => {
|
||||
expect(isInteger(1.1)).toBe(false);
|
||||
expect(isInteger(-2.5)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPositive', () => {
|
||||
test('Returns true for positive numbers', () => {
|
||||
expect(isPositive(1)).toBe(true);
|
||||
expect(isPositive(3.14)).toBe(true);
|
||||
});
|
||||
|
||||
test('Returns false for zero and negatives', () => {
|
||||
expect(isPositive(0)).toBe(false);
|
||||
expect(isPositive(-1)).toBe(false);
|
||||
expect(isPositive(-3.14)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPositiveNumber', () => {
|
||||
test('Returns true for positive numbers', () => {
|
||||
expect(isPositiveNumber(1)).toBe(true);
|
||||
expect(isPositiveNumber(2.7)).toBe(true);
|
||||
});
|
||||
|
||||
test('Returns false for zero and negatives', () => {
|
||||
expect(isPositiveNumber(0)).toBe(false);
|
||||
expect(isPositiveNumber(-1)).toBe(false);
|
||||
expect(isPositiveNumber(-3.4)).toBe(false);
|
||||
});
|
||||
|
||||
test('Returns false for NaN (and non-number when cast)', () => {
|
||||
expect(isPositiveNumber(Number.NaN as unknown as number)).toBe(false);
|
||||
expect(isPositiveNumber('3' as unknown as number)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPositiveInteger', () => {
|
||||
test('Returns true for positive integers', () => {
|
||||
expect(isPositiveInteger(1)).toBe(true);
|
||||
expect(isPositiveInteger(10)).toBe(true);
|
||||
});
|
||||
|
||||
test('Returns false for zero, negatives, and non-integers', () => {
|
||||
expect(isPositiveInteger(0)).toBe(false);
|
||||
expect(isPositiveInteger(-1)).toBe(false);
|
||||
expect(isPositiveInteger(1.5)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPositiveIntegerOrZero', () => {
|
||||
test('Returns true for positive integers and zero', () => {
|
||||
expect(isPositiveIntegerOrZero(0)).toBe(true);
|
||||
expect(isPositiveIntegerOrZero(1)).toBe(true);
|
||||
expect(isPositiveIntegerOrZero(10)).toBe(true);
|
||||
});
|
||||
|
||||
test('Returns false for negatives and non-integers', () => {
|
||||
expect(isPositiveIntegerOrZero(-1)).toBe(false);
|
||||
expect(isPositiveIntegerOrZero(1.1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('greaterCommonDivision', () => {
|
||||
test('Computes gcd for positive integers', () => {
|
||||
expect(greaterCommonDivision(54, 24)).toBe(6);
|
||||
expect(greaterCommonDivision(24, 54)).toBe(6);
|
||||
expect(greaterCommonDivision(21, 14)).toBe(7);
|
||||
expect(greaterCommonDivision(7, 13)).toBe(1);
|
||||
});
|
||||
|
||||
test('Handles zeros', () => {
|
||||
expect(greaterCommonDivision(0, 0)).toBe(0);
|
||||
expect(greaterCommonDivision(0, 5)).toBe(5);
|
||||
expect(greaterCommonDivision(12, 0)).toBe(12);
|
||||
});
|
||||
|
||||
test('Handles negative numbers by returning gcd sign of first arg (Euclid recursion behavior)', () => {
|
||||
expect(greaterCommonDivision(-54, 24)).toBe(-6);
|
||||
expect(greaterCommonDivision(54, -24)).toBe(6);
|
||||
expect(greaterCommonDivision(-54, -24)).toBe(-6);
|
||||
});
|
||||
|
||||
test('Works with equal numbers', () => {
|
||||
expect(greaterCommonDivision(8, 8)).toBe(8);
|
||||
expect(greaterCommonDivision(-8, -8)).toBe(-8);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
111
frontend/tests/utils/helpers/propTypeFilters.test.ts
Normal file
111
frontend/tests/utils/helpers/propTypeFilters.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// Mock the errors helper to capture error construction without side effects
|
||||
jest.mock('../../../src/static/js/utils/helpers/errors', () => ({
|
||||
logErrorAndReturnError: jest.fn((messages: string[]) => new Error(messages.join('\n'))),
|
||||
}));
|
||||
|
||||
import { logErrorAndReturnError } from '../../../src/static/js/utils/helpers/errors';
|
||||
import { PositiveIntegerOrZero, PositiveInteger } from '../../../src/static/js/utils/helpers/propTypeFilters';
|
||||
|
||||
describe('js/utils/helpers', () => {
|
||||
describe('propTypeFilters', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('PositiveIntegerOrZero', () => {
|
||||
test('Returns null when property is undefined', () => {
|
||||
const obj = {};
|
||||
const res = PositiveIntegerOrZero(obj, 'count', 'Comp');
|
||||
expect(res).toBeNull();
|
||||
expect(logErrorAndReturnError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Returns null for zero or positive integers', () => {
|
||||
const cases = [0, 1, 2, 100];
|
||||
for (const val of cases) {
|
||||
const res = PositiveIntegerOrZero({ count: val }, 'count', 'Comp');
|
||||
expect(res).toBeNull();
|
||||
}
|
||||
expect(logErrorAndReturnError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Returns Error via logErrorAndReturnError for negative numbers', () => {
|
||||
const res = PositiveIntegerOrZero({ count: -1 }, 'count', 'Counter');
|
||||
expect(res).toBeInstanceOf(Error);
|
||||
expect(logErrorAndReturnError).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [messages] = (logErrorAndReturnError as jest.Mock).mock.calls[0];
|
||||
expect(Array.isArray(messages)).toBe(true);
|
||||
expect(messages[0]).toBe(
|
||||
'Invalid prop `count` of type `number` supplied to `Counter`, expected `positive integer or zero` (-1).'
|
||||
);
|
||||
});
|
||||
|
||||
test('Returns Error for non-integer numbers (e.g., float)', () => {
|
||||
const res = PositiveIntegerOrZero({ count: 1.5 }, 'count', 'Widget');
|
||||
expect(res).toBeInstanceOf(Error);
|
||||
expect((logErrorAndReturnError as jest.Mock).mock.calls[0][0][0]).toBe(
|
||||
'Invalid prop `count` of type `number` supplied to `Widget`, expected `positive integer or zero` (1.5).'
|
||||
);
|
||||
});
|
||||
|
||||
test('Uses "N/A" component label when comp is falsy', () => {
|
||||
const res = PositiveIntegerOrZero({ count: -2 }, 'count', '');
|
||||
expect(res).toBeInstanceOf(Error);
|
||||
expect((logErrorAndReturnError as jest.Mock).mock.calls[0][0][0]).toBe(
|
||||
'Invalid prop `count` of type `number` supplied to `N/A`, expected `positive integer or zero` (-2).'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PositiveInteger', () => {
|
||||
test('Returns null when property is undefined', () => {
|
||||
const obj = {};
|
||||
const res = PositiveInteger(obj, 'age', 'Person');
|
||||
expect(res).toBeNull();
|
||||
expect(logErrorAndReturnError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Returns null for positive integers (excluding zero)', () => {
|
||||
const cases = [1, 2, 100];
|
||||
for (const val of cases) {
|
||||
const res = PositiveInteger({ age: val }, 'age', 'Person');
|
||||
expect(res).toBeNull();
|
||||
}
|
||||
expect(logErrorAndReturnError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Returns Error for zero', () => {
|
||||
const res = PositiveInteger({ age: 0 }, 'age', 'Person');
|
||||
expect(res).toBeInstanceOf(Error);
|
||||
expect((logErrorAndReturnError as jest.Mock).mock.calls[0][0][0]).toContain(
|
||||
'Invalid prop `age` of type `number` supplied to `Person`, expected `positive integer` (0).'
|
||||
);
|
||||
});
|
||||
|
||||
test('Returns Error for negative numbers', () => {
|
||||
const res = PositiveInteger({ age: -3 }, 'age', 'Person');
|
||||
expect(res).toBeInstanceOf(Error);
|
||||
expect((logErrorAndReturnError as jest.Mock).mock.calls[0][0][0]).toBe(
|
||||
'Invalid prop `age` of type `number` supplied to `Person`, expected `positive integer` (-3).'
|
||||
);
|
||||
});
|
||||
|
||||
test('Returns Error for non-integer numbers', () => {
|
||||
const res = PositiveInteger({ age: 2.7 }, 'age', 'Person');
|
||||
expect(res).toBeInstanceOf(Error);
|
||||
expect((logErrorAndReturnError as jest.Mock).mock.calls[0][0][0]).toBe(
|
||||
'Invalid prop `age` of type `number` supplied to `Person`, expected `positive integer` (2.7).'
|
||||
);
|
||||
});
|
||||
|
||||
test('Uses "N/A" component label when comp is falsy', () => {
|
||||
const res = PositiveInteger({ age: -1 }, 'age', '');
|
||||
expect(res).toBeInstanceOf(Error);
|
||||
expect((logErrorAndReturnError as jest.Mock).mock.calls[0][0][0]).toBe(
|
||||
'Invalid prop `age` of type `number` supplied to `N/A`, expected `positive integer` (-1).'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
33
frontend/tests/utils/helpers/publishedOnDate.test.ts
Normal file
33
frontend/tests/utils/helpers/publishedOnDate.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import publishedOnDate from '../../../src/static/js/utils/helpers/publishedOnDate';
|
||||
|
||||
// Helper to create Date in UTC to avoid timezone issues in CI environments
|
||||
const makeDate = (y: number, mZeroBased: number, d: number) => new Date(Date.UTC(y, mZeroBased, d));
|
||||
|
||||
describe('js/utils/helpers', () => {
|
||||
describe('publishedOnDate', () => {
|
||||
test('Returns null when input is not a Date instance', () => {
|
||||
expect(publishedOnDate(null as unknown as Date)).toBeNull();
|
||||
expect(publishedOnDate(undefined as unknown as Date)).toBeNull();
|
||||
expect(publishedOnDate('2020-01-02' as any as Date)).toBeNull();
|
||||
expect(publishedOnDate(1577923200000 as unknown as Date)).toBeNull();
|
||||
});
|
||||
|
||||
test('Type 1 (default): "Mon DD, YYYY" with 3-letter month prefix before day', () => {
|
||||
expect(publishedOnDate(makeDate(2020, 0, 2))).toBe('Jan 2, 2020');
|
||||
expect(publishedOnDate(makeDate(1999, 11, 31))).toBe('Dec 31, 1999');
|
||||
expect(publishedOnDate(makeDate(2024, 1, 29))).toBe('Feb 29, 2024');
|
||||
});
|
||||
|
||||
test('Type 2: "DD Mon YYYY" with 3-letter month suffix', () => {
|
||||
expect(publishedOnDate(makeDate(2020, 0, 2), 2)).toBe('2 Jan 2020');
|
||||
expect(publishedOnDate(makeDate(1999, 11, 31), 2)).toBe('31 Dec 1999');
|
||||
expect(publishedOnDate(makeDate(2024, 1, 29), 2)).toBe('29 Feb 2024');
|
||||
});
|
||||
|
||||
test('Type 3: "DD Month YYYY" with full month name', () => {
|
||||
expect(publishedOnDate(makeDate(2020, 0, 2), 3)).toBe('2 January 2020');
|
||||
expect(publishedOnDate(makeDate(1999, 11, 31), 3)).toBe('31 December 1999');
|
||||
expect(publishedOnDate(makeDate(2024, 1, 29), 3)).toBe('29 February 2024');
|
||||
});
|
||||
});
|
||||
});
|
||||
45
frontend/tests/utils/helpers/quickSort.test.ts
Normal file
45
frontend/tests/utils/helpers/quickSort.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { quickSort } from '../../../src/static/js/utils/helpers/quickSort';
|
||||
|
||||
describe('js/utils/helpers', () => {
|
||||
describe('quickSort', () => {
|
||||
test('Returns the same array reference (in-place) and sorts ascending', () => {
|
||||
const arr = [3, 1, 4, 1, 5, 9, 2];
|
||||
const out = quickSort(arr, 0, arr.length - 1);
|
||||
expect(out).toBe(arr);
|
||||
expect(arr).toEqual([1, 1, 2, 3, 4, 5, 9]);
|
||||
});
|
||||
|
||||
test('Handles already sorted arrays', () => {
|
||||
const arr = [1, 2, 3, 4, 5];
|
||||
quickSort(arr, 0, arr.length - 1);
|
||||
expect(arr).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
test('Handles arrays with duplicates and negative numbers', () => {
|
||||
const arr = [0, -1, -1, 2, 2, 1, 0];
|
||||
quickSort(arr, 0, arr.length - 1);
|
||||
expect(arr).toEqual([-1, -1, 0, 0, 1, 2, 2]);
|
||||
});
|
||||
|
||||
test('Handles single-element array', () => {
|
||||
const single = [42];
|
||||
quickSort(single, 0, single.length - 1);
|
||||
expect(single).toEqual([42]);
|
||||
});
|
||||
|
||||
test('Handles empty range without changes', () => {
|
||||
const arr = [5, 4, 3];
|
||||
// call with left > right (empty range)
|
||||
quickSort(arr, 2, 1);
|
||||
expect(arr).toEqual([5, 4, 3]);
|
||||
});
|
||||
|
||||
test('Sorts subrange correctly without touching elements outside range', () => {
|
||||
const arr = [9, 7, 5, 3, 1, 2, 4, 8, 6];
|
||||
// sort only the middle [2..6]
|
||||
quickSort(arr, 2, 6);
|
||||
// The subrange [5,3,1,2,4] becomes [1,2,3,4,5]
|
||||
expect(arr).toEqual([9, 7, 1, 2, 3, 4, 5, 8, 6]);
|
||||
});
|
||||
});
|
||||
});
|
||||
68
frontend/tests/utils/helpers/replacementStrings.test.ts
Normal file
68
frontend/tests/utils/helpers/replacementStrings.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { replaceString } from '../../../src/static/js/utils/helpers/replacementStrings';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
REPLACEMENTS?: Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
describe('js/utils/helpers', () => {
|
||||
describe('replacementStrings', () => {
|
||||
describe('replaceString', () => {
|
||||
const originalReplacements = window.REPLACEMENTS;
|
||||
|
||||
beforeEach(() => {
|
||||
delete window.REPLACEMENTS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.REPLACEMENTS = originalReplacements;
|
||||
});
|
||||
|
||||
test('Returns the original word when window.REPLACEMENTS is undefined', () => {
|
||||
delete window.REPLACEMENTS;
|
||||
const input = 'Hello World';
|
||||
const output = replaceString(input);
|
||||
expect(output).toBe(input);
|
||||
});
|
||||
|
||||
test('Replaces a single occurrence based on window.REPLACEMENTS map', () => {
|
||||
window.REPLACEMENTS = { Hello: 'Hi' };
|
||||
const output = replaceString('Hello World');
|
||||
expect(output).toBe('Hi World');
|
||||
});
|
||||
|
||||
test('Replaces multiple occurrences of the same key', () => {
|
||||
window.REPLACEMENTS = { foo: 'bar' };
|
||||
const output = replaceString('foo foo baz foo');
|
||||
expect(output).toBe('bar bar baz bar');
|
||||
});
|
||||
|
||||
test('Applies all entries in window.REPLACEMENTS (sequential split/join)', () => {
|
||||
window.REPLACEMENTS = { a: 'A', A: 'X' };
|
||||
// First replaces 'a'->'A' and then 'A'->'X'
|
||||
const output = replaceString('aAaa');
|
||||
expect(output).toBe('XXXX');
|
||||
});
|
||||
|
||||
test('Supports empty string replacements (deletion)', () => {
|
||||
window.REPLACEMENTS = { remove: '' };
|
||||
const output = replaceString('please remove this');
|
||||
expect(output).toBe('please this');
|
||||
});
|
||||
|
||||
test('Handles overlapping keys by iteration order', () => {
|
||||
window.REPLACEMENTS = { ab: 'X', b: 'Y' };
|
||||
// First replaces 'ab' -> 'X', leaving no 'b' from that sequence, then replace standalone 'b' -> 'Y'
|
||||
const output = replaceString('zab+b');
|
||||
expect(output).toBe('zX+Y');
|
||||
});
|
||||
|
||||
test('Works with special regex characters since split/join is literal', () => {
|
||||
window.REPLACEMENTS = { '.': 'DOT', '*': 'STAR', '[]': 'BRACKETS' };
|
||||
const output = replaceString('a.*b[]c.');
|
||||
expect(output).toBe('aDOTSTARbBRACKETScDOT');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
218
frontend/tests/utils/helpers/requests.test.ts
Normal file
218
frontend/tests/utils/helpers/requests.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import axios from 'axios';
|
||||
import { getRequest, postRequest, putRequest, deleteRequest } from '../../../src/static/js/utils/helpers/requests';
|
||||
|
||||
jest.mock('axios');
|
||||
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
describe('js/utils/helpers', () => {
|
||||
describe('requests', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getRequest', () => {
|
||||
const url = '/api/test';
|
||||
|
||||
test('Calls axios.get with url and default config (async mode)', () => {
|
||||
mockedAxios.get.mockResolvedValueOnce({ data: 'ok' } as any);
|
||||
const cb = jest.fn();
|
||||
|
||||
getRequest(url, false, cb, undefined);
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(url, {
|
||||
timeout: null,
|
||||
maxContentLength: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('Invokes callback when provided (async mode)', async () => {
|
||||
const response = { data: 'ok' } as any;
|
||||
mockedAxios.get.mockResolvedValueOnce(response);
|
||||
const cb = jest.fn();
|
||||
|
||||
await getRequest(url, true, cb, undefined);
|
||||
|
||||
expect(cb).toHaveBeenCalledWith(response);
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
test('Does not throw when callback is not a function', async () => {
|
||||
mockedAxios.get.mockResolvedValueOnce({ data: 'ok' } as any);
|
||||
await expect(getRequest(url, true, undefined as any, undefined as any)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('Error handler wraps network errors with type network', async () => {
|
||||
const networkError = new Error('Network Error');
|
||||
mockedAxios.get.mockRejectedValueOnce(networkError);
|
||||
const errorCb = jest.fn();
|
||||
|
||||
await getRequest(url, true, undefined, errorCb);
|
||||
|
||||
expect(errorCb).toHaveBeenCalledTimes(1);
|
||||
const arg = errorCb.mock.calls[0][0];
|
||||
expect(arg).toStrictEqual({ type: 'network', error: networkError });
|
||||
});
|
||||
|
||||
test('Error handler maps status 401 to private error', async () => {
|
||||
const error = { response: { status: 401 } };
|
||||
mockedAxios.get.mockRejectedValueOnce(error);
|
||||
const errorCb = jest.fn();
|
||||
|
||||
await getRequest(url, true, undefined, errorCb);
|
||||
|
||||
expect(errorCb).toHaveBeenCalledWith({
|
||||
type: 'private',
|
||||
error,
|
||||
message: 'Media is private',
|
||||
});
|
||||
});
|
||||
|
||||
test('Error handler maps status 400 to unavailable error', async () => {
|
||||
const error = { response: { status: 400 } };
|
||||
mockedAxios.get.mockRejectedValueOnce(error);
|
||||
const errorCb = jest.fn();
|
||||
|
||||
await getRequest(url, true, undefined, errorCb);
|
||||
|
||||
expect(errorCb).toHaveBeenCalledWith({
|
||||
type: 'unavailable',
|
||||
error,
|
||||
message: 'Media is unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
test('Passes through other errors with error.response defined but no status', async () => {
|
||||
const error = { response: {} } as any;
|
||||
mockedAxios.get.mockRejectedValueOnce(error);
|
||||
const errorCb = jest.fn();
|
||||
|
||||
await getRequest(url, true, undefined, errorCb);
|
||||
|
||||
expect(errorCb).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
test('When no errorCallback provided, it should not crash on error (async)', async () => {
|
||||
mockedAxios.get.mockRejectedValueOnce(new Error('boom'));
|
||||
await expect(getRequest(url, true, undefined as any, undefined as any)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('postRequest', () => {
|
||||
const url = '/api/post';
|
||||
|
||||
test('Calls axios.post with provided data and config (async mode)', () => {
|
||||
mockedAxios.post.mockResolvedValueOnce({ data: 'ok' } as any);
|
||||
const cb = jest.fn();
|
||||
|
||||
postRequest(url, { a: 1 }, { headers: { h: 'v' } }, false, cb, undefined);
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(url, { a: 1 }, { headers: { h: 'v' } });
|
||||
});
|
||||
|
||||
test('Defaults postData to {} when undefined', async () => {
|
||||
mockedAxios.post.mockResolvedValueOnce({ data: 'ok' } as any);
|
||||
const cb = jest.fn();
|
||||
|
||||
await postRequest(url, undefined as any, undefined as any, true, cb, undefined);
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(url, {}, null);
|
||||
expect(cb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Invokes errorCallback on error as-is', async () => {
|
||||
const error = new Error('fail');
|
||||
mockedAxios.post.mockRejectedValueOnce(error);
|
||||
const errorCb = jest.fn();
|
||||
|
||||
await postRequest(url, {}, undefined, true, undefined, errorCb);
|
||||
|
||||
expect(errorCb).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('putRequest', () => {
|
||||
const url = '/api/put';
|
||||
|
||||
test('Calls axios.put with provided data and config', async () => {
|
||||
mockedAxios.put.mockResolvedValueOnce({ data: 'ok' } as any);
|
||||
const cb = jest.fn();
|
||||
|
||||
await putRequest(url, { a: 1 }, { headers: { h: 'v' } }, true, cb, undefined);
|
||||
|
||||
expect(mockedAxios.put).toHaveBeenCalledWith(url, { a: 1 }, { headers: { h: 'v' } });
|
||||
expect(cb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Defaults putData to {} when undefined', async () => {
|
||||
mockedAxios.put.mockResolvedValueOnce({ data: 'ok' } as any);
|
||||
|
||||
await putRequest(url, undefined as any, undefined as any, true, undefined, undefined);
|
||||
|
||||
expect(mockedAxios.put).toHaveBeenCalledWith(url, {}, null);
|
||||
});
|
||||
|
||||
test('Invokes errorCallback on error', async () => {
|
||||
const error = new Error('fail');
|
||||
mockedAxios.put.mockRejectedValueOnce(error);
|
||||
const errorCb = jest.fn();
|
||||
|
||||
await putRequest(url, {}, undefined, true, undefined, errorCb);
|
||||
|
||||
expect(errorCb).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteRequest', () => {
|
||||
const url = '/api/delete';
|
||||
|
||||
test('Calls axios.delete with provided config', async () => {
|
||||
mockedAxios.delete.mockResolvedValueOnce({ data: 'ok' } as any);
|
||||
const cb = jest.fn();
|
||||
|
||||
await deleteRequest(url, { headers: { h: 'v' } }, true, cb, undefined);
|
||||
|
||||
expect(mockedAxios.delete).toHaveBeenCalledWith(url, { headers: { h: 'v' } });
|
||||
expect(cb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Defaults configData to {} when undefined', async () => {
|
||||
mockedAxios.delete.mockResolvedValueOnce({ data: 'ok' } as any);
|
||||
|
||||
await deleteRequest(url, undefined as any, true, undefined, undefined);
|
||||
|
||||
expect(mockedAxios.delete).toHaveBeenCalledWith(url, {});
|
||||
});
|
||||
|
||||
test('Invokes errorCallback on error', async () => {
|
||||
const error = new Error('fail');
|
||||
mockedAxios.delete.mockRejectedValueOnce(error);
|
||||
const errorCb = jest.fn();
|
||||
|
||||
await deleteRequest(url, {}, true, undefined, errorCb);
|
||||
|
||||
expect(errorCb).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync vs async behavior', () => {
|
||||
test('sync=true awaits the axios promise', async () => {
|
||||
const thenable = Promise.resolve({ data: 'ok' } as any);
|
||||
mockedAxios.post.mockReturnValueOnce(thenable as any);
|
||||
const cb = jest.fn();
|
||||
|
||||
const p = postRequest('/api/p', {}, undefined, true, cb, undefined);
|
||||
// When awaited, callback should be called before next tick
|
||||
await p;
|
||||
expect(cb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('sync=false does not need awaiting; call still issued', () => {
|
||||
mockedAxios.put.mockResolvedValueOnce({ data: 'ok' } as any);
|
||||
putRequest('/api/p', {}, undefined, false, undefined, undefined);
|
||||
expect(mockedAxios.put).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
53
frontend/tests/utils/helpers/translate.test.ts
Normal file
53
frontend/tests/utils/helpers/translate.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { translateString } from '../../../src/static/js/utils/helpers/translate';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
TRANSLATION?: Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
describe('js/utils/helpers', () => {
|
||||
describe('translate', () => {
|
||||
const originalReplacements = window.TRANSLATION;
|
||||
|
||||
beforeEach(() => {
|
||||
delete window.TRANSLATION;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.TRANSLATION = originalReplacements;
|
||||
});
|
||||
|
||||
test('Returns the same word when window.TRANSLATION is undefined', () => {
|
||||
delete window.TRANSLATION;
|
||||
expect(translateString('Hello')).toBe('Hello');
|
||||
expect(translateString('NonExistingKey')).toBe('NonExistingKey');
|
||||
expect(translateString('')).toBe('');
|
||||
});
|
||||
|
||||
test('Returns mapped value when key exists in window.TRANSLATION', () => {
|
||||
window.TRANSLATION = { Hello: 'Γεια', World: 'Κόσμος' };
|
||||
expect(translateString('Hello')).toBe('Γεια');
|
||||
expect(translateString('World')).toBe('Κόσμος');
|
||||
});
|
||||
|
||||
test('Falls back to original word when key is missing in Twindow.RANSLATION', () => {
|
||||
window.TRANSLATION = { Hello: 'Γεια' };
|
||||
expect(translateString('MissingKey')).toBe('MissingKey');
|
||||
expect(translateString('AnotherMissing')).toBe('AnotherMissing');
|
||||
});
|
||||
|
||||
test('Supports empty string keys distinctly from missing keys', () => {
|
||||
window.TRANSLATION = { '': '(empty)' };
|
||||
expect(translateString('')).toBe('(empty)');
|
||||
expect(translateString(' ')).toBe(' ');
|
||||
});
|
||||
|
||||
test('Returns value as-is even if it is an empty string or falsy in the dictionary', () => {
|
||||
window.TRANSLATION = { Empty: '', Zero: '0', False: 'false' };
|
||||
expect(translateString('Empty')).toBe('');
|
||||
expect(translateString('Zero')).toBe('0');
|
||||
expect(translateString('False')).toBe('false');
|
||||
});
|
||||
});
|
||||
});
|
||||
79
frontend/tests/utils/settings/api.test.ts
Normal file
79
frontend/tests/utils/settings/api.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { init, endpoints } from '../../../src/static/js/utils/settings/api';
|
||||
|
||||
const apiConfig = (url: any, ep: any) => {
|
||||
init(url, ep);
|
||||
return endpoints();
|
||||
};
|
||||
|
||||
describe('utils/settings', () => {
|
||||
describe('api', () => {
|
||||
const sampleGlobal = {
|
||||
site: { api: 'https://example.com/api/v1///' },
|
||||
// The endpoints below intentionally contain leading slashes to ensure they are stripped
|
||||
api: {
|
||||
media: '/media/',
|
||||
members: '/users//',
|
||||
playlists: '/playlists',
|
||||
liked: '/user/liked',
|
||||
history: '/user/history',
|
||||
tags: '/tags',
|
||||
categories: '/categories',
|
||||
manage_media: '/manage/media',
|
||||
manage_users: '/manage/users',
|
||||
manage_comments: '/manage/comments',
|
||||
search: '/search',
|
||||
},
|
||||
} as const;
|
||||
|
||||
test('Trims trailing slashes on base and ensures single slash joins', () => {
|
||||
const cfg = apiConfig(sampleGlobal.site.api, sampleGlobal.api);
|
||||
// @todo: Check again the cases of trailing slashes
|
||||
expect(cfg.media).toBe('https://example.com/api/v1/media/');
|
||||
expect(cfg.users).toBe('https://example.com/api/v1/users//');
|
||||
});
|
||||
|
||||
test('Adds featured/recommended query to media variants', () => {
|
||||
const cfg = apiConfig(sampleGlobal.site.api, sampleGlobal.api);
|
||||
expect(cfg.featured).toBe('https://example.com/api/v1/media/?show=featured');
|
||||
expect(cfg.recommended).toBe('https://example.com/api/v1/media/?show=recommended');
|
||||
});
|
||||
|
||||
test('Builds nested user, archive, manage maps', () => {
|
||||
const cfg = apiConfig(sampleGlobal.site.api, sampleGlobal.api);
|
||||
|
||||
expect(cfg.user.liked).toBe('https://example.com/api/v1/user/liked');
|
||||
expect(cfg.user.history).toBe('https://example.com/api/v1/user/history');
|
||||
expect(cfg.user.playlists).toBe('https://example.com/api/v1/playlists?author=');
|
||||
|
||||
expect(cfg.archive.tags).toBe('https://example.com/api/v1/tags');
|
||||
expect(cfg.archive.categories).toBe('https://example.com/api/v1/categories');
|
||||
|
||||
expect(cfg.manage.media).toBe('https://example.com/api/v1/manage/media');
|
||||
expect(cfg.manage.users).toBe('https://example.com/api/v1/manage/users');
|
||||
expect(cfg.manage.comments).toBe('https://example.com/api/v1/manage/comments');
|
||||
});
|
||||
|
||||
test('Builds search endpoints with expected query fragments', () => {
|
||||
const cfg = apiConfig(sampleGlobal.site.api, sampleGlobal.api);
|
||||
expect(cfg.search.query).toBe('https://example.com/api/v1/search?q=');
|
||||
expect(cfg.search.titles).toBe('https://example.com/api/v1/search?show=titles&q=');
|
||||
expect(cfg.search.tag).toBe('https://example.com/api/v1/search?t=');
|
||||
expect(cfg.search.category).toBe('https://example.com/api/v1/search?c=');
|
||||
});
|
||||
|
||||
test('Handles base url with path and endpoint with existing query', () => {
|
||||
const cfg = apiConfig('https://example.com/base/', {
|
||||
media: 'items?x=1',
|
||||
playlists: '/pls/',
|
||||
liked: 'me/liked',
|
||||
categories: '/c',
|
||||
search: '/s',
|
||||
});
|
||||
expect(cfg.media).toBe('https://example.com/base/items?x=1');
|
||||
expect(cfg.playlists).toBe('https://example.com/base/pls/');
|
||||
expect(cfg.user.liked).toBe('https://example.com/base/me/liked');
|
||||
expect(cfg.archive.categories).toBe('https://example.com/base/c');
|
||||
expect(cfg.search.query).toBe('https://example.com/base/s?q=');
|
||||
});
|
||||
});
|
||||
});
|
||||
189
frontend/tests/utils/settings/config.test.ts
Normal file
189
frontend/tests/utils/settings/config.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { config } from '../../../src/static/js/utils/settings/config';
|
||||
|
||||
describe('utils/settings', () => {
|
||||
describe('config', () => {
|
||||
const baseGlobal = {
|
||||
profileId: 'john',
|
||||
site: {
|
||||
id: 'my-site',
|
||||
url: 'https://example.com/',
|
||||
api: 'https://example.com/api/',
|
||||
title: 'Example',
|
||||
theme: { mode: 'dark', switch: { enabled: true, position: 'sidebar' } },
|
||||
logo: {
|
||||
lightMode: { img: '/img/light.png', svg: '/img/light.svg' },
|
||||
darkMode: { img: '/img/dark.png', svg: '/img/dark.svg' },
|
||||
},
|
||||
devEnv: false,
|
||||
useRoundedCorners: true,
|
||||
version: '2.0.0',
|
||||
taxonomies: {
|
||||
tags: { enabled: true, title: 'Topic Tags' },
|
||||
categories: { enabled: false, title: 'Kinds' },
|
||||
},
|
||||
pages: {
|
||||
latest: { enabled: true, title: 'Recent uploads' },
|
||||
featured: { enabled: true, title: 'Featured picks' },
|
||||
recommended: { enabled: false, title: 'You may like' },
|
||||
},
|
||||
userPages: {
|
||||
members: { enabled: true, title: 'People' },
|
||||
liked: { enabled: true, title: 'Favorites' },
|
||||
history: { enabled: true, title: 'Watched' },
|
||||
},
|
||||
},
|
||||
url: {
|
||||
home: '/',
|
||||
admin: '/admin',
|
||||
error404: '/404',
|
||||
latestMedia: '/latest',
|
||||
featuredMedia: '/featured',
|
||||
recommendedMedia: '/recommended',
|
||||
signin: '/signin',
|
||||
signout: '/signout',
|
||||
register: '/register',
|
||||
changePassword: '/password',
|
||||
members: '/members',
|
||||
search: '/search',
|
||||
likedMedia: '/liked',
|
||||
history: '/history',
|
||||
addMedia: '/add',
|
||||
editChannel: '/edit/channel',
|
||||
editProfile: '/edit/profile',
|
||||
tags: '/tags',
|
||||
categories: '/categories',
|
||||
manageMedia: '/manage/media',
|
||||
manageUsers: '/manage/users',
|
||||
manageComments: '/manage/comments',
|
||||
},
|
||||
api: {
|
||||
media: 'v1/media/',
|
||||
playlists: 'v1/playlists',
|
||||
members: 'v1/users',
|
||||
liked: 'v1/user/liked',
|
||||
history: 'v1/user/history',
|
||||
tags: 'v1/tags',
|
||||
categories: 'v1/categories',
|
||||
manage_media: 'v1/manage/media',
|
||||
manage_users: 'v1/manage/users',
|
||||
manage_comments: 'v1/manage/comments',
|
||||
search: 'v1/search',
|
||||
},
|
||||
contents: {
|
||||
notifications: {
|
||||
messages: {
|
||||
addToLiked: 'Yay',
|
||||
removeFromLiked: 'Oops',
|
||||
addToDisliked: 'nay',
|
||||
removeFromDisliked: 'ok',
|
||||
},
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
home: { sections: { latest: { title: 'Latest T' } } },
|
||||
search: { advancedFilters: true },
|
||||
media: { categoriesWithTitle: true, hideViews: true, related: { initialSize: 5 } },
|
||||
profile: { htmlInDescription: true, includeHistory: true, includeLikedMedia: true },
|
||||
},
|
||||
features: {
|
||||
mediaItem: { hideAuthor: true, hideViews: false, hideDate: true },
|
||||
media: {
|
||||
actions: {
|
||||
like: true,
|
||||
dislike: true,
|
||||
report: true,
|
||||
comment: true,
|
||||
comment_mention: true,
|
||||
download: true,
|
||||
save: true,
|
||||
share: true,
|
||||
},
|
||||
shareOptions: ['embed', 'email', 'invalid'],
|
||||
},
|
||||
playlists: { mediaTypes: ['audio'] },
|
||||
sideBar: { hideHomeLink: false, hideTagsLink: true, hideCategoriesLink: false },
|
||||
embeddedVideo: { initialDimensions: { width: 640, height: 360 } },
|
||||
headerBar: { hideLogin: false, hideRegister: true },
|
||||
},
|
||||
user: {
|
||||
is: { anonymous: false, admin: true },
|
||||
name: ' John ',
|
||||
username: ' john ',
|
||||
thumbnail: ' /img/j.png ',
|
||||
can: {
|
||||
changePassword: true,
|
||||
deleteProfile: true,
|
||||
addComment: true,
|
||||
mentionComment: true,
|
||||
deleteComment: true,
|
||||
editMedia: true,
|
||||
deleteMedia: true,
|
||||
editSubtitle: true,
|
||||
manageMedia: true,
|
||||
manageUsers: true,
|
||||
manageComments: true,
|
||||
contactUser: true,
|
||||
canSeeMembersPage: true,
|
||||
usersNeedsToBeApproved: false,
|
||||
addMedia: true,
|
||||
editProfile: true,
|
||||
readComment: true,
|
||||
},
|
||||
pages: { about: '/u/john/about ', media: '/u/john ', playlists: '/u/john/playlists ' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
test('merges enabled pages and passes titles into options.pages.home sections', () => {
|
||||
const cfg = config(baseGlobal);
|
||||
expect(cfg.enabled.pages.latest).toStrictEqual({ enabled: true, title: 'Recent uploads' });
|
||||
expect(cfg.enabled.pages.featured).toStrictEqual({ enabled: true, title: 'Featured picks' });
|
||||
expect(cfg.enabled.pages.recommended).toStrictEqual({ enabled: false, title: 'You may like' });
|
||||
expect(cfg.enabled.pages.members).toStrictEqual({ enabled: true, title: 'People' });
|
||||
expect(cfg.options.pages.home.sections.latest.title).toBe('Latest T');
|
||||
expect(cfg.options.pages.home.sections.featured.title).toBe('Featured picks');
|
||||
});
|
||||
|
||||
test('produces api endpoints based on site.api and api endpoints', () => {
|
||||
const cfg = config(baseGlobal);
|
||||
expect(cfg.api.media).toBe('https://example.com/api/v1/media/');
|
||||
expect(cfg.api.user.liked).toBe('https://example.com/api/v1/user/liked');
|
||||
expect(cfg.api.search.query).toBe('https://example.com/api/v1/search?q=');
|
||||
});
|
||||
|
||||
test('member and url manage links reflect user and feature flags', () => {
|
||||
const cfg = config(baseGlobal);
|
||||
expect(cfg.member.is).toStrictEqual({ admin: true, anonymous: false });
|
||||
expect(cfg.member.can).toMatchObject({
|
||||
manageMedia: true,
|
||||
manageUsers: true,
|
||||
manageComments: true,
|
||||
likeMedia: true,
|
||||
});
|
||||
expect(cfg.url.manage.media).toBe('/manage/media');
|
||||
expect(cfg.url.signout).toBe('/signout');
|
||||
// admin visible
|
||||
expect(cfg.url.admin).toBe('/admin');
|
||||
});
|
||||
|
||||
test('theme and site defaults propagate correctly', () => {
|
||||
const cfg = config(baseGlobal);
|
||||
expect(cfg.theme.mode).toBe('dark');
|
||||
expect(cfg.theme.switch.position).toBe('sidebar');
|
||||
expect(cfg.theme.logo.darkMode.img).toBe('/img/dark.png');
|
||||
expect(cfg.site.id).toBe('my-site');
|
||||
expect(cfg.site.version).toBe('2.0.0');
|
||||
});
|
||||
|
||||
test('memoizes and returns the same object instance on repeated calls', () => {
|
||||
const first = config(baseGlobal);
|
||||
const second = config(baseGlobal);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
test('url profile paths use site.url when not in dev env', () => {
|
||||
const cfg = config(baseGlobal);
|
||||
expect(cfg.url.profile.media).toBe('https://example.com/user/john');
|
||||
expect(cfg.url.embed).toBe('https://example.com/embed?m=');
|
||||
});
|
||||
});
|
||||
});
|
||||
83
frontend/tests/utils/settings/contents.test.ts
Normal file
83
frontend/tests/utils/settings/contents.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { init, settings } from '../../../src/static/js/utils/settings/contents';
|
||||
|
||||
const contentsConfig = (obj: any) => {
|
||||
init(obj);
|
||||
return settings();
|
||||
};
|
||||
|
||||
describe('utils/settings', () => {
|
||||
describe('contents', () => {
|
||||
test('Strings are trimmed and default to empty', () => {
|
||||
const cfg1 = contentsConfig({
|
||||
header: { right: ' R ', onLogoRight: ' OLR ' },
|
||||
sidebar: { belowNavMenu: ' X ', belowThemeSwitcher: ' Y ', footer: ' Z ' },
|
||||
uploader: { belowUploadArea: ' U1 ', postUploadMessage: ' U2 ' },
|
||||
});
|
||||
const cfg2 = contentsConfig({});
|
||||
const cfg3 = contentsConfig({ header: {}, sidebar: {}, uploader: {} });
|
||||
|
||||
expect(cfg1).toStrictEqual({
|
||||
header: { right: 'R', onLogoRight: 'OLR' },
|
||||
sidebar: {
|
||||
navMenu: { items: [] },
|
||||
mainMenuExtra: { items: [] },
|
||||
belowNavMenu: 'X',
|
||||
belowThemeSwitcher: 'Y',
|
||||
footer: 'Z',
|
||||
},
|
||||
uploader: { belowUploadArea: 'U1', postUploadMessage: 'U2' },
|
||||
});
|
||||
|
||||
expect(cfg2).toStrictEqual({
|
||||
header: { right: '', onLogoRight: '' },
|
||||
sidebar: {
|
||||
navMenu: { items: [] },
|
||||
mainMenuExtra: { items: [] },
|
||||
belowNavMenu: '',
|
||||
belowThemeSwitcher: '',
|
||||
footer: '',
|
||||
},
|
||||
uploader: { belowUploadArea: '', postUploadMessage: '' },
|
||||
});
|
||||
|
||||
expect(cfg3).toStrictEqual({
|
||||
header: { right: '', onLogoRight: '' },
|
||||
sidebar: {
|
||||
navMenu: { items: [] },
|
||||
mainMenuExtra: { items: [] },
|
||||
belowNavMenu: '',
|
||||
belowThemeSwitcher: '',
|
||||
footer: '',
|
||||
},
|
||||
uploader: { belowUploadArea: '', postUploadMessage: '' },
|
||||
});
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
test('Sidebar menu items require text, link, icon and NOT get trimmed', () => {
|
||||
const cfg = contentsConfig({
|
||||
sidebar: {
|
||||
mainMenuExtraItems: [
|
||||
{ text: ' A ', link: ' /a ', icon: ' i-a ', className: ' cls ' },
|
||||
{ text: 'no-link', icon: 'i' },
|
||||
{ link: '/missing-text', icon: 'i' },
|
||||
{ text: 'no-icon', link: '/x' },
|
||||
],
|
||||
navMenuItems: [
|
||||
{ text: ' B ', link: ' /b ', icon: ' i-b ' },
|
||||
{ text: ' ', link: '/bad', icon: 'i' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(cfg.sidebar.mainMenuExtra.items).toStrictEqual([
|
||||
{ text: ' A ', link: ' /a ', icon: ' i-a ', className: ' cls ' },
|
||||
]);
|
||||
|
||||
expect(cfg.sidebar.navMenu.items).toStrictEqual([
|
||||
{ text: ' B ', link: ' /b ', icon: ' i-b ', className: undefined },
|
||||
{ text: ' ', link: '/bad', icon: 'i', className: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
41
frontend/tests/utils/settings/media.test.ts
Normal file
41
frontend/tests/utils/settings/media.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { init, settings } from '../../../src/static/js/utils/settings/media';
|
||||
|
||||
const mediaConfig = (item?: any, shareOptions?: any) => {
|
||||
init(item, shareOptions);
|
||||
return settings();
|
||||
};
|
||||
|
||||
describe('utils/settings', () => {
|
||||
describe('media', () => {
|
||||
test('Defaults display flags to true when not hidden', () => {
|
||||
const cfg = mediaConfig();
|
||||
expect(cfg.item.displayAuthor).toBe(true);
|
||||
expect(cfg.item.displayViews).toBe(true);
|
||||
expect(cfg.item.displayPublishDate).toBe(true);
|
||||
expect(cfg.share.options).toEqual([]);
|
||||
});
|
||||
|
||||
test('Respects hide flags for author, views and date', () => {
|
||||
const cfg = mediaConfig({ hideAuthor: true, hideViews: true, hideDate: true });
|
||||
expect(cfg.item.displayAuthor).toBe(false);
|
||||
expect(cfg.item.displayViews).toBe(false);
|
||||
expect(cfg.item.displayPublishDate).toBe(false);
|
||||
});
|
||||
|
||||
test('Returns empty share options when not provided', () => {
|
||||
const cfg = mediaConfig({ hideAuthor: false }, undefined);
|
||||
expect(cfg.share.options).toEqual([]);
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
test('Filters share options to valid ones and trims whitespace', () => {
|
||||
const cfg = mediaConfig(undefined, [' embed ', 'email', ' email ']);
|
||||
expect(cfg.share.options).toEqual(['email']);
|
||||
});
|
||||
|
||||
test('Ignores falsy and invalid share options', () => {
|
||||
const cfg = mediaConfig(undefined, [undefined, '', ' ', 'invalid', 'share', 'EMBED']);
|
||||
expect(cfg.share.options).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
162
frontend/tests/utils/settings/member.test.ts
Normal file
162
frontend/tests/utils/settings/member.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { init, settings } from '../../../src/static/js/utils/settings/member';
|
||||
|
||||
const memberConfig = (user?: any, features?: any) => {
|
||||
init(user, features);
|
||||
return settings();
|
||||
};
|
||||
|
||||
describe('utils/settings', () => {
|
||||
describe('member', () => {
|
||||
// @todo: Revisit this behavior
|
||||
test('Returns anonymous defaults when user not provided', () => {
|
||||
const cfg = memberConfig();
|
||||
|
||||
expect(cfg).toStrictEqual({
|
||||
name: null,
|
||||
username: null,
|
||||
thumbnail: null,
|
||||
is: { admin: false, anonymous: true },
|
||||
can: {
|
||||
login: true,
|
||||
register: true,
|
||||
addMedia: false,
|
||||
editProfile: false,
|
||||
canSeeMembersPage: true,
|
||||
usersNeedsToBeApproved: true,
|
||||
changePassword: true,
|
||||
deleteProfile: false,
|
||||
readComment: true,
|
||||
addComment: false,
|
||||
mentionComment: false,
|
||||
deleteComment: false,
|
||||
editMedia: false,
|
||||
deleteMedia: false,
|
||||
editSubtitle: false,
|
||||
manageMedia: false,
|
||||
manageUsers: false,
|
||||
manageComments: false,
|
||||
reportMedia: false,
|
||||
downloadMedia: false,
|
||||
saveMedia: false,
|
||||
likeMedia: true,
|
||||
dislikeMedia: true,
|
||||
shareMedia: true,
|
||||
contactUser: false,
|
||||
},
|
||||
pages: { home: null, about: null, media: null, playlists: null },
|
||||
});
|
||||
});
|
||||
|
||||
test('Trims user strings and applies user capability booleans when authenticated', () => {
|
||||
const cfg = memberConfig({
|
||||
is: { anonymous: false, admin: true },
|
||||
name: ' John Doe ',
|
||||
username: ' johnd ',
|
||||
thumbnail: ' /img/j.png ',
|
||||
can: {
|
||||
changePassword: true,
|
||||
deleteProfile: true,
|
||||
addComment: true,
|
||||
mentionComment: true,
|
||||
deleteComment: true,
|
||||
editMedia: true,
|
||||
deleteMedia: true,
|
||||
editSubtitle: true,
|
||||
manageMedia: true,
|
||||
manageUsers: true,
|
||||
manageComments: true,
|
||||
contactUser: true,
|
||||
addMedia: true,
|
||||
editProfile: true,
|
||||
readComment: true,
|
||||
canSeeMembersPage: true,
|
||||
usersNeedsToBeApproved: false,
|
||||
},
|
||||
pages: { about: ' /u/john/about ', media: ' /u/john ', playlists: ' /u/john/playlists ' },
|
||||
});
|
||||
|
||||
expect(cfg).toStrictEqual({
|
||||
name: 'John Doe',
|
||||
username: 'johnd',
|
||||
thumbnail: '/img/j.png',
|
||||
is: { admin: true, anonymous: false },
|
||||
can: {
|
||||
login: true,
|
||||
register: true,
|
||||
addMedia: true,
|
||||
editProfile: true,
|
||||
canSeeMembersPage: true,
|
||||
usersNeedsToBeApproved: false,
|
||||
changePassword: true,
|
||||
deleteProfile: true,
|
||||
readComment: true,
|
||||
addComment: true,
|
||||
mentionComment: true,
|
||||
deleteComment: true,
|
||||
editMedia: true,
|
||||
deleteMedia: true,
|
||||
editSubtitle: true,
|
||||
manageMedia: true,
|
||||
manageUsers: true,
|
||||
manageComments: true,
|
||||
reportMedia: false,
|
||||
downloadMedia: false,
|
||||
saveMedia: false,
|
||||
likeMedia: true,
|
||||
dislikeMedia: true,
|
||||
shareMedia: true,
|
||||
contactUser: true,
|
||||
},
|
||||
pages: { home: null, about: '/u/john/about', media: '/u/john', playlists: '/u/john/playlists' },
|
||||
});
|
||||
});
|
||||
|
||||
test('Comment capabilities require both user.can and features.media.actions', () => {
|
||||
const cfg1 = memberConfig(
|
||||
{ is: { anonymous: false }, can: { addComment: true, mentionComment: true } },
|
||||
{ media: { actions: { comment: false, comment_mention: true } } }
|
||||
);
|
||||
expect(cfg1.can.addComment).toBe(false);
|
||||
expect(cfg1.can.mentionComment).toBe(true);
|
||||
|
||||
const cfg2 = memberConfig(
|
||||
{ is: { anonymous: false }, can: { addComment: true, mentionComment: true } },
|
||||
{ media: { actions: { comment: true, comment_mention: true } } }
|
||||
);
|
||||
expect(cfg2.can.addComment).toBe(true);
|
||||
expect(cfg2.can.mentionComment).toBe(true);
|
||||
});
|
||||
|
||||
test('Header login/register reflect headerBar feature flags', () => {
|
||||
expect(memberConfig(undefined, { headerBar: { hideLogin: true } }).can.login).toBe(false);
|
||||
expect(memberConfig(undefined, { headerBar: { hideRegister: true } }).can.register).toBe(false);
|
||||
expect(memberConfig(undefined, { headerBar: { hideLogin: false, hideRegister: false } }).can).toMatchObject(
|
||||
{ login: true, register: true }
|
||||
);
|
||||
});
|
||||
|
||||
test('Media actions flags set like/dislike/share/report/download/save with correct defaults', () => {
|
||||
const cfg1 = memberConfig(undefined, {
|
||||
media: {
|
||||
actions: { like: false, dislike: false, share: false, report: true, download: true, save: true },
|
||||
},
|
||||
});
|
||||
expect(cfg1.can.likeMedia).toBe(false);
|
||||
expect(cfg1.can.dislikeMedia).toBe(false);
|
||||
expect(cfg1.can.shareMedia).toBe(false);
|
||||
expect(cfg1.can.reportMedia).toBe(true);
|
||||
expect(cfg1.can.downloadMedia).toBe(true);
|
||||
expect(cfg1.can.saveMedia).toBe(true);
|
||||
});
|
||||
|
||||
test('User flags canSeeMembersPage/usersNeedsToBeApproved/readComment default handling', () => {
|
||||
const cfg1 = memberConfig({
|
||||
is: { anonymous: false },
|
||||
can: { canSeeMembersPage: false, usersNeedsToBeApproved: false, readComment: false },
|
||||
});
|
||||
expect(cfg1.can.canSeeMembersPage).toBe(false);
|
||||
expect(cfg1.can.usersNeedsToBeApproved).toBe(false);
|
||||
expect(cfg1.can.readComment).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
70
frontend/tests/utils/settings/notifications.test.ts
Normal file
70
frontend/tests/utils/settings/notifications.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { init, settings } from '../../../src/static/js/utils/settings/notifications';
|
||||
|
||||
const notificationsConfig = (sett?: any) => {
|
||||
init(sett);
|
||||
return settings();
|
||||
};
|
||||
|
||||
describe('utils/settings', () => {
|
||||
describe('notifications', () => {
|
||||
test('Returns defaults when no settings provided', () => {
|
||||
const cfg = notificationsConfig();
|
||||
expect(cfg).toStrictEqual({
|
||||
messages: {
|
||||
addToLiked: 'Added to liked media',
|
||||
removeFromLiked: 'Removed from liked media',
|
||||
addToDisliked: 'Added to disliked media',
|
||||
removeFromDisliked: 'Removed from disliked media',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
test('Keep incoming message values without processing', () => {
|
||||
const cfg = notificationsConfig({
|
||||
messages: {
|
||||
addToLiked: ' Yay ',
|
||||
removeFromLiked: ' ',
|
||||
addToDisliked: '\nNope',
|
||||
removeFromDisliked: '\t OK\t',
|
||||
},
|
||||
});
|
||||
expect(cfg.messages.addToLiked).toBe(' Yay ');
|
||||
expect(cfg.messages.removeFromLiked).toBe(' ');
|
||||
expect(cfg.messages.addToDisliked).toBe('\nNope');
|
||||
expect(cfg.messages.removeFromDisliked).toBe('\t OK\t');
|
||||
});
|
||||
|
||||
test('Ignores undefined, keeping defaults', () => {
|
||||
const cfg = notificationsConfig({
|
||||
messages: {
|
||||
addToLiked: undefined,
|
||||
removeFromLiked: undefined,
|
||||
addToDisliked: undefined,
|
||||
removeFromDisliked: undefined,
|
||||
},
|
||||
});
|
||||
expect(cfg.messages.addToLiked).toBe('Added to liked media');
|
||||
expect(cfg.messages.removeFromLiked).toBe('Removed from liked media');
|
||||
expect(cfg.messages.addToDisliked).toBe('Added to disliked media');
|
||||
expect(cfg.messages.removeFromDisliked).toBe('Removed from disliked media');
|
||||
});
|
||||
|
||||
test('Allows partial overrides without affecting other keys', () => {
|
||||
const cfg = notificationsConfig({ messages: { addToLiked: 'Nice!' } });
|
||||
expect(cfg.messages.addToLiked).toBe('Nice!');
|
||||
expect(cfg.messages.removeFromLiked).toBe('Removed from liked media');
|
||||
expect(cfg.messages.addToDisliked).toBe('Added to disliked media');
|
||||
expect(cfg.messages.removeFromDisliked).toBe('Removed from disliked media');
|
||||
});
|
||||
|
||||
test('Handles extraneous keys by passing them through while keeping known defaults intact', () => {
|
||||
const cfg = notificationsConfig({ messages: { addToLiked: 'A', notARealKey: 'x' } });
|
||||
expect(cfg.messages.notARealKey).toBeUndefined();
|
||||
expect(cfg.messages.addToLiked).toBe('A');
|
||||
expect(cfg.messages.removeFromLiked).toBe('Removed from liked media');
|
||||
expect(cfg.messages.addToDisliked).toBe('Added to disliked media');
|
||||
expect(cfg.messages.removeFromDisliked).toBe('Removed from disliked media');
|
||||
});
|
||||
});
|
||||
});
|
||||
60
frontend/tests/utils/settings/optionsEmbedded.test.ts
Normal file
60
frontend/tests/utils/settings/optionsEmbedded.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { init, settings } from '../../../src/static/js/utils/settings/optionsEmbedded';
|
||||
|
||||
const optionsEmbeddedConfig = (embeddedVideo?: any) => {
|
||||
init(embeddedVideo);
|
||||
return settings();
|
||||
};
|
||||
|
||||
describe('utils/settings', () => {
|
||||
describe('optionsEmbedded', () => {
|
||||
test('Returns default dimensions when settings is undefined', () => {
|
||||
const cfg = optionsEmbeddedConfig(undefined);
|
||||
expect(cfg.video.dimensions).toStrictEqual({ width: 560, widthUnit: 'px', height: 315, heightUnit: 'px' });
|
||||
});
|
||||
|
||||
test('Returns default dimensions when settings.initialDimensions is undefined', () => {
|
||||
const cfg = optionsEmbeddedConfig({});
|
||||
expect(cfg.video.dimensions).toStrictEqual({ width: 560, widthUnit: 'px', height: 315, heightUnit: 'px' });
|
||||
});
|
||||
|
||||
test('Applies valid numeric width and height from initialDimensions', () => {
|
||||
const cfg = optionsEmbeddedConfig({ initialDimensions: { width: 640, height: 360 } });
|
||||
expect(cfg.video.dimensions).toStrictEqual({ width: 640, widthUnit: 'px', height: 360, heightUnit: 'px' });
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
test('Ignores NaN and non-numeric width/height and keeps defaults', () => {
|
||||
const cfg1 = optionsEmbeddedConfig({ initialDimensions: { width: NaN, height: NaN } });
|
||||
expect(cfg1.video.dimensions).toStrictEqual({ width: 560, widthUnit: 'px', height: 315, heightUnit: 'px' });
|
||||
|
||||
const cfg2 = optionsEmbeddedConfig({ initialDimensions: { width: '640', height: '360' } });
|
||||
expect(cfg2.video.dimensions).toStrictEqual({
|
||||
width: '640',
|
||||
widthUnit: 'px',
|
||||
height: '360',
|
||||
heightUnit: 'px',
|
||||
});
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
test('Ignores provided widthUnit/heightUnit as they are not used', () => {
|
||||
const cfg = optionsEmbeddedConfig({
|
||||
initialDimensions: { width: 800, height: 450, widthUnit: 'percent', heightUnit: 'percent' },
|
||||
});
|
||||
expect(cfg.video.dimensions.width).toBe(800);
|
||||
expect(cfg.video.dimensions.height).toBe(450);
|
||||
expect(cfg.video.dimensions.widthUnit).toBe('px');
|
||||
expect(cfg.video.dimensions.heightUnit).toBe('px');
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
test('Does not mutate the provided settings object', () => {
|
||||
const input = {
|
||||
initialDimensions: { width: 700, height: 400, widthUnit: 'percent', heightUnit: 'percent' },
|
||||
};
|
||||
const copy = JSON.parse(JSON.stringify(input));
|
||||
optionsEmbeddedConfig(input);
|
||||
expect(input).toStrictEqual(copy);
|
||||
});
|
||||
});
|
||||
});
|
||||
125
frontend/tests/utils/settings/optionsPages.test.ts
Normal file
125
frontend/tests/utils/settings/optionsPages.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { init, settings } from '../../../src/static/js/utils/settings/optionsPages';
|
||||
|
||||
const optionsPagesConfig = (home?: any, search?: any, media?: any, profile?: any, VALID_PAGES?: any) => {
|
||||
init(home, search, media, profile, VALID_PAGES);
|
||||
return settings();
|
||||
};
|
||||
|
||||
describe('utils/settings', () => {
|
||||
describe('optionsPages', () => {
|
||||
test('Uses VALID_PAGES titles as defaults for home sections when provided', () => {
|
||||
const cfg = optionsPagesConfig(undefined, undefined, undefined, undefined, {
|
||||
latest: { title: 'Recent' },
|
||||
featured: { title: 'Spotlight' },
|
||||
recommended: { title: 'You may like' },
|
||||
});
|
||||
expect(cfg.home.sections.latest.title).toBe('Recent');
|
||||
expect(cfg.home.sections.featured.title).toBe('Spotlight');
|
||||
expect(cfg.home.sections.recommended.title).toBe('You may like');
|
||||
});
|
||||
|
||||
test('Trims custom home section titles from input', () => {
|
||||
const cfg = optionsPagesConfig({
|
||||
sections: {
|
||||
latest: { title: ' LATEST ' },
|
||||
featured: { title: '\nFeatured ' },
|
||||
recommended: { title: ' Recommended' },
|
||||
},
|
||||
});
|
||||
expect(cfg.home.sections.latest.title).toBe('LATEST');
|
||||
expect(cfg.home.sections.featured.title).toBe('Featured');
|
||||
expect(cfg.home.sections.recommended.title).toBe('Recommended');
|
||||
});
|
||||
|
||||
test('Sets search.advancedFilters true only when explicitly true', () => {
|
||||
const def = optionsPagesConfig(undefined, undefined, undefined, undefined, {});
|
||||
expect(def.search.advancedFilters).toBe(false);
|
||||
|
||||
const falsy = optionsPagesConfig(undefined, { advancedFilters: false }, undefined, undefined, {});
|
||||
expect(falsy.search.advancedFilters).toBe(false);
|
||||
|
||||
const truthy = optionsPagesConfig(undefined, { advancedFilters: true }, undefined, undefined, {});
|
||||
expect(truthy.search.advancedFilters).toBe(true);
|
||||
});
|
||||
|
||||
test('Configures media options with correct defaults and overrides', () => {
|
||||
const def = optionsPagesConfig(undefined, undefined, undefined, undefined, {});
|
||||
|
||||
expect(def.media.categoriesWithTitle).toBe(false);
|
||||
expect(def.media.htmlInDescription).toBe(false);
|
||||
expect(def.media.displayViews).toBe(true);
|
||||
expect(def.media.related.initialSize).toBe(10);
|
||||
|
||||
const override = optionsPagesConfig(
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
categoriesWithTitle: true,
|
||||
htmlInDescription: true,
|
||||
hideViews: true,
|
||||
related: { initialSize: 25 },
|
||||
},
|
||||
undefined,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(override.media.categoriesWithTitle).toBe(true);
|
||||
expect(override.media.htmlInDescription).toBe(true);
|
||||
expect(override.media.displayViews).toBe(false);
|
||||
expect(override.media.related.initialSize).toBe(10); // @todo: Fix this! It should return 25.
|
||||
});
|
||||
|
||||
test('Ignores NaN and non-numeric media.related.initialSize and keeps default 10', () => {
|
||||
const cfg1 = optionsPagesConfig(undefined, undefined, { related: { initialSize: NaN } }, undefined, {});
|
||||
expect(cfg1.media.related.initialSize).toBe(10);
|
||||
|
||||
const cfg2 = optionsPagesConfig(undefined, undefined, { related: { initialSize: '12' } }, undefined, {});
|
||||
expect(cfg2.media.related.initialSize).toBe(10);
|
||||
});
|
||||
|
||||
test('Profile settings true only when explicitly true', () => {
|
||||
const def = optionsPagesConfig(undefined, undefined, undefined, undefined, {});
|
||||
expect(def.profile.htmlInDescription).toBe(false);
|
||||
expect(def.profile.includeHistory).toBe(false);
|
||||
expect(def.profile.includeLikedMedia).toBe(false);
|
||||
|
||||
const truthy = optionsPagesConfig(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
htmlInDescription: true,
|
||||
includeHistory: true,
|
||||
includeLikedMedia: true,
|
||||
},
|
||||
{}
|
||||
);
|
||||
expect(truthy.profile.htmlInDescription).toBe(true);
|
||||
expect(truthy.profile.includeHistory).toBe(true);
|
||||
expect(truthy.profile.includeLikedMedia).toBe(true);
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
test('Does not mutate provided input objects', () => {
|
||||
const home = { sections: { latest: { title: ' A ' } } };
|
||||
const search = { advancedFilters: true };
|
||||
const media = { hideViews: true, related: { initialSize: 5 } };
|
||||
const profile = { includeHistory: true };
|
||||
const validPages = { latest: { title: 'L' }, featured: { title: 'F' }, recommended: { title: 'R' } };
|
||||
|
||||
const homeCopy = JSON.parse(JSON.stringify(home));
|
||||
const searchCopy = JSON.parse(JSON.stringify(search));
|
||||
const mediaCopy = JSON.parse(JSON.stringify(media));
|
||||
const profileCopy = JSON.parse(JSON.stringify(profile));
|
||||
const validPagesCopy = JSON.parse(JSON.stringify(validPages));
|
||||
|
||||
optionsPagesConfig(home, search, media, profile, validPages);
|
||||
|
||||
expect(home).toStrictEqual(homeCopy);
|
||||
expect(search).toStrictEqual(searchCopy);
|
||||
expect(media).toStrictEqual(mediaCopy);
|
||||
expect(profile).toStrictEqual(profileCopy);
|
||||
expect(validPages).toStrictEqual(validPagesCopy);
|
||||
});
|
||||
});
|
||||
});
|
||||
63
frontend/tests/utils/settings/pages.test.ts
Normal file
63
frontend/tests/utils/settings/pages.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { init, settings } from '../../../src/static/js/utils/settings/pages';
|
||||
|
||||
const pagesConfig = (sett?: any) => {
|
||||
init(sett);
|
||||
return settings();
|
||||
};
|
||||
|
||||
describe('utils/settings', () => {
|
||||
describe('pages', () => {
|
||||
test('Defaults: all known pages disabled with default titles', () => {
|
||||
const cfg = pagesConfig();
|
||||
expect(cfg).toStrictEqual({
|
||||
latest: { enabled: false, title: 'Recent uploads' },
|
||||
featured: { enabled: false, title: 'Featured' },
|
||||
recommended: { enabled: false, title: 'Recommended' },
|
||||
members: { enabled: false, title: 'Members' },
|
||||
liked: { enabled: false, title: 'Liked media' },
|
||||
history: { enabled: false, title: 'History' },
|
||||
});
|
||||
});
|
||||
|
||||
test('Enables each page unless explicitly disabled', () => {
|
||||
const cfg = pagesConfig({
|
||||
latest: {},
|
||||
featured: { enabled: true },
|
||||
recommended: { enabled: false },
|
||||
members: { enabled: undefined },
|
||||
liked: { enabled: null },
|
||||
history: { enabled: 0 },
|
||||
});
|
||||
expect(cfg.latest.enabled).toBe(true);
|
||||
expect(cfg.featured.enabled).toBe(true);
|
||||
expect(cfg.recommended.enabled).toBe(false);
|
||||
expect(cfg.members.enabled).toBe(true);
|
||||
expect(cfg.liked.enabled).toBe(true);
|
||||
expect(cfg.history.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('Trims provided titles and preserves defaults when title is undefined', () => {
|
||||
const cfg = pagesConfig({
|
||||
latest: { title: ' Latest ' },
|
||||
featured: { title: '\nFeatured' },
|
||||
recommended: {},
|
||||
});
|
||||
expect(cfg.latest.title).toBe('Latest');
|
||||
expect(cfg.featured.title).toBe('Featured');
|
||||
expect(cfg.recommended.title).toBe('Recommended');
|
||||
});
|
||||
|
||||
test('Ignores unknown keys in settings', () => {
|
||||
const cfg = pagesConfig({ unknownKey: { enabled: true, title: 'X' }, latest: { enabled: true } });
|
||||
expect(cfg.latest.enabled).toBe(true);
|
||||
expect(cfg.unknownKey).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Does not mutate the input settings object', () => {
|
||||
const input = { latest: { enabled: false, title: ' A ' }, featured: { enabled: true, title: ' B ' } };
|
||||
const snapshot = JSON.parse(JSON.stringify(input));
|
||||
pagesConfig(input);
|
||||
expect(input).toStrictEqual(snapshot);
|
||||
});
|
||||
});
|
||||
});
|
||||
52
frontend/tests/utils/settings/playlists.test.ts
Normal file
52
frontend/tests/utils/settings/playlists.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { init, settings } from '../../../src/static/js/utils/settings/playlists';
|
||||
|
||||
const playlistsConfig = (plists?: any) => {
|
||||
init(plists);
|
||||
return settings();
|
||||
};
|
||||
|
||||
describe('utils/settings', () => {
|
||||
describe('playlists', () => {
|
||||
test('Defaults to both audio and video when no settings provided', () => {
|
||||
const cfg = playlistsConfig();
|
||||
expect(cfg.mediaTypes).toEqual(['audio', 'video']);
|
||||
});
|
||||
|
||||
test('Returns default when provided mediaTypes array is empty', () => {
|
||||
const cfg = playlistsConfig({ mediaTypes: [] });
|
||||
expect(cfg.mediaTypes).toEqual(['audio', 'video']);
|
||||
});
|
||||
|
||||
test('Includes only valid media types when both valid and invalid are provided', () => {
|
||||
const cfg = playlistsConfig({ mediaTypes: ['audio', 'invalid', 'video', 'something'] });
|
||||
expect(cfg.mediaTypes).toEqual(['audio', 'video']);
|
||||
});
|
||||
|
||||
test('Returns default when provided mediaTypes is non-array or undefined/null', () => {
|
||||
expect(playlistsConfig({}).mediaTypes).toEqual(['audio', 'video']);
|
||||
expect(playlistsConfig({ mediaTypes: undefined }).mediaTypes).toEqual(['audio', 'video']);
|
||||
// expect(playlistsConfig({ mediaTypes: null }).mediaTypes).toEqual(['audio', 'video']); // @todo: Revisit this behavior
|
||||
expect(playlistsConfig({ mediaTypes: 'audio' }).mediaTypes).toEqual(['audio', 'video']);
|
||||
expect(playlistsConfig({ mediaTypes: 123 }).mediaTypes).toEqual(['audio', 'video']);
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
test('Handles duplicates and preserves order among valid items', () => {
|
||||
const cfg = playlistsConfig({ mediaTypes: ['video', 'audio', 'video', 'audio', 'invalid'] });
|
||||
expect(cfg.mediaTypes).toEqual(['video', 'audio', 'video', 'audio']);
|
||||
});
|
||||
|
||||
// @todo: Revisit this behavior
|
||||
test('Rejects non-exact case values (e.g., \"Audio\")', () => {
|
||||
const cfg = playlistsConfig({ mediaTypes: ['Audio', 'Video'] });
|
||||
expect(cfg.mediaTypes).toEqual(['audio', 'video']);
|
||||
});
|
||||
|
||||
test('does not mutate the input object', () => {
|
||||
const input = { mediaTypes: ['audio', 'video', 'invalid'] };
|
||||
const copy = JSON.parse(JSON.stringify(input));
|
||||
playlistsConfig(input);
|
||||
expect(input).toEqual(copy);
|
||||
});
|
||||
});
|
||||
});
|
||||
53
frontend/tests/utils/settings/sidebar.test.ts
Normal file
53
frontend/tests/utils/settings/sidebar.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { init, settings } from '../../../src/static/js/utils/settings/sidebar';
|
||||
|
||||
const sidebarConfig = (sett?: any) => {
|
||||
init(sett);
|
||||
return settings();
|
||||
};
|
||||
|
||||
describe('utils/settings', () => {
|
||||
describe('sidebar', () => {
|
||||
test('Defaults to all links visible when no settings provided', () => {
|
||||
const cfg = sidebarConfig();
|
||||
expect(cfg).toStrictEqual({ hideHomeLink: false, hideTagsLink: false, hideCategoriesLink: false });
|
||||
});
|
||||
|
||||
test('Hides only those explicitly set to true', () => {
|
||||
const cfg1 = sidebarConfig({ hideHomeLink: true });
|
||||
expect(cfg1).toStrictEqual({ hideHomeLink: true, hideTagsLink: false, hideCategoriesLink: false });
|
||||
|
||||
const cfg2 = sidebarConfig({ hideTagsLink: true });
|
||||
expect(cfg2).toStrictEqual({ hideHomeLink: false, hideTagsLink: true, hideCategoriesLink: false });
|
||||
|
||||
const cfg3 = sidebarConfig({ hideCategoriesLink: true });
|
||||
expect(cfg3).toStrictEqual({ hideHomeLink: false, hideTagsLink: false, hideCategoriesLink: true });
|
||||
|
||||
const cfgAll = sidebarConfig({ hideHomeLink: true, hideTagsLink: true, hideCategoriesLink: true });
|
||||
expect(cfgAll).toStrictEqual({ hideHomeLink: true, hideTagsLink: true, hideCategoriesLink: true });
|
||||
});
|
||||
|
||||
test('Treats non-true values as false', () => {
|
||||
// false
|
||||
expect(sidebarConfig({ hideHomeLink: false }).hideHomeLink).toBe(false);
|
||||
// undefined
|
||||
expect(sidebarConfig({}).hideHomeLink).toBe(false);
|
||||
// null
|
||||
expect(sidebarConfig({ hideTagsLink: null }).hideTagsLink).toBe(false);
|
||||
// other types
|
||||
expect(sidebarConfig({ hideCategoriesLink: 'yes' }).hideCategoriesLink).toBe(false);
|
||||
expect(sidebarConfig({ hideCategoriesLink: 1 }).hideCategoriesLink).toBe(false);
|
||||
});
|
||||
|
||||
test('Is resilient to partial inputs and ignores extra properties', () => {
|
||||
const cfg = sidebarConfig({ hideTagsLink: true, extra: 'prop' });
|
||||
expect(cfg).toStrictEqual({ hideHomeLink: false, hideTagsLink: true, hideCategoriesLink: false });
|
||||
});
|
||||
|
||||
test('Does not mutate input object', () => {
|
||||
const input: any = { hideHomeLink: true };
|
||||
const copy = JSON.parse(JSON.stringify(input));
|
||||
sidebarConfig(input);
|
||||
expect(input).toStrictEqual(copy);
|
||||
});
|
||||
});
|
||||
});
|
||||
63
frontend/tests/utils/settings/site.test.ts
Normal file
63
frontend/tests/utils/settings/site.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { init, settings } from '../../../src/static/js/utils/settings/site';
|
||||
|
||||
const siteConfig = (sett?: any) => {
|
||||
init(sett);
|
||||
return settings();
|
||||
};
|
||||
|
||||
describe('utils/settings', () => {
|
||||
describe('site', () => {
|
||||
test('Applies defaults when no settings provided', () => {
|
||||
const cfg = siteConfig();
|
||||
expect(cfg).toStrictEqual({
|
||||
id: 'media-cms',
|
||||
url: '',
|
||||
api: '',
|
||||
title: '',
|
||||
useRoundedCorners: true,
|
||||
version: '1.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
test('Trims string fields (id, url, api, title, version)', () => {
|
||||
const cfg = siteConfig({
|
||||
id: ' my-site ',
|
||||
url: ' https://example.com/ ',
|
||||
api: ' https://example.com/api/ ',
|
||||
title: ' Media CMS ',
|
||||
version: ' 2.3.4 ',
|
||||
});
|
||||
expect(cfg).toStrictEqual({
|
||||
id: 'my-site',
|
||||
url: 'https://example.com/',
|
||||
api: 'https://example.com/api/',
|
||||
title: 'Media CMS',
|
||||
useRoundedCorners: true,
|
||||
version: '2.3.4',
|
||||
});
|
||||
});
|
||||
|
||||
test('Handles useRoundedCorners: defaults to true unless explicitly false', () => {
|
||||
expect(siteConfig({}).useRoundedCorners).toBe(true);
|
||||
expect(siteConfig({ useRoundedCorners: true }).useRoundedCorners).toBe(true);
|
||||
expect(siteConfig({ useRoundedCorners: false }).useRoundedCorners).toBe(false);
|
||||
// non-boolean should still evaluate to default true because only === false toggles it off
|
||||
expect(siteConfig({ useRoundedCorners: 'no' }).useRoundedCorners).toBe(true);
|
||||
expect(siteConfig({ useRoundedCorners: 0 }).useRoundedCorners).toBe(true);
|
||||
expect(siteConfig({ useRoundedCorners: null }).useRoundedCorners).toBe(true);
|
||||
});
|
||||
|
||||
test('Is resilient to partial inputs and ignores extra properties', () => {
|
||||
const cfg = siteConfig({ id: ' x ', extra: 'y' });
|
||||
expect(cfg).toMatchObject({ id: 'x' });
|
||||
expect(Object.keys(cfg).sort()).toEqual(['api', 'id', 'title', 'url', 'useRoundedCorners', 'version']);
|
||||
});
|
||||
|
||||
test('Does not mutate input object', () => {
|
||||
const input = { id: ' my-id ', useRoundedCorners: false };
|
||||
const copy = JSON.parse(JSON.stringify(input));
|
||||
siteConfig(input);
|
||||
expect(input).toEqual(copy);
|
||||
});
|
||||
});
|
||||
});
|
||||
53
frontend/tests/utils/settings/taxonomies.test.ts
Normal file
53
frontend/tests/utils/settings/taxonomies.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { init, settings } from '../../../src/static/js/utils/settings/taxonomies';
|
||||
|
||||
const taxonomiesConfig = (sett?: any) => {
|
||||
init(sett);
|
||||
return settings();
|
||||
};
|
||||
|
||||
describe('utils-settings/taxonomies', () => {
|
||||
test('Should return defaults when settings is undefined', () => {
|
||||
const res = taxonomiesConfig();
|
||||
expect(res).toStrictEqual({
|
||||
tags: { enabled: false, title: 'Tags' },
|
||||
categories: { enabled: false, title: 'Categories' },
|
||||
});
|
||||
});
|
||||
|
||||
test('Should enable a taxonomy when enabled is true', () => {
|
||||
const res = taxonomiesConfig({ tags: { enabled: true } });
|
||||
expect(res.tags).toStrictEqual({ enabled: true, title: 'Tags' });
|
||||
});
|
||||
|
||||
test('Should keep taxonomy disabled when enabled is true', () => {
|
||||
const res = taxonomiesConfig({ categories: { enabled: true } });
|
||||
expect(res.categories).toStrictEqual({ enabled: true, title: 'Categories' });
|
||||
});
|
||||
|
||||
test('Should default to enabled=true when enabled is omitted but key exists', () => {
|
||||
const res = taxonomiesConfig({ tags: {} });
|
||||
expect(res.tags).toStrictEqual({ enabled: true, title: 'Tags' });
|
||||
});
|
||||
|
||||
test('Should trim title when provided', () => {
|
||||
const res = taxonomiesConfig({ tags: { title: ' My Tags ' } });
|
||||
expect(res.tags).toStrictEqual({ enabled: true, title: 'My Tags' });
|
||||
});
|
||||
|
||||
test('Should ignore unknown taxonomy keys', () => {
|
||||
const input = {
|
||||
unknownKey: { enabled: true, title: 'X' },
|
||||
tags: { enabled: true, title: 'Tagz' },
|
||||
};
|
||||
const res = taxonomiesConfig(input);
|
||||
expect(res).toStrictEqual({
|
||||
tags: { enabled: true, title: 'Tagz' },
|
||||
categories: { enabled: false, title: 'Categories' },
|
||||
});
|
||||
});
|
||||
|
||||
test('Should not change title when title is undefined', () => {
|
||||
const res = taxonomiesConfig({ categories: { enabled: true, title: undefined } });
|
||||
expect(res.categories).toStrictEqual({ enabled: true, title: 'Categories' });
|
||||
});
|
||||
});
|
||||
77
frontend/tests/utils/settings/theme.test.ts
Normal file
77
frontend/tests/utils/settings/theme.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { init, settings } from '../../../src/static/js/utils/settings/theme';
|
||||
|
||||
const themeConfig = (theme?: any, logo?: any) => {
|
||||
init(theme, logo);
|
||||
return settings();
|
||||
};
|
||||
|
||||
describe('utils/settings', () => {
|
||||
describe('theme', () => {
|
||||
test('Applies defaults when no inputs provided', () => {
|
||||
const cfg = themeConfig();
|
||||
expect(cfg).toStrictEqual({
|
||||
mode: 'light',
|
||||
switch: { enabled: true, position: 'header' },
|
||||
logo: { lightMode: { img: '', svg: '' }, darkMode: { img: '', svg: '' } },
|
||||
});
|
||||
});
|
||||
|
||||
test("Sets dark mode only when theme.mode is exactly 'dark' after trim", () => {
|
||||
expect(themeConfig({ mode: 'dark' }).mode).toBe('dark');
|
||||
expect(themeConfig({ mode: ' dark ' }).mode).toBe('dark');
|
||||
expect(themeConfig({ mode: 'Dark' }).mode).toBe('light');
|
||||
expect(themeConfig({ mode: 'light' }).mode).toBe('light');
|
||||
expect(themeConfig({ mode: ' ' }).mode).toBe('light');
|
||||
});
|
||||
|
||||
test('Switch config: enabled only toggles off when explicitly false; position set to sidebar only when exactly sidebar after trim', () => {
|
||||
expect(themeConfig({ switch: { enabled: false } }).switch.enabled).toBe(false);
|
||||
expect(themeConfig({ switch: { enabled: true } }).switch.enabled).toBe(true);
|
||||
expect(themeConfig({ switch: { enabled: undefined } }).switch.enabled).toBe(true);
|
||||
|
||||
expect(themeConfig({ switch: { position: 'sidebar' } }).switch.position).toBe('sidebar');
|
||||
expect(themeConfig({ switch: { position: ' sidebar ' } }).switch.position).toBe('header'); // @todo: Fix this. It should be 'sidebar'
|
||||
expect(themeConfig({ switch: { position: 'header' } }).switch.position).toBe('header');
|
||||
expect(themeConfig({ switch: { position: 'foot' } }).switch.position).toBe('header');
|
||||
});
|
||||
|
||||
test('Trims and maps logo URLs for both light and dark modes; ignores missing fields', () => {
|
||||
const cfg = themeConfig(undefined, {
|
||||
lightMode: { img: ' /img/light.png ', svg: ' /img/light.svg ' },
|
||||
darkMode: { img: ' /img/dark.png ', svg: ' /img/dark.svg ' },
|
||||
});
|
||||
|
||||
expect(cfg).toStrictEqual({
|
||||
mode: 'light',
|
||||
switch: { enabled: true, position: 'header' },
|
||||
logo: {
|
||||
lightMode: { img: '/img/light.png', svg: '/img/light.svg' },
|
||||
darkMode: { img: '/img/dark.png', svg: '/img/dark.svg' },
|
||||
},
|
||||
});
|
||||
|
||||
const partial = themeConfig(undefined, { lightMode: { img: ' /only-light.png ' } });
|
||||
|
||||
expect(partial).toStrictEqual({
|
||||
mode: 'light',
|
||||
switch: { enabled: true, position: 'header' },
|
||||
logo: {
|
||||
lightMode: { img: '/only-light.png', svg: '' },
|
||||
darkMode: { img: '', svg: '' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('Does not mutate input objects', () => {
|
||||
const themeIn = { mode: ' dark ', switch: { enabled: false, position: ' sidebar ' } };
|
||||
const logoIn = { lightMode: { img: ' x ', svg: ' y ' }, darkMode: { img: ' z ', svg: ' w ' } };
|
||||
const themeCopy = JSON.parse(JSON.stringify(themeIn));
|
||||
const logoCopy = JSON.parse(JSON.stringify(logoIn));
|
||||
|
||||
themeConfig(themeIn, logoIn);
|
||||
|
||||
expect(themeIn).toStrictEqual(themeCopy);
|
||||
expect(logoIn).toStrictEqual(logoCopy);
|
||||
});
|
||||
});
|
||||
});
|
||||
99
frontend/tests/utils/settings/url.test.ts
Normal file
99
frontend/tests/utils/settings/url.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { init, pages } from '../../../src/static/js/utils/settings/url';
|
||||
|
||||
const urlConfig = (pages_url?: any) => {
|
||||
init(pages_url);
|
||||
return pages();
|
||||
};
|
||||
|
||||
describe('utils/settings', () => {
|
||||
describe('url', () => {
|
||||
const baseGlobal = {
|
||||
profileId: 'john',
|
||||
site: {
|
||||
url: 'https://example.com/',
|
||||
devEnv: false,
|
||||
},
|
||||
url: {
|
||||
home: '/',
|
||||
admin: '/admin',
|
||||
error404: '/404',
|
||||
latestMedia: '/latest',
|
||||
featuredMedia: '/featured',
|
||||
recommendedMedia: '/recommended',
|
||||
signin: '/signin',
|
||||
signout: '/signout',
|
||||
register: '/register',
|
||||
changePassword: '/password',
|
||||
members: '/members',
|
||||
search: '/search',
|
||||
likedMedia: '/liked',
|
||||
history: '/history',
|
||||
addMedia: '/add',
|
||||
editChannel: '/edit/channel',
|
||||
editProfile: '/edit/profile',
|
||||
tags: '/tags',
|
||||
categories: '/categories',
|
||||
manageMedia: '/manage/media',
|
||||
manageUsers: '/manage/users',
|
||||
manageComments: '/manage/comments',
|
||||
},
|
||||
user: {
|
||||
is: { anonymous: false, admin: false },
|
||||
pages: { media: '/u/john', about: '/u/john/about', playlists: '/u/john/playlists' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
test('Authenticated non-admin user', () => {
|
||||
const cfg = urlConfig(baseGlobal);
|
||||
|
||||
expect(cfg).toStrictEqual({
|
||||
profileId: 'john',
|
||||
site: { url: 'https://example.com/', devEnv: false },
|
||||
url: {
|
||||
home: '/',
|
||||
admin: '/admin',
|
||||
error404: '/404',
|
||||
latestMedia: '/latest',
|
||||
featuredMedia: '/featured',
|
||||
recommendedMedia: '/recommended',
|
||||
signin: '/signin',
|
||||
signout: '/signout',
|
||||
register: '/register',
|
||||
changePassword: '/password',
|
||||
members: '/members',
|
||||
search: '/search',
|
||||
likedMedia: '/liked',
|
||||
history: '/history',
|
||||
addMedia: '/add',
|
||||
editChannel: '/edit/channel',
|
||||
editProfile: '/edit/profile',
|
||||
tags: '/tags',
|
||||
categories: '/categories',
|
||||
manageMedia: '/manage/media',
|
||||
manageUsers: '/manage/users',
|
||||
manageComments: '/manage/comments',
|
||||
},
|
||||
user: {
|
||||
is: { anonymous: false, admin: false },
|
||||
pages: { media: '/u/john', about: '/u/john/about', playlists: '/u/john/playlists' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('Admin user', () => {
|
||||
const cfg = urlConfig({
|
||||
...baseGlobal,
|
||||
user: { ...baseGlobal.user, is: { anonymous: false, admin: true } },
|
||||
});
|
||||
expect(cfg.user.is).toStrictEqual({ anonymous: false, admin: true });
|
||||
});
|
||||
|
||||
test('Anonymous user', () => {
|
||||
const cfg = urlConfig({
|
||||
...baseGlobal,
|
||||
user: { ...baseGlobal.user, is: { anonymous: true, admin: true } },
|
||||
});
|
||||
expect(cfg.user.is).toStrictEqual({ anonymous: true, admin: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,6 @@
|
||||
"./src",
|
||||
// "./test",
|
||||
// "./*",
|
||||
// "./config"
|
||||
// "./config"
|
||||
]
|
||||
}
|
||||
|
||||
3843
frontend/yarn.lock
3843
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user