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:
42
.github/workflows/frontend-build-and-test.yml
vendored
Normal file
42
.github/workflows/frontend-build-and-test.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: Frontend build and test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
node: [20]
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: '${{ matrix.os }} - node v${{ matrix.node }}'
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./frontend
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build script
|
||||
run: npm run dist
|
||||
|
||||
- name: Test script
|
||||
run: npm run test
|
||||
@@ -1 +1 @@
|
||||
VERSION = "7.5"
|
||||
VERSION = "7.6"
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
3843
frontend/yarn.lock
3843
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
!function(){"use strict";var n,e={4256:function(n,e,r){(0,r(2985).C)()}},r={};function t(n){var o=r[n];if(void 0!==o)return o.exports;var i=r[n]={exports:{}};return e[n].call(i.exports,i,i.exports,t),i.exports}t.m=e,n=[],t.O=function(e,r,o,i){if(!r){var u=1/0;for(l=0;l<n.length;l++){r=n[l][0],o=n[l][1],i=n[l][2];for(var f=!0,c=0;c<r.length;c++)(!1&i||u>=i)&&Object.keys(t.O).every((function(n){return t.O[n](r[c])}))?r.splice(c--,1):(f=!1,i<u&&(u=i));if(f){n.splice(l--,1);var a=o();void 0!==a&&(e=a)}}return e}i=i||0;for(var l=n.length;l>0&&n[l-1][2]>i;l--)n[l]=n[l-1];n[l]=[r,o,i]},t.n=function(n){var e=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(e,{a:e}),e},t.d=function(n,e){for(var r in e)t.o(e,r)&&!t.o(n,r)&&Object.defineProperty(n,r,{enumerable:!0,get:e[r]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(n){if("object"==typeof window)return window}}(),t.o=function(n,e){return Object.prototype.hasOwnProperty.call(n,e)},t.r=function(n){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},t.j=594,function(){var n={594:0};t.O.j=function(e){return 0===n[e]};var e=function(e,r){var o,i,u=r[0],f=r[1],c=r[2],a=0;if(u.some((function(e){return 0!==n[e]}))){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(c)var l=c(t)}for(e&&e(r);a<u.length;a++)i=u[a],t.o(n,i)&&n[i]&&n[i][0](),n[i]=0;return t.O(l)},r=self.webpackChunkmediacms_frontend=self.webpackChunkmediacms_frontend||[];r.forEach(e.bind(null,0)),r.push=e.bind(null,r.push.bind(r))}();var o=t.O(void 0,[276],(function(){return t(4256)}));o=t.O(o)}();
|
||||
!function(){"use strict";var n,e={4256:function(n,e,r){(0,r(2985).C)()}},r={};function t(n){var o=r[n];if(void 0!==o)return o.exports;var i=r[n]={exports:{}};return e[n].call(i.exports,i,i.exports,t),i.exports}t.m=e,n=[],t.O=function(e,r,o,i){if(!r){var u=1/0;for(l=0;l<n.length;l++){r=n[l][0],o=n[l][1],i=n[l][2];for(var f=!0,c=0;c<r.length;c++)(!1&i||u>=i)&&Object.keys(t.O).every(function(n){return t.O[n](r[c])})?r.splice(c--,1):(f=!1,i<u&&(u=i));if(f){n.splice(l--,1);var a=o();void 0!==a&&(e=a)}}return e}i=i||0;for(var l=n.length;l>0&&n[l-1][2]>i;l--)n[l]=n[l-1];n[l]=[r,o,i]},t.n=function(n){var e=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(e,{a:e}),e},t.d=function(n,e){for(var r in e)t.o(e,r)&&!t.o(n,r)&&Object.defineProperty(n,r,{enumerable:!0,get:e[r]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(n){if("object"==typeof window)return window}}(),t.o=function(n,e){return Object.prototype.hasOwnProperty.call(n,e)},t.r=function(n){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},t.j=594,function(){var n={594:0};t.O.j=function(e){return 0===n[e]};var e=function(e,r){var o,i,u=r[0],f=r[1],c=r[2],a=0;if(u.some(function(e){return 0!==n[e]})){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(c)var l=c(t)}for(e&&e(r);a<u.length;a++)i=u[a],t.o(n,i)&&n[i]&&n[i][0](),n[i]=0;return t.O(l)},r=self.webpackChunkmediacms_frontend=self.webpackChunkmediacms_frontend||[];r.forEach(e.bind(null,0)),r.push=e.bind(null,r.push.bind(r))}();var o=t.O(void 0,[276],function(){return t(4256)});o=t.O(o)}();
|
||||
@@ -1 +1 @@
|
||||
!function(){"use strict";var n,e={5879:function(n,e,r){(0,r(2985).C)()}},r={};function t(n){var o=r[n];if(void 0!==o)return o.exports;var i=r[n]={exports:{}};return e[n].call(i.exports,i,i.exports,t),i.exports}t.m=e,n=[],t.O=function(e,r,o,i){if(!r){var u=1/0;for(l=0;l<n.length;l++){r=n[l][0],o=n[l][1],i=n[l][2];for(var f=!0,c=0;c<r.length;c++)(!1&i||u>=i)&&Object.keys(t.O).every((function(n){return t.O[n](r[c])}))?r.splice(c--,1):(f=!1,i<u&&(u=i));if(f){n.splice(l--,1);var a=o();void 0!==a&&(e=a)}}return e}i=i||0;for(var l=n.length;l>0&&n[l-1][2]>i;l--)n[l]=n[l-1];n[l]=[r,o,i]},t.n=function(n){var e=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(e,{a:e}),e},t.d=function(n,e){for(var r in e)t.o(e,r)&&!t.o(n,r)&&Object.defineProperty(n,r,{enumerable:!0,get:e[r]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(n){if("object"==typeof window)return window}}(),t.o=function(n,e){return Object.prototype.hasOwnProperty.call(n,e)},t.r=function(n){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},t.j=543,function(){var n={543:0};t.O.j=function(e){return 0===n[e]};var e=function(e,r){var o,i,u=r[0],f=r[1],c=r[2],a=0;if(u.some((function(e){return 0!==n[e]}))){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(c)var l=c(t)}for(e&&e(r);a<u.length;a++)i=u[a],t.o(n,i)&&n[i]&&n[i][0](),n[i]=0;return t.O(l)},r=self.webpackChunkmediacms_frontend=self.webpackChunkmediacms_frontend||[];r.forEach(e.bind(null,0)),r.push=e.bind(null,r.push.bind(r))}();var o=t.O(void 0,[276],(function(){return t(5879)}));o=t.O(o)}();
|
||||
!function(){"use strict";var n,e={5879:function(n,e,r){(0,r(2985).C)()}},r={};function t(n){var o=r[n];if(void 0!==o)return o.exports;var i=r[n]={exports:{}};return e[n].call(i.exports,i,i.exports,t),i.exports}t.m=e,n=[],t.O=function(e,r,o,i){if(!r){var u=1/0;for(l=0;l<n.length;l++){r=n[l][0],o=n[l][1],i=n[l][2];for(var f=!0,c=0;c<r.length;c++)(!1&i||u>=i)&&Object.keys(t.O).every(function(n){return t.O[n](r[c])})?r.splice(c--,1):(f=!1,i<u&&(u=i));if(f){n.splice(l--,1);var a=o();void 0!==a&&(e=a)}}return e}i=i||0;for(var l=n.length;l>0&&n[l-1][2]>i;l--)n[l]=n[l-1];n[l]=[r,o,i]},t.n=function(n){var e=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(e,{a:e}),e},t.d=function(n,e){for(var r in e)t.o(e,r)&&!t.o(n,r)&&Object.defineProperty(n,r,{enumerable:!0,get:e[r]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(n){if("object"==typeof window)return window}}(),t.o=function(n,e){return Object.prototype.hasOwnProperty.call(n,e)},t.r=function(n){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},t.j=543,function(){var n={543:0};t.O.j=function(e){return 0===n[e]};var e=function(e,r){var o,i,u=r[0],f=r[1],c=r[2],a=0;if(u.some(function(e){return 0!==n[e]})){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(c)var l=c(t)}for(e&&e(r);a<u.length;a++)i=u[a],t.o(n,i)&&n[i]&&n[i][0](),n[i]=0;return t.O(l)},r=self.webpackChunkmediacms_frontend=self.webpackChunkmediacms_frontend||[];r.forEach(e.bind(null,0)),r.push=e.bind(null,r.push.bind(r))}();var o=t.O(void 0,[276],function(){return t(5879)});o=t.O(o)}();
|
||||
@@ -1 +1 @@
|
||||
!function(){"use strict";var n,e={1684:function(n,e,r){(0,r(2985).C)()}},r={};function t(n){var o=r[n];if(void 0!==o)return o.exports;var i=r[n]={exports:{}};return e[n].call(i.exports,i,i.exports,t),i.exports}t.m=e,n=[],t.O=function(e,r,o,i){if(!r){var u=1/0;for(l=0;l<n.length;l++){r=n[l][0],o=n[l][1],i=n[l][2];for(var f=!0,c=0;c<r.length;c++)(!1&i||u>=i)&&Object.keys(t.O).every((function(n){return t.O[n](r[c])}))?r.splice(c--,1):(f=!1,i<u&&(u=i));if(f){n.splice(l--,1);var a=o();void 0!==a&&(e=a)}}return e}i=i||0;for(var l=n.length;l>0&&n[l-1][2]>i;l--)n[l]=n[l-1];n[l]=[r,o,i]},t.n=function(n){var e=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(e,{a:e}),e},t.d=function(n,e){for(var r in e)t.o(e,r)&&!t.o(n,r)&&Object.defineProperty(n,r,{enumerable:!0,get:e[r]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(n){if("object"==typeof window)return window}}(),t.o=function(n,e){return Object.prototype.hasOwnProperty.call(n,e)},t.r=function(n){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},t.j=152,function(){var n={152:0};t.O.j=function(e){return 0===n[e]};var e=function(e,r){var o,i,u=r[0],f=r[1],c=r[2],a=0;if(u.some((function(e){return 0!==n[e]}))){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(c)var l=c(t)}for(e&&e(r);a<u.length;a++)i=u[a],t.o(n,i)&&n[i]&&n[i][0](),n[i]=0;return t.O(l)},r=self.webpackChunkmediacms_frontend=self.webpackChunkmediacms_frontend||[];r.forEach(e.bind(null,0)),r.push=e.bind(null,r.push.bind(r))}();var o=t.O(void 0,[276],(function(){return t(1684)}));o=t.O(o)}();
|
||||
!function(){"use strict";var n,e={1684:function(n,e,r){(0,r(2985).C)()}},r={};function t(n){var o=r[n];if(void 0!==o)return o.exports;var i=r[n]={exports:{}};return e[n].call(i.exports,i,i.exports,t),i.exports}t.m=e,n=[],t.O=function(e,r,o,i){if(!r){var u=1/0;for(l=0;l<n.length;l++){r=n[l][0],o=n[l][1],i=n[l][2];for(var f=!0,c=0;c<r.length;c++)(!1&i||u>=i)&&Object.keys(t.O).every(function(n){return t.O[n](r[c])})?r.splice(c--,1):(f=!1,i<u&&(u=i));if(f){n.splice(l--,1);var a=o();void 0!==a&&(e=a)}}return e}i=i||0;for(var l=n.length;l>0&&n[l-1][2]>i;l--)n[l]=n[l-1];n[l]=[r,o,i]},t.n=function(n){var e=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(e,{a:e}),e},t.d=function(n,e){for(var r in e)t.o(e,r)&&!t.o(n,r)&&Object.defineProperty(n,r,{enumerable:!0,get:e[r]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(n){if("object"==typeof window)return window}}(),t.o=function(n,e){return Object.prototype.hasOwnProperty.call(n,e)},t.r=function(n){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},t.j=152,function(){var n={152:0};t.O.j=function(e){return 0===n[e]};var e=function(e,r){var o,i,u=r[0],f=r[1],c=r[2],a=0;if(u.some(function(e){return 0!==n[e]})){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(c)var l=c(t)}for(e&&e(r);a<u.length;a++)i=u[a],t.o(n,i)&&n[i]&&n[i][0](),n[i]=0;return t.O(l)},r=self.webpackChunkmediacms_frontend=self.webpackChunkmediacms_frontend||[];r.forEach(e.bind(null,0)),r.push=e.bind(null,r.push.bind(r))}();var o=t.O(void 0,[276],function(){return t(1684)});o=t.O(o)}();
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
!function(){"use strict";var t,e={282:function(t,e,n){var r=n(2985),o=n(9471),i=n(8713),s=n.n(i),a=n(8790),u=n(285),l=n(2855),c=n(9835),m=n(9479);function f(t,e,n){return t+"?"+e+(""===e?"":"&")+"page="+n}class h extends m.Y{constructor(t){super(t,"manage-comments"),this.state={resultsCount:null,requestUrl:a.ApiUrlContext._currentValue.manage.comments,currentPage:1,sortingArgs:"",sortBy:"add_date",ordering:"desc",refresh:0},this.getCountFunc=this.getCountFunc.bind(this),this.onTablePageChange=this.onTablePageChange.bind(this),this.onColumnSortClick=this.onColumnSortClick.bind(this),this.onItemsRemoval=this.onItemsRemoval.bind(this),this.onItemsRemovalFail=this.onItemsRemovalFail.bind(this)}onTablePageChange(t,e){this.setState({currentPage:e,requestUrl:f(a.ApiUrlContext._currentValue.manage.comments,this.state.sortingArgs,e)})}getCountFunc(t){this.setState({resultsCount:t})}onColumnSortClick(t,e){const n="sort_by="+t+"&ordering="+e;this.setState({sortBy:t,ordering:e,sortingArgs:n,requestUrl:f(a.ApiUrlContext._currentValue.manage.comments,n,this.state.currentPage)})}onItemsRemoval(t){this.setState({resultsCount:null,refresh:this.state.refresh+1,requestUrl:a.ApiUrlContext._currentValue.manage.comments},(function(){t?u.PageActions.addNotification("The comments deleted successfully.","commentsRemovalSucceed"):u.PageActions.addNotification("The comment deleted successfully.","commentRemovalSucceed")}))}onItemsRemovalFail(t){t?u.PageActions.addNotification("The comments removal failed. Please try again.","commentsRemovalFailed"):u.PageActions.addNotification("The comment removal failed. Please try again.","commentRemovalFailed")}pageContent(){return o.createElement(l.MediaListWrapper,{title:this.props.title+(null===this.state.resultsCount?"":" ("+this.state.resultsCount+")"),className:"search-results-wrap items-list-hor"},o.createElement(c.D,{pageItems:50,manageType:"comments",key:this.state.requestUrl+"["+this.state.refresh+"]",itemsCountCallback:this.getCountFunc,requestUrl:this.state.requestUrl,onPageChange:this.onTablePageChange,sortBy:this.state.sortBy,ordering:this.state.ordering,onRowsDelete:this.onItemsRemoval,onRowsDeleteFail:this.onItemsRemovalFail,onClickColumnSort:this.onColumnSortClick}))}}h.propTypes={title:s().string.isRequired},h.defaultProps={title:"Manage comments"},(0,r.C)("page-manage-comments",h)},7664:function(t,e,n){n.r(e),n.d(e,{CircleIconButton:function(){return r.i},FilterOptions:function(){return o.P},FiltersToggleButton:function(){return i.I},MaterialIcon:function(){return s.Z},NavigationContentApp:function(){return a.V},NavigationMenuList:function(){return u.S},Notifications:function(){return l.$},NumericInputWithUnit:function(){return c._},PopupMain:function(){return m.AP},PopupTop:function(){return m.cp},SpinnerLoader:function(){return f.x},UserThumbnail:function(){return h.c}});var r=n(5321),o=n(7256),i=n(3135),s=n(2828),a=n(5305),u=n(7201),l=n(6089),c=n(3818),m=n(2901),f=n(6568),h=n(878)}},n={};function r(t){var o=n[t];if(void 0!==o)return o.exports;var i=n[t]={exports:{}};return e[t].call(i.exports,i,i.exports,r),i.exports}r.m=e,t=[],r.O=function(e,n,o,i){if(!n){var s=1/0;for(c=0;c<t.length;c++){n=t[c][0],o=t[c][1],i=t[c][2];for(var a=!0,u=0;u<n.length;u++)(!1&i||s>=i)&&Object.keys(r.O).every((function(t){return r.O[t](n[u])}))?n.splice(u--,1):(a=!1,i<s&&(s=i));if(a){t.splice(c--,1);var l=o();void 0!==l&&(e=l)}}return e}i=i||0;for(var c=t.length;c>0&&t[c-1][2]>i;c--)t[c]=t[c-1];t[c]=[n,o,i]},r.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return r.d(e,{a:e}),e},r.d=function(t,e){for(var n in e)r.o(e,n)&&!r.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:e[n]})},r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}(),r.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},r.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},r.j=401,function(){var t={401:0};r.O.j=function(e){return 0===t[e]};var e=function(e,n){var o,i,s=n[0],a=n[1],u=n[2],l=0;if(s.some((function(e){return 0!==t[e]}))){for(o in a)r.o(a,o)&&(r.m[o]=a[o]);if(u)var c=u(r)}for(e&&e(n);l<s.length;l++)i=s[l],r.o(t,i)&&t[i]&&t[i][0](),t[i]=0;return r.O(c)},n=self.webpackChunkmediacms_frontend=self.webpackChunkmediacms_frontend||[];n.forEach(e.bind(null,0)),n.push=e.bind(null,n.push.bind(n))}();var o=r.O(void 0,[276],(function(){return r(282)}));o=r.O(o)}();
|
||||
!function(){"use strict";var t,e={282:function(t,e,n){var r=n(2985),o=n(9471),i=n(8713),s=n.n(i),a=n(8790),u=n(285),l=n(2855),c=n(9835),m=n(9479);function f(t,e,n){return t+"?"+e+(""===e?"":"&")+"page="+n}class h extends m.Y{constructor(t){super(t,"manage-comments"),this.state={resultsCount:null,requestUrl:a.ApiUrlContext._currentValue.manage.comments,currentPage:1,sortingArgs:"",sortBy:"add_date",ordering:"desc",refresh:0},this.getCountFunc=this.getCountFunc.bind(this),this.onTablePageChange=this.onTablePageChange.bind(this),this.onColumnSortClick=this.onColumnSortClick.bind(this),this.onItemsRemoval=this.onItemsRemoval.bind(this),this.onItemsRemovalFail=this.onItemsRemovalFail.bind(this)}onTablePageChange(t,e){this.setState({currentPage:e,requestUrl:f(a.ApiUrlContext._currentValue.manage.comments,this.state.sortingArgs,e)})}getCountFunc(t){this.setState({resultsCount:t})}onColumnSortClick(t,e){const n="sort_by="+t+"&ordering="+e;this.setState({sortBy:t,ordering:e,sortingArgs:n,requestUrl:f(a.ApiUrlContext._currentValue.manage.comments,n,this.state.currentPage)})}onItemsRemoval(t){this.setState({resultsCount:null,refresh:this.state.refresh+1,requestUrl:a.ApiUrlContext._currentValue.manage.comments},function(){t?u.PageActions.addNotification("The comments deleted successfully.","commentsRemovalSucceed"):u.PageActions.addNotification("The comment deleted successfully.","commentRemovalSucceed")})}onItemsRemovalFail(t){t?u.PageActions.addNotification("The comments removal failed. Please try again.","commentsRemovalFailed"):u.PageActions.addNotification("The comment removal failed. Please try again.","commentRemovalFailed")}pageContent(){return o.createElement(l.MediaListWrapper,{title:this.props.title+(null===this.state.resultsCount?"":" ("+this.state.resultsCount+")"),className:"search-results-wrap items-list-hor"},o.createElement(c.D,{pageItems:50,manageType:"comments",key:this.state.requestUrl+"["+this.state.refresh+"]",itemsCountCallback:this.getCountFunc,requestUrl:this.state.requestUrl,onPageChange:this.onTablePageChange,sortBy:this.state.sortBy,ordering:this.state.ordering,onRowsDelete:this.onItemsRemoval,onRowsDeleteFail:this.onItemsRemovalFail,onClickColumnSort:this.onColumnSortClick}))}}h.propTypes={title:s().string.isRequired},h.defaultProps={title:"Manage comments"},(0,r.C)("page-manage-comments",h)},7664:function(t,e,n){n.r(e),n.d(e,{CircleIconButton:function(){return r.i},FilterOptions:function(){return o.P},FiltersToggleButton:function(){return i.I},MaterialIcon:function(){return s.Z},NavigationContentApp:function(){return a.V},NavigationMenuList:function(){return u.S},Notifications:function(){return l.$},NumericInputWithUnit:function(){return c._},PopupMain:function(){return m.AP},PopupTop:function(){return m.cp},SpinnerLoader:function(){return f.x},UserThumbnail:function(){return h.c}});var r=n(5321),o=n(7256),i=n(3135),s=n(2828),a=n(5305),u=n(7201),l=n(6089),c=n(3818),m=n(2901),f=n(6568),h=n(878)}},n={};function r(t){var o=n[t];if(void 0!==o)return o.exports;var i=n[t]={exports:{}};return e[t].call(i.exports,i,i.exports,r),i.exports}r.m=e,t=[],r.O=function(e,n,o,i){if(!n){var s=1/0;for(c=0;c<t.length;c++){n=t[c][0],o=t[c][1],i=t[c][2];for(var a=!0,u=0;u<n.length;u++)(!1&i||s>=i)&&Object.keys(r.O).every(function(t){return r.O[t](n[u])})?n.splice(u--,1):(a=!1,i<s&&(s=i));if(a){t.splice(c--,1);var l=o();void 0!==l&&(e=l)}}return e}i=i||0;for(var c=t.length;c>0&&t[c-1][2]>i;c--)t[c]=t[c-1];t[c]=[n,o,i]},r.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return r.d(e,{a:e}),e},r.d=function(t,e){for(var n in e)r.o(e,n)&&!r.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:e[n]})},r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}(),r.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},r.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},r.j=401,function(){var t={401:0};r.O.j=function(e){return 0===t[e]};var e=function(e,n){var o,i,s=n[0],a=n[1],u=n[2],l=0;if(s.some(function(e){return 0!==t[e]})){for(o in a)r.o(a,o)&&(r.m[o]=a[o]);if(u)var c=u(r)}for(e&&e(n);l<s.length;l++)i=s[l],r.o(t,i)&&t[i]&&t[i][0](),t[i]=0;return r.O(c)},n=self.webpackChunkmediacms_frontend=self.webpackChunkmediacms_frontend||[];n.forEach(e.bind(null,0)),n.push=e.bind(null,n.push.bind(n))}();var o=r.O(void 0,[276],function(){return r(282)});o=r.O(o)}();
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user