mirror of
https://github.com/mediacms-io/mediacms.git
synced 2026-06-07 09:24:20 -04:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b2996b8c8 | |||
| a3fe375a83 | |||
| 777b06bbeb | |||
| e89c4a3c85 | |||
| 7a02d25d0b | |||
| c7a673bbbf | |||
| b0c0d9a83f | |||
| ae63a5af64 | |||
| 98d5d6af8b | |||
| 9302559d4b | |||
| 279cccb980 | |||
| 25e91e9d5e | |||
| d6a11514e5 | |||
| c7a1d60d73 | |||
| 6ee5bef6ce | |||
| 2e01000559 | |||
| 4f11addcfd | |||
| b11f2f561c | |||
| b6da9c4662 | |||
| 10c0782fe0 | |||
| 318dad0e5d | |||
| df4b0422d5 | |||
| 0434f24691 | |||
| c2043fafa1 | |||
| 9f9dd699b2 | |||
| e2bc9399b9 | |||
| 45d94069b9 |
@@ -24,7 +24,6 @@ If applicable, add screenshots to help explain your problem.
|
|||||||
|
|
||||||
**Environment (please complete the following information):**
|
**Environment (please complete the following information):**
|
||||||
- OS: [e.g. Ubuntu Linux]
|
- OS: [e.g. Ubuntu Linux]
|
||||||
- Installation method: [Docker install, or single server install]
|
|
||||||
- Browser, if applicable
|
- Browser, if applicable
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
|
|||||||
@@ -37,3 +37,5 @@ frontend-tools/chapters-editor/client/public/videos/sample-video.mp3
|
|||||||
static/chapters_editor/videos/sample-video.mp3
|
static/chapters_editor/videos/sample-video.mp3
|
||||||
static/video_editor/videos/sample-video.mp3
|
static/video_editor/videos/sample-video.mp3
|
||||||
templates/todo-MS4.md
|
templates/todo-MS4.md
|
||||||
|
.secret_key
|
||||||
|
.secret_key.lock
|
||||||
|
|||||||
@@ -1,5 +1,60 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [8.1.3](https://github.com/mediacms-io/mediacms/compare/v8.1.2...v8.1.3) (2026-05-19)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* django connection settings ([#1529](https://github.com/mediacms-io/mediacms/issues/1529)) ([e89c4a3](https://github.com/mediacms-io/mediacms/commit/e89c4a3c8523574b5852a434ed67e281b6290584))
|
||||||
|
* prestart.sh loaddata re-runs on every container restart ([#1502](https://github.com/mediacms-io/mediacms/issues/1502)) ([777b06b](https://github.com/mediacms-io/mediacms/commit/777b06bbebf141e5b1cb27e17533fe65d57eb6cd))
|
||||||
|
|
||||||
|
## [8.1.2](https://github.com/mediacms-io/mediacms/compare/v8.1.1...v8.1.2) (2026-05-18)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* remove redundant check ([#1528](https://github.com/mediacms-io/mediacms/issues/1528)) ([c7a673b](https://github.com/mediacms-io/mediacms/commit/c7a673bbbf46efc37621dc4a5109a85fc10e1317))
|
||||||
|
|
||||||
|
## [8.1.1](https://github.com/mediacms-io/mediacms/compare/v8.1.0...v8.1.1) (2026-05-18)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* x-accell headers on uploaded poster ([#1526](https://github.com/mediacms-io/mediacms/issues/1526)) ([ae63a5a](https://github.com/mediacms-io/mediacms/commit/ae63a5af647c8865b96e6e50dda1ea9d29b5bd0b))
|
||||||
|
|
||||||
|
## [8.1.0](https://github.com/mediacms-io/mediacms/compare/v8.0.8...v8.1.0) (2026-05-17)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* introduce x-accell headers ([9302559](https://github.com/mediacms-io/mediacms/commit/9302559d4bb3e4d0adb299ed37438b04c39e1864))
|
||||||
|
|
||||||
|
## [8.0.8](https://github.com/mediacms-io/mediacms/compare/v8.0.7...v8.0.8) (2026-05-13)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* update documentation and fix smaller issues ([#1520](https://github.com/mediacms-io/mediacms/issues/1520)) ([d6a1151](https://github.com/mediacms-io/mediacms/commit/d6a11514e54b9341ec8a306a259adce6b4199d42))
|
||||||
|
|
||||||
|
## [8.0.7](https://github.com/mediacms-io/mediacms/compare/v8.0.6...v8.0.7) (2026-05-12)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* bring related items back ([#1515](https://github.com/mediacms-io/mediacms/issues/1515)) ([6ee5bef](https://github.com/mediacms-io/mediacms/commit/6ee5bef6ce31cf849941f65d0817e53b8f03362f))
|
||||||
|
|
||||||
|
## [8.0.6](https://github.com/mediacms-io/mediacms/compare/v8.0.5...v8.0.6) (2026-05-11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* better place secret key settings ([4f11add](https://github.com/mediacms-io/mediacms/commit/4f11addcfd6657e7e63eed0570b1d4d9bca75698))
|
||||||
|
|
||||||
|
## [8.0.5](https://github.com/mediacms-io/mediacms/compare/v8.0.4...v8.0.5) (2026-05-11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add secret key to gitignore ([b6da9c4](https://github.com/mediacms-io/mediacms/commit/b6da9c4662b3fba234b8dc69700ffa44fced7482))
|
||||||
|
|
||||||
|
## [8.0.4](https://github.com/mediacms-io/mediacms/compare/v8.0.3...v8.0.4) (2026-05-11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* docker compose settings, provide key ([318dad0](https://github.com/mediacms-io/mediacms/commit/318dad0e5d2512d68068c019eb87f942f83318e9))
|
||||||
|
|
||||||
## [8.0.3](https://github.com/mediacms-io/mediacms/compare/v8.0.2...v8.0.3) (2026-05-11)
|
## [8.0.3](https://github.com/mediacms-io/mediacms/compare/v8.0.2...v8.0.3) (2026-05-11)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ A demo is available at https://demo.mediacms.io
|
|||||||
- **Multiple transcoding profiles**: sane defaults for multiple dimensions (144p, 240p, 360p, 480p, 720p, 1080p) and multiple profiles (h264, h265, vp9)
|
- **Multiple transcoding profiles**: sane defaults for multiple dimensions (144p, 240p, 360p, 480p, 720p, 1080p) and multiple profiles (h264, h265, vp9)
|
||||||
- **Adaptive video streaming**: possible through HLS protocol
|
- **Adaptive video streaming**: possible through HLS protocol
|
||||||
- **Subtitles/CC**: support for multilingual subtitle files
|
- **Subtitles/CC**: support for multilingual subtitle files
|
||||||
- **Scalable transcoding**: transcoding through priorities. Experimental support for remote workers
|
- **Scalable transcoding**: transcoding through priorities.
|
||||||
- **Chunked file uploads**: for pausable/resumable upload of content
|
- **Chunked file uploads**: for pausable/resumable upload of content
|
||||||
- **REST API**: Documented through Swagger
|
- **REST API**: Documented through Swagger
|
||||||
- **Translation**: Most of the CMS is translated to a number of languages
|
- **Translation**: Most of the CMS is translated to a number of languages
|
||||||
@@ -91,7 +91,6 @@ In order to support automatic transcriptions through Whisper, consider more CPUs
|
|||||||
|
|
||||||
There are two ways to run MediaCMS, through Docker Compose and through installing it on a server via an automation script that installs and configures all needed services. Find the related pages:
|
There are two ways to run MediaCMS, through Docker Compose and through installing it on a server via an automation script that installs and configures all needed services. Find the related pages:
|
||||||
|
|
||||||
- [Single Server](docs/admins_docs.md#2-server-installation) page
|
|
||||||
- [Docker Compose](docs/admins_docs.md#3-docker-installation) page
|
- [Docker Compose](docs/admins_docs.md#3-docker-installation) page
|
||||||
|
|
||||||
A complete guide can be found on the blog post [How to self-host and share your videos in 2021](https://medium.com/@MediaCMS.io/how-to-self-host-and-share-your-videos-in-2021-14067e3b291b).
|
A complete guide can be found on the blog post [How to self-host and share your videos in 2021](https://medium.com/@MediaCMS.io/how-to-self-host-and-share-your-videos-in-2021-14067e3b291b).
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ from __future__ import absolute_import
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
from celery.signals import worker_process_init
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.db import connections
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cms.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cms.settings")
|
||||||
app = Celery("cms")
|
app = Celery("cms")
|
||||||
@@ -20,3 +22,13 @@ app.conf.task_always_eager = settings.CELERY_TASK_ALWAYS_EAGER
|
|||||||
|
|
||||||
|
|
||||||
app.conf.worker_prefetch_multiplier = 1
|
app.conf.worker_prefetch_multiplier = 1
|
||||||
|
|
||||||
|
|
||||||
|
@worker_process_init.connect
|
||||||
|
def close_db_pool_on_fork(**_):
|
||||||
|
# psycopg3's ConnectionPool is not fork-safe: children inherit dead sockets
|
||||||
|
# from the parent's pool and block on getconn() until PoolTimeout. Dispose
|
||||||
|
# the inherited pool here so each prefork child opens its own on first use.
|
||||||
|
# NB: plain conn.close() would only putconn() back into the broken pool.
|
||||||
|
for conn in connections.all():
|
||||||
|
conn.close_pool()
|
||||||
|
|||||||
+41
-21
@@ -1,7 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from celery.schedules import crontab
|
from celery.schedules import crontab
|
||||||
from django.core.management.utils import get_random_secret_key
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
@@ -172,10 +171,19 @@ REST_FRAMEWORK = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Set the SECRET_KEY env var in production. If unset, a fresh random key is
|
# In docker, deploy/docker/entrypoint.sh ensures the SECRET_KEY env var is
|
||||||
# generated per process — safe but invalidates sessions and signed tokens on
|
# set (generating .secret_key once on first start if needed). Outside docker,
|
||||||
# every restart.
|
# either set SECRET_KEY in the environment or create a .secret_key file at the
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY") or get_random_secret_key()
|
# project root, e.g.:
|
||||||
|
# python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' > .secret_key
|
||||||
|
SECRET_KEY = os.getenv('SECRET_KEY')
|
||||||
|
if not SECRET_KEY:
|
||||||
|
_secret_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.secret_key')
|
||||||
|
if os.path.exists(_secret_path):
|
||||||
|
with open(_secret_path) as _f:
|
||||||
|
SECRET_KEY = _f.read().strip()
|
||||||
|
if not SECRET_KEY:
|
||||||
|
raise RuntimeError("SECRET_KEY is not set. Set the SECRET_KEY env var or create a .secret_key file at the project root.")
|
||||||
|
|
||||||
TEMP_DIRECTORY = "/tmp" # Don't use a temp directory inside BASE_DIR!!!
|
TEMP_DIRECTORY = "/tmp" # Don't use a temp directory inside BASE_DIR!!!
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
@@ -194,6 +202,15 @@ THUMBNAIL_UPLOAD_DIR = f"{MEDIA_UPLOAD_DIR}/thumbnails/"
|
|||||||
SUBTITLES_UPLOAD_DIR = f"{MEDIA_UPLOAD_DIR}/subtitles/"
|
SUBTITLES_UPLOAD_DIR = f"{MEDIA_UPLOAD_DIR}/subtitles/"
|
||||||
HLS_DIR = os.path.join(MEDIA_ROOT, "hls/")
|
HLS_DIR = os.path.join(MEDIA_ROOT, "hls/")
|
||||||
|
|
||||||
|
# Protect media files via nginx auth_request
|
||||||
|
# When True, nginx delegates authorization for /media/<protected>/... to a
|
||||||
|
# Django endpoint that checks the Media's state and the user's access.
|
||||||
|
USE_X_ACCEL_REDIRECT = True
|
||||||
|
# Subdirectories of MEDIA_ROOT that should be gated. "chunks" is intentionally
|
||||||
|
# omitted (upload state, not playback).
|
||||||
|
X_ACCEL_PROTECTED_PATHS = ["encoded", "hls", "original"]
|
||||||
|
X_ACCEL_AUTH_CACHE_SECONDS = 300
|
||||||
|
|
||||||
FFMPEG_COMMAND = "ffmpeg" # this is the path
|
FFMPEG_COMMAND = "ffmpeg" # this is the path
|
||||||
FFPROBE_COMMAND = "ffprobe" # this is the path
|
FFPROBE_COMMAND = "ffprobe" # this is the path
|
||||||
MP4HLS = "mp4hls"
|
MP4HLS = "mp4hls"
|
||||||
@@ -258,21 +275,6 @@ CANNOT_ADD_MEDIA_MESSAGE = "User cannot add media, or maximum number of media up
|
|||||||
# mp4hls command, part of Bento4
|
# mp4hls command, part of Bento4
|
||||||
MP4HLS_COMMAND = "/home/mediacms.io/mediacms/Bento4-SDK-1-6-0-637.x86_64-unknown-linux/bin/mp4hls"
|
MP4HLS_COMMAND = "/home/mediacms.io/mediacms/Bento4-SDK-1-6-0-637.x86_64-unknown-linux/bin/mp4hls"
|
||||||
|
|
||||||
# highly experimental, related with remote workers
|
|
||||||
ADMIN_TOKEN = ""
|
|
||||||
# this is used by remote workers to push
|
|
||||||
# encodings once they are done
|
|
||||||
# USE_BASIC_HTTP = True
|
|
||||||
# BASIC_HTTP_USER_PAIR = ('user', 'password')
|
|
||||||
# specify basic auth user/password pair for use with the
|
|
||||||
# remote workers, if nginx basic auth is setup
|
|
||||||
# apache2-utils need be installed
|
|
||||||
# then run
|
|
||||||
# htpasswd -c /home/mediacms.io/mediacms/deploy/.htpasswd user
|
|
||||||
# and set a password
|
|
||||||
# edit /etc/nginx/sites-enabled/mediacms.io and
|
|
||||||
# uncomment the two lines related to htpasswd
|
|
||||||
|
|
||||||
|
|
||||||
AUTH_USER_MODEL = "users.User"
|
AUTH_USER_MODEL = "users.User"
|
||||||
LOGIN_REDIRECT_URL = "/"
|
LOGIN_REDIRECT_URL = "/"
|
||||||
@@ -405,7 +407,25 @@ LOGGING = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
DATABASES = {"default": {"ENGINE": "django.db.backends.postgresql", "NAME": "mediacms", "HOST": "127.0.0.1", "PORT": "5432", "USER": "mediacms", "PASSWORD": "mediacms", "OPTIONS": {'pool': True}}}
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
|
"NAME": "mediacms",
|
||||||
|
"HOST": "127.0.0.1",
|
||||||
|
"PORT": "5432",
|
||||||
|
"USER": "mediacms",
|
||||||
|
"PASSWORD": "mediacms",
|
||||||
|
"OPTIONS": {
|
||||||
|
"pool": {
|
||||||
|
"min_size": 2,
|
||||||
|
"max_size": 8,
|
||||||
|
"timeout": 10,
|
||||||
|
"max_lifetime": 30 * 60,
|
||||||
|
"max_idle": 10 * 60,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
REDIS_LOCATION = "redis://127.0.0.1:6379/1"
|
REDIS_LOCATION = "redis://127.0.0.1:6379/1"
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
VERSION = "8.0.3"
|
VERSION = "8.1.3"
|
||||||
|
|||||||
@@ -35,4 +35,29 @@ find /home/mediacms.io/mediacms ! \( -path "*.git*" -o -name "package-lock.json"
|
|||||||
|
|
||||||
chmod +x /home/mediacms.io/mediacms/deploy/docker/start.sh /home/mediacms.io/mediacms/deploy/docker/prestart.sh
|
chmod +x /home/mediacms.io/mediacms/deploy/docker/start.sh /home/mediacms.io/mediacms/deploy/docker/prestart.sh
|
||||||
|
|
||||||
|
# Generate or read SECRET_KEY once, shared across all containers via the
|
||||||
|
# host-mounted project volume. Atomic create-or-read so parallel container
|
||||||
|
# starts (web + celery_worker + celery_beat + migrations) can't race.
|
||||||
|
# Uses `mkdir` as the lock primitive (POSIX-atomic, no dependency on flock).
|
||||||
|
SECRET_KEY_FILE="${SECRET_KEY_FILE:-/home/mediacms.io/mediacms/.secret_key}"
|
||||||
|
SECRET_KEY_LOCK="${SECRET_KEY_FILE}.lock"
|
||||||
|
|
||||||
|
if [ -z "${SECRET_KEY:-}" ]; then
|
||||||
|
if [ ! -s "$SECRET_KEY_FILE" ]; then
|
||||||
|
# Spin-acquire the lock. mkdir is atomic; first caller wins, others retry.
|
||||||
|
while ! mkdir "$SECRET_KEY_LOCK" 2>/dev/null; do
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
# Re-check inside the lock: another container may have just written it.
|
||||||
|
if [ ! -s "$SECRET_KEY_FILE" ]; then
|
||||||
|
python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' > "$SECRET_KEY_FILE"
|
||||||
|
chown www-data:www-data "$SECRET_KEY_FILE"
|
||||||
|
chmod 600 "$SECRET_KEY_FILE"
|
||||||
|
echo "entrypoint.sh: generated new SECRET_KEY at $SECRET_KEY_FILE"
|
||||||
|
fi
|
||||||
|
rmdir "$SECRET_KEY_LOCK"
|
||||||
|
fi
|
||||||
|
export SECRET_KEY="$(cat "$SECRET_KEY_FILE")"
|
||||||
|
fi
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from django.core.management.utils import get_random_secret_key
|
|
||||||
|
|
||||||
FRONTEND_HOST = os.getenv('FRONTEND_HOST', 'http://localhost')
|
FRONTEND_HOST = os.getenv('FRONTEND_HOST', 'http://localhost')
|
||||||
PORTAL_NAME = os.getenv('PORTAL_NAME', 'MediaCMS')
|
PORTAL_NAME = os.getenv('PORTAL_NAME', 'MediaCMS')
|
||||||
SECRET_KEY = os.getenv('SECRET_KEY') or get_random_secret_key()
|
|
||||||
REDIS_LOCATION = os.getenv('REDIS_LOCATION', 'redis://redis:6379/1')
|
REDIS_LOCATION = os.getenv('REDIS_LOCATION', 'redis://redis:6379/1')
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
@@ -15,7 +12,15 @@ DATABASES = {
|
|||||||
"PORT": os.getenv('POSTGRES_PORT', '5432'),
|
"PORT": os.getenv('POSTGRES_PORT', '5432'),
|
||||||
"USER": os.getenv('POSTGRES_USER', 'mediacms'),
|
"USER": os.getenv('POSTGRES_USER', 'mediacms'),
|
||||||
"PASSWORD": os.getenv('POSTGRES_PASSWORD', 'mediacms'),
|
"PASSWORD": os.getenv('POSTGRES_PASSWORD', 'mediacms'),
|
||||||
"OPTIONS": {'pool': True},
|
"OPTIONS": {
|
||||||
|
"pool": {
|
||||||
|
"min_size": 2,
|
||||||
|
"max_size": 8,
|
||||||
|
"timeout": 10,
|
||||||
|
"max_lifetime": 30 * 60,
|
||||||
|
"max_idle": 10 * 60,
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,27 @@ server {
|
|||||||
location /static {
|
location /static {
|
||||||
alias /home/mediacms.io/mediacms/static ;
|
alias /home/mediacms.io/mediacms/static ;
|
||||||
}
|
}
|
||||||
|
location = /_media_auth {
|
||||||
|
internal;
|
||||||
|
proxy_pass http://127.0.0.1:9000/api/v1/media-auth;
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Original-URI $request_uri;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
|
}
|
||||||
|
|
||||||
location /media/original {
|
location ~ ^/media/(encoded|hls|original)/(.*)$ {
|
||||||
alias /home/mediacms.io/mediacms/media_files/original;
|
auth_request /_media_auth;
|
||||||
|
alias /home/mediacms.io/mediacms/media_files/$1/$2;
|
||||||
|
|
||||||
|
add_header 'Access-Control-Allow-Origin' '*';
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||||
|
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
|
||||||
}
|
}
|
||||||
|
|
||||||
location /media {
|
location /media {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ ADMIN_PASSWORD=${ADMIN_PASSWORD:-$RANDOM_ADMIN_PASS}
|
|||||||
if [ X"$ENABLE_MIGRATIONS" = X"yes" ]; then
|
if [ X"$ENABLE_MIGRATIONS" = X"yes" ]; then
|
||||||
echo "Running migrations service"
|
echo "Running migrations service"
|
||||||
python manage.py migrate
|
python manage.py migrate
|
||||||
EXISTING_INSTALLATION=`echo "from users.models import User; print(User.objects.exists())" |python manage.py shell`
|
EXISTING_INSTALLATION=`echo "from users.models import User; print(User.objects.exists())" |python manage.py shell 2>/dev/null | tail -1`
|
||||||
if [ "$EXISTING_INSTALLATION" = "True" ]; then
|
if [ "$EXISTING_INSTALLATION" = "True" ]; then
|
||||||
echo "Loaddata has already run"
|
echo "Loaddata has already run"
|
||||||
else
|
else
|
||||||
|
|||||||
+1
-65
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
- [1. Welcome](#1-welcome)
|
- [1. Welcome](#1-welcome)
|
||||||
- [2. Single Server Installaton](#2-single-server-installation)
|
|
||||||
- [3. Docker Installation](#3-docker-installation)
|
- [3. Docker Installation](#3-docker-installation)
|
||||||
- [4. Docker Deployment options](#4-docker-deployment-options)
|
- [4. Docker Deployment options](#4-docker-deployment-options)
|
||||||
- [5. Configuration](#5-configuration)
|
- [5. Configuration](#5-configuration)
|
||||||
@@ -34,58 +33,6 @@
|
|||||||
## 1. Welcome
|
## 1. Welcome
|
||||||
This page is created for MediaCMS administrators that are responsible for setting up the software, maintaining it and making modifications.
|
This page is created for MediaCMS administrators that are responsible for setting up the software, maintaining it and making modifications.
|
||||||
|
|
||||||
## 2. Single Server Installation
|
|
||||||
|
|
||||||
The core dependencies are python3, Django, celery, PostgreSQL, redis, ffmpeg. Any system that can have these dependencies installed, can run MediaCMS. But the install.sh is only tested in Linux Ubuntu 24 and 22 versions.
|
|
||||||
|
|
||||||
Installation on an Ubuntu 22/24 system with git utility installed should be completed in a few minutes with the following steps.
|
|
||||||
Make sure you run it as user root, on a clear system, since the automatic script will install and configure the following services: Celery/PostgreSQL/Redis/Nginx and will override any existing settings.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir /home/mediacms.io && cd /home/mediacms.io/
|
|
||||||
git clone https://github.com/mediacms-io/mediacms
|
|
||||||
cd /home/mediacms.io/mediacms/ && bash ./install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The script will ask if you have a URL where you want to deploy MediaCMS, otherwise it will use localhost. If you provide a URL, it will use Let's Encrypt service to install a valid ssl certificate.
|
|
||||||
|
|
||||||
|
|
||||||
### Update
|
|
||||||
|
|
||||||
If you've used the above way to install MediaCMS, update with the following:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/mediacms.io/mediacms # enter mediacms directory
|
|
||||||
source /home/mediacms.io/bin/activate # use virtualenv
|
|
||||||
git pull # update code
|
|
||||||
pip install -r requirements.txt -U # run pip install to update
|
|
||||||
python manage.py migrate # run Django migrations
|
|
||||||
sudo systemctl restart mediacms celery_long celery_short # restart services
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update from version 2 to version 3
|
|
||||||
Version 3 is using Django 4 and Celery 5, and needs a recent Python 3.x version. If you are updating from an older version, make sure Python is updated first. Version 2 could run on Python 3.6, but version 3 needs Python3.8 and higher.
|
|
||||||
The syntax for starting Celery has also changed, so you have to copy the celery related systemctl files and restart
|
|
||||||
|
|
||||||
```
|
|
||||||
# cp deploy/local_install/celery_long.service /etc/systemd/system/celery_long.service
|
|
||||||
# cp deploy/local_install/celery_short.service /etc/systemd/system/celery_short.service
|
|
||||||
# cp deploy/local_install/celery_beat.service /etc/systemd/system/celery_beat.service
|
|
||||||
# systemctl daemon-reload
|
|
||||||
# systemctl start celery_long celery_short celery_beat
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
Checkout the configuration section here.
|
|
||||||
|
|
||||||
|
|
||||||
### Maintenance
|
|
||||||
Database can be backed up with pg_dump and media_files on /home/mediacms.io/mediacms/media_files include original files and encoded/transcoded versions
|
|
||||||
|
|
||||||
|
|
||||||
## 3. Docker Installation
|
## 3. Docker Installation
|
||||||
|
|
||||||
@@ -220,14 +167,10 @@ Several options are available on `cms/settings.py`, most of the things that are
|
|||||||
|
|
||||||
It is advisable to override any of them by adding it to `local_settings.py` .
|
It is advisable to override any of them by adding it to `local_settings.py` .
|
||||||
|
|
||||||
In case of a the single server installation, add to `cms/local_settings.py` .
|
|
||||||
|
|
||||||
In case of a docker compose installation, add to `deploy/docker/local_settings.py` . This will automatically overwrite `cms/local_settings.py` .
|
In case of a docker compose installation, add to `deploy/docker/local_settings.py` . This will automatically overwrite `cms/local_settings.py` .
|
||||||
|
|
||||||
Any change needs restart of MediaCMS in order to take effect.
|
Any change needs restart of MediaCMS in order to take effect.
|
||||||
|
|
||||||
Single server installation: edit `cms/local_settings.py`, make a change and restart MediaCMS
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
#systemctl restart mediacms
|
#systemctl restart mediacms
|
||||||
```
|
```
|
||||||
@@ -795,14 +738,7 @@ Instructions contributed by @alberto98fx
|
|||||||
On the [Configuration](https://github.com/mediacms-io/mediacms/blob/main/docs/admins_docs.md#5-configuration) section of this guide we've see how to edit the email settings.
|
On the [Configuration](https://github.com/mediacms-io/mediacms/blob/main/docs/admins_docs.md#5-configuration) section of this guide we've see how to edit the email settings.
|
||||||
In case we are yet unable to receive email from MediaCMS, the following may help us debug the issue - in most cases it is an issue of setting the correct username, password or TLS option
|
In case we are yet unable to receive email from MediaCMS, the following may help us debug the issue - in most cases it is an issue of setting the correct username, password or TLS option
|
||||||
|
|
||||||
Enter the Django shell, example if you're using the Single Server installation:
|
Enter the Django shell and inside the shell
|
||||||
|
|
||||||
```bash
|
|
||||||
source /home/mediacms.io/bin/activate
|
|
||||||
python manage.py shell
|
|
||||||
```
|
|
||||||
|
|
||||||
and inside the shell
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
from django.core.mail import EmailMessage
|
from django.core.mail import EmailMessage
|
||||||
|
|||||||
@@ -120,9 +120,11 @@ class MediaList(APIView):
|
|||||||
operation_description='Delete media for MediaCMS managers and reviewers',
|
operation_description='Delete media for MediaCMS managers and reviewers',
|
||||||
)
|
)
|
||||||
def delete(self, request, format=None):
|
def delete(self, request, format=None):
|
||||||
|
if not is_mediacms_manager(request.user):
|
||||||
|
return Response({"detail": "bad permissions"}, status=status.HTTP_403_FORBIDDEN)
|
||||||
tokens = request.GET.get("tokens")
|
tokens = request.GET.get("tokens")
|
||||||
if tokens:
|
if tokens:
|
||||||
tokens = tokens.split(",")
|
tokens = [t for t in tokens.split(",") if t][:50]
|
||||||
Media.objects.filter(friendly_token__in=tokens).delete()
|
Media.objects.filter(friendly_token__in=tokens).delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
@@ -177,7 +179,7 @@ class CommentList(APIView):
|
|||||||
def delete(self, request, format=None):
|
def delete(self, request, format=None):
|
||||||
comment_ids = request.GET.get("comment_ids")
|
comment_ids = request.GET.get("comment_ids")
|
||||||
if comment_ids:
|
if comment_ids:
|
||||||
comments = comment_ids.split(",")
|
comments = [c for c in comment_ids.split(",") if c][:50]
|
||||||
Comment.objects.filter(uid__in=comments).delete()
|
Comment.objects.filter(uid__in=comments).delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|||||||
+13
-9
@@ -453,17 +453,21 @@ def kill_ffmpeg_process(filepath):
|
|||||||
filepath: Path to the file being processed by ffmpeg
|
filepath: Path to the file being processed by ffmpeg
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
subprocess.CompletedProcess: Result of the kill command
|
bool: True if the lookup ran, False if input was unusable
|
||||||
"""
|
"""
|
||||||
if not filepath:
|
if not filepath or not isinstance(filepath, str):
|
||||||
return False
|
return False
|
||||||
cmd = "ps aux|grep 'ffmpeg'|grep %s|grep -v grep |awk '{print $2}'" % filepath
|
try:
|
||||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True)
|
ps = subprocess.run(["ps", "aux"], stdout=subprocess.PIPE, check=False)
|
||||||
pid = result.stdout.decode("utf-8").strip()
|
except OSError:
|
||||||
if pid:
|
return False
|
||||||
cmd = "kill -9 %s" % pid
|
for line in ps.stdout.decode("utf-8", "replace").splitlines():
|
||||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True)
|
if "ffmpeg" not in line or filepath not in line or "grep" in line:
|
||||||
return result
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) > 1 and parts[1].isdigit():
|
||||||
|
subprocess.run(["kill", "-9", parts[1]], check=False)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def copy_video(original_media, copy_encodings=True, title_suffix="(Trimmed)"):
|
def copy_video(original_media, copy_encodings=True, title_suffix="(Trimmed)"):
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from django.core.files import File
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models.signals import post_delete, post_save
|
from django.db.models.signals import post_delete, post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from .. import helpers
|
from .. import helpers
|
||||||
from .utils import (
|
from .utils import (
|
||||||
@@ -136,9 +135,6 @@ class Encoding(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.profile.name}-{self.media.title}"
|
return f"{self.profile.name}-{self.media.title}"
|
||||||
|
|
||||||
def get_absolute_url(self):
|
|
||||||
return reverse("api_get_encoding", kwargs={"encoding_id": self.id})
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Encoding)
|
@receiver(post_save, sender=Encoding)
|
||||||
def encoding_file_save(sender, instance, created, **kwargs):
|
def encoding_file_save(sender, instance, created, **kwargs):
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from django.conf import settings
|
|||||||
from django.contrib.postgres.indexes import GinIndex
|
from django.contrib.postgres.indexes import GinIndex
|
||||||
from django.contrib.postgres.search import SearchVectorField
|
from django.contrib.postgres.search import SearchVectorField
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.db import models
|
from django.db import models, transaction
|
||||||
from django.db.models import Func, Value
|
from django.db.models import Func, Value
|
||||||
from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete
|
from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
@@ -536,7 +536,9 @@ class Media(models.Model):
|
|||||||
|
|
||||||
from .. import tasks
|
from .. import tasks
|
||||||
|
|
||||||
tasks.produce_sprite_from_video.delay(self.friendly_token)
|
# Defer until the surrounding transaction commits so the worker can
|
||||||
|
# actually find the Media row. Runs immediately if not in a tx.
|
||||||
|
transaction.on_commit(lambda token=self.friendly_token: tasks.produce_sprite_from_video.delay(token))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def encode(self, profiles=[], force=True, chunkize=True):
|
def encode(self, profiles=[], force=True, chunkize=True):
|
||||||
@@ -559,9 +561,8 @@ class Media(models.Model):
|
|||||||
profiles.remove(profile)
|
profiles.remove(profile)
|
||||||
encoding = Encoding(media=self, profile=profile)
|
encoding = Encoding(media=self, profile=profile)
|
||||||
encoding.save()
|
encoding.save()
|
||||||
enc_url = settings.SSL_FRONTEND_HOST + encoding.get_absolute_url()
|
|
||||||
tasks.encode_media.apply_async(
|
tasks.encode_media.apply_async(
|
||||||
args=[self.friendly_token, profile.id, encoding.id, enc_url],
|
args=[self.friendly_token, profile.id, encoding.id],
|
||||||
kwargs={"force": force},
|
kwargs={"force": force},
|
||||||
priority=0,
|
priority=0,
|
||||||
)
|
)
|
||||||
@@ -575,13 +576,12 @@ class Media(models.Model):
|
|||||||
continue
|
continue
|
||||||
encoding = Encoding(media=self, profile=profile)
|
encoding = Encoding(media=self, profile=profile)
|
||||||
encoding.save()
|
encoding.save()
|
||||||
enc_url = settings.SSL_FRONTEND_HOST + encoding.get_absolute_url()
|
|
||||||
if profile.resolution in settings.MINIMUM_RESOLUTIONS_TO_ENCODE:
|
if profile.resolution in settings.MINIMUM_RESOLUTIONS_TO_ENCODE:
|
||||||
priority = 9
|
priority = 9
|
||||||
else:
|
else:
|
||||||
priority = 0
|
priority = 0
|
||||||
tasks.encode_media.apply_async(
|
tasks.encode_media.apply_async(
|
||||||
args=[self.friendly_token, profile.id, encoding.id, enc_url],
|
args=[self.friendly_token, profile.id, encoding.id],
|
||||||
kwargs={"force": force},
|
kwargs={"force": force},
|
||||||
priority=priority,
|
priority=priority,
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-5
@@ -171,8 +171,7 @@ def chunkize_media(self, friendly_token, profiles, force=True):
|
|||||||
continue
|
continue
|
||||||
encoding = Encoding(media=media, profile=profile)
|
encoding = Encoding(media=media, profile=profile)
|
||||||
encoding.save()
|
encoding.save()
|
||||||
enc_url = settings.SSL_FRONTEND_HOST + encoding.get_absolute_url()
|
encode_media.delay(friendly_token, profile.id, encoding.id, force=force)
|
||||||
encode_media.delay(friendly_token, profile.id, encoding.id, enc_url, force=force)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
chunks = [os.path.join(cwd, ch) for ch in chunks]
|
chunks = [os.path.join(cwd, ch) for ch in chunks]
|
||||||
@@ -202,13 +201,12 @@ def chunkize_media(self, friendly_token, profiles, force=True):
|
|||||||
)
|
)
|
||||||
|
|
||||||
encoding.save()
|
encoding.save()
|
||||||
enc_url = settings.SSL_FRONTEND_HOST + encoding.get_absolute_url()
|
|
||||||
if profile.resolution in settings.MINIMUM_RESOLUTIONS_TO_ENCODE:
|
if profile.resolution in settings.MINIMUM_RESOLUTIONS_TO_ENCODE:
|
||||||
priority = 0
|
priority = 0
|
||||||
else:
|
else:
|
||||||
priority = 9
|
priority = 9
|
||||||
encode_media.apply_async(
|
encode_media.apply_async(
|
||||||
args=[friendly_token, profile.id, encoding.id, enc_url],
|
args=[friendly_token, profile.id, encoding.id],
|
||||||
kwargs={"force": force, "chunk": True, "chunk_file_path": chunk},
|
kwargs={"force": force, "chunk": True, "chunk_file_path": chunk},
|
||||||
priority=priority,
|
priority=priority,
|
||||||
)
|
)
|
||||||
@@ -246,7 +244,6 @@ def encode_media(
|
|||||||
friendly_token,
|
friendly_token,
|
||||||
profile_id,
|
profile_id,
|
||||||
encoding_id,
|
encoding_id,
|
||||||
encoding_url,
|
|
||||||
force=True,
|
force=True,
|
||||||
chunk=False,
|
chunk=False,
|
||||||
chunk_file_path="",
|
chunk_file_path="",
|
||||||
|
|||||||
+1
-5
@@ -61,11 +61,6 @@ urlpatterns = [
|
|||||||
views.MediaDetail.as_view(),
|
views.MediaDetail.as_view(),
|
||||||
name="api_get_media",
|
name="api_get_media",
|
||||||
),
|
),
|
||||||
re_path(
|
|
||||||
r"^api/v1/media/encoding/(?P<encoding_id>[\w]*)$",
|
|
||||||
views.EncodingDetail.as_view(),
|
|
||||||
name="api_get_encoding",
|
|
||||||
),
|
|
||||||
re_path(r"^api/v1/search$", views.MediaSearch.as_view()),
|
re_path(r"^api/v1/search$", views.MediaSearch.as_view()),
|
||||||
re_path(
|
re_path(
|
||||||
rf"^api/v1/media/{friendly_token}/share$",
|
rf"^api/v1/media/{friendly_token}/share$",
|
||||||
@@ -111,6 +106,7 @@ urlpatterns = [
|
|||||||
re_path(r"^api/v1/tasks$", views.TasksList.as_view()),
|
re_path(r"^api/v1/tasks$", views.TasksList.as_view()),
|
||||||
re_path(r"^api/v1/tasks/$", views.TasksList.as_view()),
|
re_path(r"^api/v1/tasks/$", views.TasksList.as_view()),
|
||||||
re_path(r"^api/v1/tasks/(?P<friendly_token>[\w|\W]*)$", views.TaskDetail.as_view()),
|
re_path(r"^api/v1/tasks/(?P<friendly_token>[\w|\W]*)$", views.TaskDetail.as_view()),
|
||||||
|
re_path(r"^api/v1/media-auth$", views.media_auth, name="media_auth"),
|
||||||
re_path(r"^manage/comments$", views.manage_comments, name="manage_comments"),
|
re_path(r"^manage/comments$", views.manage_comments, name="manage_comments"),
|
||||||
re_path(r"^manage/media$", views.manage_media, name="manage_media"),
|
re_path(r"^manage/media$", views.manage_media, name="manage_media"),
|
||||||
re_path(r"^manage/users$", views.manage_users, name="manage_users"),
|
re_path(r"^manage/users$", views.manage_users, name="manage_users"),
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
from .auth import custom_login_view, saml_metadata # noqa: F401
|
from .auth import custom_login_view, saml_metadata # noqa: F401
|
||||||
from .categories import CategoryList, CategoryListContributor, TagList # noqa: F401
|
from .categories import CategoryList, CategoryListContributor, TagList # noqa: F401
|
||||||
from .comments import CommentDetail, CommentList # noqa: F401
|
from .comments import CommentDetail, CommentList # noqa: F401
|
||||||
from .encoding import EncodeProfileList, EncodingDetail # noqa: F401
|
from .encoding import EncodeProfileList # noqa: F401
|
||||||
from .media import MediaActions # noqa: F401
|
from .media import MediaActions # noqa: F401
|
||||||
from .media import MediaBulkUserActions # noqa: F401
|
from .media import MediaBulkUserActions # noqa: F401
|
||||||
from .media import MediaDetail # noqa: F401
|
from .media import MediaDetail # noqa: F401
|
||||||
from .media import MediaList # noqa: F401
|
from .media import MediaList # noqa: F401
|
||||||
from .media import MediaSearch # noqa: F401
|
from .media import MediaSearch # noqa: F401
|
||||||
from .media import media_share # noqa: F401
|
from .media import media_share # noqa: F401
|
||||||
|
from .media_auth import media_auth # noqa: F401
|
||||||
from .pages import about # noqa: F401
|
from .pages import about # noqa: F401
|
||||||
from .pages import add_subtitle # noqa: F401
|
from .pages import add_subtitle # noqa: F401
|
||||||
from .pages import approval_required # noqa: F401
|
from .pages import approval_required # noqa: F401
|
||||||
|
|||||||
+1
-158
@@ -1,168 +1,11 @@
|
|||||||
from django.conf import settings
|
|
||||||
from drf_yasg.utils import swagger_auto_schema
|
from drf_yasg.utils import swagger_auto_schema
|
||||||
from rest_framework import permissions, status
|
|
||||||
from rest_framework.parsers import (
|
|
||||||
FileUploadParser,
|
|
||||||
FormParser,
|
|
||||||
JSONParser,
|
|
||||||
MultiPartParser,
|
|
||||||
)
|
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from ..helpers import produce_ffmpeg_commands
|
from ..models import EncodeProfile
|
||||||
from ..models import EncodeProfile, Encoding
|
|
||||||
from ..serializers import EncodeProfileSerializer
|
from ..serializers import EncodeProfileSerializer
|
||||||
|
|
||||||
|
|
||||||
class EncodingDetail(APIView):
|
|
||||||
"""Experimental. This View is used by remote workers
|
|
||||||
Needs heavy testing and documentation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
permission_classes = (permissions.IsAdminUser,)
|
|
||||||
parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser)
|
|
||||||
|
|
||||||
@swagger_auto_schema(auto_schema=None)
|
|
||||||
def post(self, request, encoding_id):
|
|
||||||
ret = {}
|
|
||||||
force = request.data.get("force", False)
|
|
||||||
task_id = request.data.get("task_id", False)
|
|
||||||
action = request.data.get("action", "")
|
|
||||||
chunk = request.data.get("chunk", False)
|
|
||||||
chunk_file_path = request.data.get("chunk_file_path", "")
|
|
||||||
|
|
||||||
encoding_status = request.data.get("status", "")
|
|
||||||
progress = request.data.get("progress", "")
|
|
||||||
commands = request.data.get("commands", "")
|
|
||||||
logs = request.data.get("logs", "")
|
|
||||||
retries = request.data.get("retries", "")
|
|
||||||
worker = request.data.get("worker", "")
|
|
||||||
temp_file = request.data.get("temp_file", "")
|
|
||||||
total_run_time = request.data.get("total_run_time", "")
|
|
||||||
if action == "start":
|
|
||||||
try:
|
|
||||||
encoding = Encoding.objects.get(id=encoding_id)
|
|
||||||
media = encoding.media
|
|
||||||
profile = encoding.profile
|
|
||||||
except BaseException:
|
|
||||||
Encoding.objects.filter(id=encoding_id).delete()
|
|
||||||
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
# TODO: break chunk True/False logic here
|
|
||||||
if (
|
|
||||||
Encoding.objects.filter(
|
|
||||||
media=media,
|
|
||||||
profile=profile,
|
|
||||||
chunk=chunk,
|
|
||||||
chunk_file_path=chunk_file_path,
|
|
||||||
).count()
|
|
||||||
> 1 # noqa
|
|
||||||
and force is False # noqa
|
|
||||||
):
|
|
||||||
Encoding.objects.filter(id=encoding_id).delete()
|
|
||||||
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
else:
|
|
||||||
Encoding.objects.filter(
|
|
||||||
media=media,
|
|
||||||
profile=profile,
|
|
||||||
chunk=chunk,
|
|
||||||
chunk_file_path=chunk_file_path,
|
|
||||||
).exclude(id=encoding.id).delete()
|
|
||||||
|
|
||||||
encoding.status = "running"
|
|
||||||
if task_id:
|
|
||||||
encoding.task_id = task_id
|
|
||||||
|
|
||||||
encoding.save()
|
|
||||||
if chunk:
|
|
||||||
original_media_path = chunk_file_path
|
|
||||||
original_media_md5sum = encoding.md5sum
|
|
||||||
original_media_url = settings.SSL_FRONTEND_HOST + encoding.media_chunk_url
|
|
||||||
else:
|
|
||||||
original_media_path = media.media_file.path
|
|
||||||
original_media_md5sum = media.md5sum
|
|
||||||
original_media_url = settings.SSL_FRONTEND_HOST + media.original_media_url
|
|
||||||
|
|
||||||
ret["original_media_url"] = original_media_url
|
|
||||||
ret["original_media_path"] = original_media_path
|
|
||||||
ret["original_media_md5sum"] = original_media_md5sum
|
|
||||||
|
|
||||||
# generating the commands here, and will replace these with temporary
|
|
||||||
# files created on the remote server
|
|
||||||
tf = "TEMP_FILE_REPLACE"
|
|
||||||
tfpass = "TEMP_FPASS_FILE_REPLACE"
|
|
||||||
ffmpeg_commands = produce_ffmpeg_commands(
|
|
||||||
original_media_path,
|
|
||||||
media.media_info,
|
|
||||||
resolution=profile.resolution,
|
|
||||||
codec=profile.codec,
|
|
||||||
output_filename=tf,
|
|
||||||
pass_file=tfpass,
|
|
||||||
chunk=chunk,
|
|
||||||
)
|
|
||||||
if not ffmpeg_commands:
|
|
||||||
encoding.delete()
|
|
||||||
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
ret["duration"] = media.duration
|
|
||||||
ret["ffmpeg_commands"] = ffmpeg_commands
|
|
||||||
ret["profile_extension"] = profile.extension
|
|
||||||
return Response(ret, status=status.HTTP_201_CREATED)
|
|
||||||
elif action == "update_fields":
|
|
||||||
try:
|
|
||||||
encoding = Encoding.objects.get(id=encoding_id)
|
|
||||||
except BaseException:
|
|
||||||
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
to_update = ["size", "update_date"]
|
|
||||||
if encoding_status:
|
|
||||||
encoding.status = encoding_status
|
|
||||||
to_update.append("status")
|
|
||||||
if progress:
|
|
||||||
encoding.progress = progress
|
|
||||||
to_update.append("progress")
|
|
||||||
if logs:
|
|
||||||
encoding.logs = logs
|
|
||||||
to_update.append("logs")
|
|
||||||
if commands:
|
|
||||||
encoding.commands = commands
|
|
||||||
to_update.append("commands")
|
|
||||||
if task_id:
|
|
||||||
encoding.task_id = task_id
|
|
||||||
to_update.append("task_id")
|
|
||||||
if total_run_time:
|
|
||||||
encoding.total_run_time = total_run_time
|
|
||||||
to_update.append("total_run_time")
|
|
||||||
if worker:
|
|
||||||
encoding.worker = worker
|
|
||||||
to_update.append("worker")
|
|
||||||
if temp_file:
|
|
||||||
encoding.temp_file = temp_file
|
|
||||||
to_update.append("temp_file")
|
|
||||||
|
|
||||||
if retries:
|
|
||||||
encoding.retries = retries
|
|
||||||
to_update.append("retries")
|
|
||||||
|
|
||||||
try:
|
|
||||||
encoding.save(update_fields=to_update)
|
|
||||||
except BaseException:
|
|
||||||
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
return Response({"status": "success"}, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
@swagger_auto_schema(auto_schema=None)
|
|
||||||
def put(self, request, encoding_id, format=None):
|
|
||||||
encoding_file = request.data["file"]
|
|
||||||
encoding = Encoding.objects.filter(id=encoding_id).first()
|
|
||||||
if not encoding:
|
|
||||||
return Response(
|
|
||||||
{"detail": "encoding does not exist"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
encoding.media_file = encoding_file
|
|
||||||
encoding.save()
|
|
||||||
return Response({"detail": "ok"}, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
|
|
||||||
class EncodeProfileList(APIView):
|
class EncodeProfileList(APIView):
|
||||||
"""List encode profiles"""
|
"""List encode profiles"""
|
||||||
|
|
||||||
|
|||||||
@@ -660,7 +660,9 @@ class MediaBulkUserActions(APIView):
|
|||||||
|
|
||||||
# Prioritize category_uids
|
# Prioritize category_uids
|
||||||
if category_uids:
|
if category_uids:
|
||||||
categories = Category.objects.filter(uid__in=category_uids)
|
requested = Category.objects.filter(uid__in=category_uids)
|
||||||
|
allowed_ids = [cat.id for cat in requested if not cat.is_rbac_category or request.user.has_contributor_access_to_category(cat)]
|
||||||
|
categories = Category.objects.filter(id__in=allowed_ids)
|
||||||
elif lti_context_id:
|
elif lti_context_id:
|
||||||
# Filter categories by lti_context_id and ensure they ARE RBAC categories
|
# Filter categories by lti_context_id and ensure they ARE RBAC categories
|
||||||
potential_categories = Category.objects.filter(lti_context_id=lti_context_id, is_rbac_category=True)
|
potential_categories = Category.objects.filter(lti_context_id=lti_context_id, is_rbac_category=True)
|
||||||
@@ -691,9 +693,11 @@ class MediaBulkUserActions(APIView):
|
|||||||
if not category_uids:
|
if not category_uids:
|
||||||
return Response({"detail": "category_uids is required for remove_from_category action"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": "category_uids is required for remove_from_category action"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
categories = Category.objects.filter(uid__in=category_uids)
|
requested = Category.objects.filter(uid__in=category_uids)
|
||||||
|
allowed_ids = [cat.id for cat in requested if not cat.is_rbac_category or request.user.has_contributor_access_to_category(cat)]
|
||||||
|
categories = Category.objects.filter(id__in=allowed_ids)
|
||||||
if not categories:
|
if not categories:
|
||||||
return Response({"detail": "No matching categories found"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": "No matching categories found or access denied"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
removed_count = 0
|
removed_count = 0
|
||||||
for category in categories:
|
for category in categories:
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import re
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.decorators.http import require_GET
|
||||||
|
|
||||||
|
from ..methods import is_mediacms_editor
|
||||||
|
from ..models import Media
|
||||||
|
|
||||||
|
UID_RE = re.compile(r"[0-9a-f]{32}")
|
||||||
|
THUMBNAILS_PREFIX = "original/thumbnails/"
|
||||||
|
|
||||||
|
|
||||||
|
def _ttl():
|
||||||
|
return getattr(settings, "X_ACCEL_AUTH_CACHE_SECONDS", 300)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_uid(uri):
|
||||||
|
if not uri:
|
||||||
|
return None
|
||||||
|
match = UID_RE.search(uri)
|
||||||
|
return match.group(0) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def _relpath_from_uri(uri):
|
||||||
|
path = unquote(uri.split("?", 1)[0])
|
||||||
|
media_url = settings.MEDIA_URL
|
||||||
|
if path.startswith(media_url):
|
||||||
|
return path[len(media_url) :]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_uid_by_path(relpath):
|
||||||
|
path_key = f"xaccel:path:{relpath}"
|
||||||
|
cached = cache.get(path_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached or None
|
||||||
|
|
||||||
|
parts = relpath.split("/", 4)
|
||||||
|
if len(parts) < 5 or parts[2] != "user":
|
||||||
|
cache.set(path_key, "", _ttl())
|
||||||
|
return None
|
||||||
|
username = parts[3]
|
||||||
|
|
||||||
|
row = Media.objects.filter(user__username=username).filter(Q(uploaded_thumbnail=relpath) | Q(uploaded_poster=relpath)).values("uid").first()
|
||||||
|
uid_hex = row["uid"].hex if row else ""
|
||||||
|
cache.set(path_key, uid_hex, _ttl())
|
||||||
|
return uid_hex or None
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_state(uid):
|
||||||
|
"""Return (state, owner_id) for a uid, or (None, None) if missing.
|
||||||
|
|
||||||
|
Cached on uid alone since state/ownership do not depend on the requester.
|
||||||
|
Uses .values() rather than .only() because Media.__init__ touches deferred
|
||||||
|
file fields, which would otherwise recurse via refresh_from_db.
|
||||||
|
"""
|
||||||
|
state_key = f"xaccel:state:{uid}"
|
||||||
|
cached = cache.get(state_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
row = Media.objects.filter(uid=uid).values("state", "user_id").first()
|
||||||
|
value = (row["state"], row["user_id"]) if row else (None, None)
|
||||||
|
cache.set(state_key, value, _ttl())
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _decide(uid, user):
|
||||||
|
state, owner_id = _lookup_state(uid)
|
||||||
|
if state is None:
|
||||||
|
return False
|
||||||
|
if state in ("public", "unlisted"):
|
||||||
|
return True
|
||||||
|
# private
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return False
|
||||||
|
if owner_id == user.id:
|
||||||
|
return True
|
||||||
|
if is_mediacms_editor(user):
|
||||||
|
return True
|
||||||
|
# RBAC / MediaPermission path needs a full Media instance.
|
||||||
|
try:
|
||||||
|
media = Media.objects.get(uid=uid)
|
||||||
|
except Media.DoesNotExist:
|
||||||
|
return False
|
||||||
|
return user.has_member_access_to_media(media)
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_GET
|
||||||
|
def media_auth(request):
|
||||||
|
"""Authorize a protected media request from nginx auth_request.
|
||||||
|
|
||||||
|
nginx passes the original request URI in the X-Original-URI header. The
|
||||||
|
Media.uid (32 hex chars, no dashes) is embedded somewhere in that URI for
|
||||||
|
every protected path. No uid => deny. Unknown uid => deny.
|
||||||
|
"""
|
||||||
|
if not getattr(settings, "USE_X_ACCEL_REDIRECT", True):
|
||||||
|
return HttpResponse(status=204)
|
||||||
|
|
||||||
|
uri = request.META.get("HTTP_X_ORIGINAL_URI", "")
|
||||||
|
uid = _extract_uid(uri)
|
||||||
|
if not uid:
|
||||||
|
# User-uploaded thumbnails/posters don't have the uid in the filename.
|
||||||
|
# Fall back to a per-path lookup, scoped to /original/thumbnails/.
|
||||||
|
relpath = _relpath_from_uri(uri)
|
||||||
|
if relpath and relpath.startswith(THUMBNAILS_PREFIX):
|
||||||
|
uid = _lookup_uid_by_path(relpath)
|
||||||
|
if not uid:
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
cache_key = f"xaccel:auth:{uid}:{user.id if user.is_authenticated else 'anon'}"
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached is None:
|
||||||
|
allowed = _decide(uid, user)
|
||||||
|
cache.set(cache_key, allowed, _ttl())
|
||||||
|
else:
|
||||||
|
allowed = cached
|
||||||
|
|
||||||
|
return HttpResponse(status=204 if allowed else 403)
|
||||||
Vendored
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"prettier.configPath": "../.prettierrc"
|
||||||
}
|
}
|
||||||
@@ -5,5 +5,5 @@ module.exports = {
|
|||||||
'^.+\\.tsx?$': 'ts-jest',
|
'^.+\\.tsx?$': 'ts-jest',
|
||||||
'^.+\\.jsx?$': 'babel-jest',
|
'^.+\\.jsx?$': 'babel-jest',
|
||||||
},
|
},
|
||||||
collectCoverageFrom: ['src/**'],
|
collectCoverageFrom: ['src/**', '!src/static/lib/**'],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
"@babel/core": "^7.26.9",
|
"@babel/core": "^7.26.9",
|
||||||
"@babel/preset-env": "^7.26.9",
|
"@babel/preset-env": "^7.26.9",
|
||||||
"@babel/preset-react": "^7.26.3",
|
"@babel/preset-react": "^7.26.3",
|
||||||
|
"@testing-library/dom": "^8.20.1",
|
||||||
|
"@testing-library/jest-dom": "^5.17.0",
|
||||||
|
"@testing-library/react": "^12.1.5",
|
||||||
"@types/flux": "^3.1.15",
|
"@types/flux": "^3.1.15",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/minimatch": "^5.1.2",
|
"@types/minimatch": "^5.1.2",
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { MediaPageStore } from '../../utils/stores/';
|
||||||
|
import { AutoPlay } from './AutoPlay';
|
||||||
|
import { RelatedMedia } from './RelatedMedia';
|
||||||
import PlaylistView from './PlaylistView';
|
import PlaylistView from './PlaylistView';
|
||||||
|
|
||||||
export default class ViewerSidebar extends React.PureComponent {
|
export default class ViewerSidebar extends React.PureComponent {
|
||||||
@@ -9,6 +12,7 @@ export default class ViewerSidebar extends React.PureComponent {
|
|||||||
playlistData: props.playlistData,
|
playlistData: props.playlistData,
|
||||||
isPlaylistPage: !!props.playlistData,
|
isPlaylistPage: !!props.playlistData,
|
||||||
activeItem: 0,
|
activeItem: 0,
|
||||||
|
mediaType: MediaPageStore.get('media-type'),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (props.playlistData) {
|
if (props.playlistData) {
|
||||||
@@ -23,6 +27,21 @@ export default class ViewerSidebar extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.onMediaLoad = this.onMediaLoad.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
MediaPageStore.on('loaded_media_data', this.onMediaLoad);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
MediaPageStore.removeListener('loaded_media_data', this.onMediaLoad);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMediaLoad() {
|
||||||
|
this.setState({
|
||||||
|
mediaType: MediaPageStore.get('media-type'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -30,7 +49,10 @@ export default class ViewerSidebar extends React.PureComponent {
|
|||||||
<div className="viewer-sidebar">
|
<div className="viewer-sidebar">
|
||||||
{this.state.isPlaylistPage ? (
|
{this.state.isPlaylistPage ? (
|
||||||
<PlaylistView activeItem={this.state.activeItem} playlistData={this.props.playlistData} />
|
<PlaylistView activeItem={this.state.activeItem} playlistData={this.props.playlistData} />
|
||||||
|
) : 'video' === this.state.mediaType || 'audio' === this.state.mediaType ? (
|
||||||
|
<AutoPlay />
|
||||||
) : null}
|
) : null}
|
||||||
|
<RelatedMedia hideFirst={!this.state.isPlaylistPage} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// check templates/config/installation/translations.html for more
|
// check templates/config/installation/translations.html for more
|
||||||
|
|
||||||
export function translateString(str) {
|
export function translateString(str) {
|
||||||
return window.TRANSLATION?.[str] || str;
|
return window.TRANSLATION?.[str] ?? str;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,88 +9,89 @@ let browserCache;
|
|||||||
const _StoreData = {};
|
const _StoreData = {};
|
||||||
|
|
||||||
class VideoPlayerStore extends EventEmitter {
|
class VideoPlayerStore extends EventEmitter {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.mediacms_config = mediacmsConfig(window.MediaCMS);
|
this.mediacms_config = mediacmsConfig(window.MediaCMS);
|
||||||
|
|
||||||
browserCache = new BrowserCache(this.mediacms_config.site.id, 86400); // Keep cache data "fresh" for one day.
|
browserCache = new BrowserCache(this.mediacms_config.site.id, 86400); // Keep cache data "fresh" for one day.
|
||||||
|
|
||||||
_StoreData.inTheaterMode = browserCache.get('in-theater-mode');
|
_StoreData.inTheaterMode = browserCache.get('in-theater-mode');
|
||||||
_StoreData.inTheaterMode = null !== _StoreData.inTheaterMode ? _StoreData.inTheaterMode : !1;
|
_StoreData.inTheaterMode = null !== _StoreData.inTheaterMode ? _StoreData.inTheaterMode : !1;
|
||||||
|
|
||||||
_StoreData.playerVolume = browserCache.get('player-volume');
|
_StoreData.playerVolume = browserCache.get('player-volume');
|
||||||
_StoreData.playerVolume =
|
_StoreData.playerVolume =
|
||||||
null === _StoreData.playerVolume ? 1 : Math.max(Math.min(Number(_StoreData.playerVolume), 1), 0);
|
null === _StoreData.playerVolume ? 1 : Math.max(Math.min(Number(_StoreData.playerVolume), 1), 0);
|
||||||
|
|
||||||
_StoreData.playerSoundMuted = browserCache.get('player-sound-muted');
|
_StoreData.playerSoundMuted = browserCache.get('player-sound-muted');
|
||||||
_StoreData.playerSoundMuted = null !== _StoreData.playerSoundMuted ? _StoreData.playerSoundMuted : !1;
|
_StoreData.playerSoundMuted = null !== _StoreData.playerSoundMuted ? _StoreData.playerSoundMuted : !1;
|
||||||
|
|
||||||
_StoreData.videoQuality = browserCache.get('video-quality');
|
_StoreData.videoQuality = browserCache.get('video-quality');
|
||||||
_StoreData.videoQuality = null !== _StoreData.videoQuality ? _StoreData.videoQuality : 'Auto';
|
_StoreData.videoQuality = null !== _StoreData.videoQuality ? _StoreData.videoQuality : 'Auto';
|
||||||
|
|
||||||
_StoreData.videoPlaybackSpeed = browserCache.get('video-playback-speed');
|
_StoreData.videoPlaybackSpeed = browserCache.get('video-playback-speed');
|
||||||
_StoreData.videoPlaybackSpeed = null !== _StoreData.videoPlaybackSpeed ? _StoreData.videoPlaybackSpeed : !1;
|
_StoreData.videoPlaybackSpeed = null !== _StoreData.videoPlaybackSpeed ? _StoreData.videoPlaybackSpeed : !1;
|
||||||
}
|
|
||||||
|
|
||||||
get(type) {
|
|
||||||
let r = null;
|
|
||||||
switch (type) {
|
|
||||||
case 'player-volume':
|
|
||||||
r = _StoreData.playerVolume;
|
|
||||||
break;
|
|
||||||
case 'player-sound-muted':
|
|
||||||
r = _StoreData.playerSoundMuted;
|
|
||||||
break;
|
|
||||||
case 'in-theater-mode':
|
|
||||||
r = _StoreData.inTheaterMode;
|
|
||||||
break;
|
|
||||||
case 'video-data':
|
|
||||||
r = _StoreData.videoData;
|
|
||||||
break;
|
|
||||||
case 'video-quality':
|
|
||||||
r = _StoreData.videoQuality;
|
|
||||||
break;
|
|
||||||
case 'video-playback-speed':
|
|
||||||
r = _StoreData.videoPlaybackSpeed;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
actions_handler(action) {
|
get(type) {
|
||||||
switch (action.type) {
|
let r = null;
|
||||||
case 'TOGGLE_VIEWER_MODE':
|
switch (type) {
|
||||||
_StoreData.inTheaterMode = !_StoreData.inTheaterMode;
|
case 'player-volume':
|
||||||
this.emit('changed_viewer_mode');
|
r = _StoreData.playerVolume;
|
||||||
break;
|
break;
|
||||||
case 'SET_VIEWER_MODE':
|
case 'player-sound-muted':
|
||||||
_StoreData.inTheaterMode = action.inTheaterMode;
|
r = _StoreData.playerSoundMuted;
|
||||||
browserCache.set('in-theater-mode', _StoreData.inTheaterMode);
|
break;
|
||||||
this.emit('changed_viewer_mode');
|
case 'in-theater-mode':
|
||||||
break;
|
r = _StoreData.inTheaterMode;
|
||||||
case 'SET_PLAYER_VOLUME':
|
break;
|
||||||
_StoreData.playerVolume = action.playerVolume;
|
case 'video-data':
|
||||||
browserCache.set('player-volume', action.playerVolume);
|
r = _StoreData.videoData;
|
||||||
this.emit('changed_player_volume');
|
break;
|
||||||
break;
|
case 'video-quality':
|
||||||
case 'SET_PLAYER_SOUND_MUTED':
|
r = _StoreData.videoQuality;
|
||||||
_StoreData.playerSoundMuted = action.playerSoundMuted;
|
break;
|
||||||
browserCache.set('player-sound-muted', action.playerSoundMuted);
|
case 'video-playback-speed':
|
||||||
this.emit('changed_player_sound_muted');
|
r = _StoreData.videoPlaybackSpeed;
|
||||||
break;
|
break;
|
||||||
case 'SET_VIDEO_QUALITY':
|
}
|
||||||
_StoreData.videoQuality = action.quality;
|
return r;
|
||||||
browserCache.set('video-quality', action.quality);
|
}
|
||||||
this.emit('changed_video_quality');
|
|
||||||
break;
|
actions_handler(action) {
|
||||||
case 'SET_VIDEO_PLAYBACK_SPEED':
|
switch (action.type) {
|
||||||
_StoreData.videoPlaybackSpeed = action.playbackSpeed;
|
case 'TOGGLE_VIEWER_MODE':
|
||||||
browserCache.set('video-playback-speed', action.playbackSpeed);
|
_StoreData.inTheaterMode = !_StoreData.inTheaterMode;
|
||||||
this.emit('changed_video_playback_speed');
|
browserCache.set('in-theater-mode', _StoreData.inTheaterMode);
|
||||||
break;
|
this.emit('changed_viewer_mode');
|
||||||
|
break;
|
||||||
|
case 'SET_VIEWER_MODE':
|
||||||
|
_StoreData.inTheaterMode = action.inTheaterMode;
|
||||||
|
browserCache.set('in-theater-mode', _StoreData.inTheaterMode);
|
||||||
|
this.emit('changed_viewer_mode');
|
||||||
|
break;
|
||||||
|
case 'SET_PLAYER_VOLUME':
|
||||||
|
_StoreData.playerVolume = action.playerVolume;
|
||||||
|
browserCache.set('player-volume', action.playerVolume);
|
||||||
|
this.emit('changed_player_volume');
|
||||||
|
break;
|
||||||
|
case 'SET_PLAYER_SOUND_MUTED':
|
||||||
|
_StoreData.playerSoundMuted = action.playerSoundMuted;
|
||||||
|
browserCache.set('player-sound-muted', action.playerSoundMuted);
|
||||||
|
this.emit('changed_player_sound_muted');
|
||||||
|
break;
|
||||||
|
case 'SET_VIDEO_QUALITY':
|
||||||
|
_StoreData.videoQuality = action.quality;
|
||||||
|
browserCache.set('video-quality', action.quality);
|
||||||
|
this.emit('changed_video_quality');
|
||||||
|
break;
|
||||||
|
case 'SET_VIDEO_PLAYBACK_SPEED':
|
||||||
|
_StoreData.videoPlaybackSpeed = action.playbackSpeed;
|
||||||
|
browserCache.set('video-playback-speed', action.playbackSpeed);
|
||||||
|
this.emit('changed_video_playback_speed');
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default exportStore(new VideoPlayerStore(), 'actions_handler');
|
export default exportStore(new VideoPlayerStore(), 'actions_handler');
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
export const sampleGlobalMediaCMS = {
|
||||||
|
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: '1.0.0',
|
||||||
|
taxonomies: {
|
||||||
|
tags: { enabled: true, title: 'Topic Tags' },
|
||||||
|
categories: { enabled: false, title: 'Kinds' },
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
featured: { enabled: true, title: 'Featured picks' },
|
||||||
|
latest: { enabled: true, title: 'Recent uploads' },
|
||||||
|
members: { enabled: true, title: 'People' },
|
||||||
|
recommended: { enabled: false, title: 'You may like' },
|
||||||
|
},
|
||||||
|
userPages: {
|
||||||
|
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',
|
||||||
|
actions: 'v1/actions',
|
||||||
|
comments: 'v1/comments',
|
||||||
|
},
|
||||||
|
contents: {
|
||||||
|
header: {
|
||||||
|
right: '',
|
||||||
|
onLogoRight: '',
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
messages: { addToLiked: 'Yay', removeFromLiked: 'Oops', addToDisliked: 'nay', removeFromDisliked: 'ok' },
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
belowNavMenu: '__belowNavMenu__',
|
||||||
|
belowThemeSwitcher: '__belowThemeSwitcher__',
|
||||||
|
footer: '__footer__',
|
||||||
|
mainMenuExtraItems: [
|
||||||
|
{ text: '__text_1__', link: '__link_1__', icon: '__icon_1__', className: '__className_1__' },
|
||||||
|
],
|
||||||
|
navMenuItems: [
|
||||||
|
{ text: '__text_2__', link: '__link_2__', icon: '__icon_2__', className: '__className_2__' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
uploader: {
|
||||||
|
belowUploadArea: '__belowUploadArea__',
|
||||||
|
postUploadMessage: '__postUploadMessage__',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
home: {
|
||||||
|
sections: {
|
||||||
|
latest: { title: 'Latest T' },
|
||||||
|
featured: { title: 'Featured T' },
|
||||||
|
recommended: { title: 'Recommended T' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
media: { categoriesWithTitle: true, htmlInDescription: true, hideViews: true, related: { initialSize: 5 } },
|
||||||
|
profile: { htmlInDescription: true, includeHistory: true, includeLikedMedia: true },
|
||||||
|
search: { advancedFilters: 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'],
|
||||||
|
},
|
||||||
|
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 ' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sampleMediaCMSConfig = {
|
||||||
|
api: {
|
||||||
|
archive: {
|
||||||
|
tags: '',
|
||||||
|
categories: '',
|
||||||
|
},
|
||||||
|
featured: '',
|
||||||
|
manage: {
|
||||||
|
media: '',
|
||||||
|
users: '',
|
||||||
|
comments: '',
|
||||||
|
},
|
||||||
|
media: '',
|
||||||
|
playlists: '/v1/playlists',
|
||||||
|
recommended: '',
|
||||||
|
search: {
|
||||||
|
query: '',
|
||||||
|
titles: './search.html?titles=',
|
||||||
|
tag: '',
|
||||||
|
category: '',
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
liked: '',
|
||||||
|
history: '',
|
||||||
|
playlists: '/playlists/?author=',
|
||||||
|
},
|
||||||
|
users: '/users',
|
||||||
|
},
|
||||||
|
contents: {
|
||||||
|
header: {
|
||||||
|
right: '',
|
||||||
|
onLogoRight: '',
|
||||||
|
},
|
||||||
|
uploader: {
|
||||||
|
belowUploadArea: '',
|
||||||
|
postUploadMessage: '',
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
belowNavMenu: '__belowNavMenu__',
|
||||||
|
belowThemeSwitcher: '__belowThemeSwitcher__',
|
||||||
|
footer: '__footer__',
|
||||||
|
mainMenuExtra: {
|
||||||
|
items: [{ text: '__text_1__', link: '__link_1__', icon: '__icon_1__', className: '__className_1__' }],
|
||||||
|
},
|
||||||
|
navMenu: {
|
||||||
|
items: [{ text: '__text_2__', link: '__link_2__', icon: '__icon_2__', className: '__className_2__' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: {
|
||||||
|
taxonomies: sampleGlobalMediaCMS.site.taxonomies,
|
||||||
|
pages: {
|
||||||
|
featured: { enabled: true, title: 'Featured picks' },
|
||||||
|
latest: { enabled: true, title: 'Recent uploads' },
|
||||||
|
members: { enabled: true, title: 'People' },
|
||||||
|
recommended: { enabled: true, title: 'You may like' },
|
||||||
|
liked: { enabled: true, title: 'Favorites' },
|
||||||
|
history: { enabled: true, title: 'Watched' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
member: {
|
||||||
|
name: null,
|
||||||
|
username: 'john',
|
||||||
|
thumbnail: null,
|
||||||
|
is: {
|
||||||
|
admin: false,
|
||||||
|
anonymous: false,
|
||||||
|
},
|
||||||
|
can: {
|
||||||
|
addComment: false,
|
||||||
|
addMedia: false,
|
||||||
|
canSeeMembersPage: false,
|
||||||
|
changePassword: false,
|
||||||
|
contactUser: false,
|
||||||
|
deleteComment: false,
|
||||||
|
deleteMedia: false,
|
||||||
|
deleteProfile: false,
|
||||||
|
dislikeMedia: false,
|
||||||
|
downloadMedia: false,
|
||||||
|
editMedia: false,
|
||||||
|
editProfile: false,
|
||||||
|
editSubtitle: false,
|
||||||
|
likeMedia: false,
|
||||||
|
login: false,
|
||||||
|
manageComments: false,
|
||||||
|
manageMedia: false,
|
||||||
|
manageUsers: false,
|
||||||
|
mentionComment: false,
|
||||||
|
readComment: true,
|
||||||
|
register: false,
|
||||||
|
reportMedia: false,
|
||||||
|
saveMedia: true,
|
||||||
|
shareMedia: false,
|
||||||
|
usersNeedsToBeApproved: false,
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
home: null,
|
||||||
|
about: null,
|
||||||
|
media: null,
|
||||||
|
playlists: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
item: {
|
||||||
|
displayAuthor: false,
|
||||||
|
displayViews: false,
|
||||||
|
displayPublishDate: false,
|
||||||
|
},
|
||||||
|
share: {
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
messages: {
|
||||||
|
addToLiked: '',
|
||||||
|
removeFromLiked: '',
|
||||||
|
addToDisliked: '',
|
||||||
|
removeFromDisliked: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
pages: {
|
||||||
|
home: {
|
||||||
|
sections: {
|
||||||
|
latest: {
|
||||||
|
title: '',
|
||||||
|
},
|
||||||
|
featured: {
|
||||||
|
title: '',
|
||||||
|
},
|
||||||
|
recommended: {
|
||||||
|
title: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
advancedFilters: false,
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
categoriesWithTitle: true,
|
||||||
|
htmlInDescription: true,
|
||||||
|
related: { initialSize: 5 },
|
||||||
|
displayViews: true,
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
htmlInDescription: false,
|
||||||
|
includeHistory: false,
|
||||||
|
includeLikedMedia: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
embedded: {
|
||||||
|
video: {
|
||||||
|
dimensions: {
|
||||||
|
width: 0,
|
||||||
|
widthUnit: 'px',
|
||||||
|
height: 0,
|
||||||
|
heightUnit: 'px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
playlists: {
|
||||||
|
mediaTypes: [],
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
hideHomeLink: false,
|
||||||
|
hideTagsLink: false,
|
||||||
|
hideCategoriesLink: false,
|
||||||
|
},
|
||||||
|
site: {
|
||||||
|
api: '',
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
url: '',
|
||||||
|
useRoundedCorners: false,
|
||||||
|
version: '',
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
logo: {
|
||||||
|
lightMode: { img: '/img/light.png', svg: '/img/light.svg' },
|
||||||
|
darkMode: { img: '/img/dark.png', svg: '/img/dark.svg' },
|
||||||
|
},
|
||||||
|
mode: 'dark',
|
||||||
|
switch: {
|
||||||
|
enabled: true,
|
||||||
|
position: 'sidebar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
admin: '',
|
||||||
|
archive: {
|
||||||
|
categories: '',
|
||||||
|
tags: '',
|
||||||
|
},
|
||||||
|
changePassword: '',
|
||||||
|
embed: '',
|
||||||
|
error404: '',
|
||||||
|
featured: '',
|
||||||
|
home: '',
|
||||||
|
latest: '',
|
||||||
|
manage: {
|
||||||
|
comments: '',
|
||||||
|
media: '',
|
||||||
|
users: '',
|
||||||
|
},
|
||||||
|
members: '',
|
||||||
|
profile: {
|
||||||
|
about: '',
|
||||||
|
media: '',
|
||||||
|
playlists: '',
|
||||||
|
shared_by_me: '',
|
||||||
|
shared_with_me: '',
|
||||||
|
},
|
||||||
|
recommended: '',
|
||||||
|
register: '',
|
||||||
|
search: {
|
||||||
|
base: '',
|
||||||
|
category: '',
|
||||||
|
query: '',
|
||||||
|
tag: '',
|
||||||
|
},
|
||||||
|
signin: '',
|
||||||
|
signout: '',
|
||||||
|
user: {
|
||||||
|
addMedia: '',
|
||||||
|
editChannel: '',
|
||||||
|
editProfile: '',
|
||||||
|
history: '',
|
||||||
|
liked: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import * as MediaPageActions from '../../../src/static/js/utils/actions/MediaPageActions';
|
||||||
|
import dispatcher from '../../../src/static/js/utils/dispatcher';
|
||||||
|
|
||||||
|
// Mock the dispatcher module used by MediaPageActions
|
||||||
|
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
|
||||||
|
|
||||||
|
describe('utils/actions', () => {
|
||||||
|
describe('MediaPageActions', () => {
|
||||||
|
const dispatch = dispatcher.dispatch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(dispatcher.dispatch as jest.Mock).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadMediaData', () => {
|
||||||
|
it('Should dispatch LOAD_MEDIA_DATA action', () => {
|
||||||
|
MediaPageActions.loadMediaData();
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'LOAD_MEDIA_DATA' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('likeMedia / dislikeMedia', () => {
|
||||||
|
it('Should dispatch LIKE_MEDIA action', () => {
|
||||||
|
MediaPageActions.likeMedia();
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'LIKE_MEDIA' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should dispatch DISLIKE_MEDIA action', () => {
|
||||||
|
MediaPageActions.dislikeMedia();
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'DISLIKE_MEDIA' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reportMedia', () => {
|
||||||
|
it('Should dispatch REPORT_MEDIA with empty string when description is undefined', () => {
|
||||||
|
MediaPageActions.reportMedia();
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'REPORT_MEDIA', reportDescription: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// @todo: Revisit this behavior
|
||||||
|
it('Should dispatch REPORT_MEDIA with stripped description when provided', () => {
|
||||||
|
MediaPageActions.reportMedia(' some text ');
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'REPORT_MEDIA', reportDescription: 'sometext' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// @todo: Revisit this behavior
|
||||||
|
it('Should remove all whitespace characters including newlines and tabs', () => {
|
||||||
|
MediaPageActions.reportMedia('\n\t spaced\ntext \t');
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'REPORT_MEDIA', reportDescription: 'spacedtext' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('copyShareLink / copyEmbedMediaCode', () => {
|
||||||
|
it('Should dispatch COPY_SHARE_LINK carrying the provided input element', () => {
|
||||||
|
const inputElem = document.createElement('input');
|
||||||
|
MediaPageActions.copyShareLink(inputElem);
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'COPY_SHARE_LINK', inputElement: inputElem });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should dispatch COPY_EMBED_MEDIA_CODE carrying the provided textarea element', () => {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
MediaPageActions.copyEmbedMediaCode(textarea);
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'COPY_EMBED_MEDIA_CODE', inputElement: textarea });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeMedia', () => {
|
||||||
|
it('Should dispatch REMOVE_MEDIA action', () => {
|
||||||
|
MediaPageActions.removeMedia();
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'REMOVE_MEDIA' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('comments', () => {
|
||||||
|
it('Should dispatch SUBMIT_COMMENT with provided text', () => {
|
||||||
|
const commentText = 'Nice one';
|
||||||
|
MediaPageActions.submitComment(commentText);
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'SUBMIT_COMMENT', commentText });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should dispatch DELETE_COMMENT with provided comment id', () => {
|
||||||
|
const commentId = 'c-123';
|
||||||
|
MediaPageActions.deleteComment(commentId);
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'DELETE_COMMENT', commentId });
|
||||||
|
});
|
||||||
|
|
||||||
|
// @todo: Revisit this behavior
|
||||||
|
it('Should dispatch DELETE_COMMENT with numeric comment id', () => {
|
||||||
|
const commentId = 42;
|
||||||
|
MediaPageActions.deleteComment(commentId);
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'DELETE_COMMENT', commentId });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('playlists', () => {
|
||||||
|
it('Should dispatch CREATE_PLAYLIST with provided data', () => {
|
||||||
|
const payload = { title: 'My list', description: 'Desc' };
|
||||||
|
MediaPageActions.createPlaylist(payload);
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'CREATE_PLAYLIST', playlist_data: payload });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should dispatch ADD_MEDIA_TO_PLAYLIST with ids', () => {
|
||||||
|
const playlist_id = 'pl-1';
|
||||||
|
const media_id = 'm-1';
|
||||||
|
MediaPageActions.addMediaToPlaylist(playlist_id, media_id);
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'ADD_MEDIA_TO_PLAYLIST', playlist_id, media_id });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should dispatch REMOVE_MEDIA_FROM_PLAYLIST with ids', () => {
|
||||||
|
const playlist_id = 'pl-1';
|
||||||
|
const media_id = 'm-1';
|
||||||
|
MediaPageActions.removeMediaFromPlaylist(playlist_id, media_id);
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'REMOVE_MEDIA_FROM_PLAYLIST', playlist_id, media_id });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should dispatch APPEND_NEW_PLAYLIST with provided playlist data', () => {
|
||||||
|
const playlist_data = {
|
||||||
|
playlist_id: 'pl-2',
|
||||||
|
add_date: new Date('2020-01-01T00:00:00Z'),
|
||||||
|
description: 'Cool',
|
||||||
|
title: 'T',
|
||||||
|
media_list: ['a', 'b'],
|
||||||
|
};
|
||||||
|
MediaPageActions.addNewPlaylist(playlist_data);
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'APPEND_NEW_PLAYLIST', playlist_data });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import * as PageActions from '../../../src/static/js/utils/actions/PageActions';
|
||||||
|
import dispatcher from '../../../src/static/js/utils/dispatcher';
|
||||||
|
|
||||||
|
// Mock the dispatcher module used by PageActions
|
||||||
|
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
|
||||||
|
|
||||||
|
describe('utils/actions', () => {
|
||||||
|
describe('PageActions', () => {
|
||||||
|
const dispatch = dispatcher.dispatch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(dispatcher.dispatch as jest.Mock).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initPage', () => {
|
||||||
|
it('Should dispatch INIT_PAGE with provided page string', () => {
|
||||||
|
PageActions.initPage('home');
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'INIT_PAGE', page: 'home' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// @todo: Revisit this behavior
|
||||||
|
it('Should dispatch INIT_PAGE with empty string', () => {
|
||||||
|
PageActions.initPage('');
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'INIT_PAGE', page: '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleMediaAutoPlay', () => {
|
||||||
|
it('Should dispatch TOGGLE_AUTO_PLAY action', () => {
|
||||||
|
PageActions.toggleMediaAutoPlay();
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_AUTO_PLAY' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addNotification', () => {
|
||||||
|
it('Should dispatch ADD_NOTIFICATION with message and id', () => {
|
||||||
|
const notification = 'Saved!';
|
||||||
|
const notificationId = 'notif-1';
|
||||||
|
PageActions.addNotification(notification, notificationId);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'ADD_NOTIFICATION', notification, notificationId });
|
||||||
|
});
|
||||||
|
|
||||||
|
// @todo: Revisit this behavior
|
||||||
|
it('Should dispatch ADD_NOTIFICATION with empty notification message', () => {
|
||||||
|
const notification = '';
|
||||||
|
const notificationId = 'id-empty';
|
||||||
|
PageActions.addNotification(notification, notificationId);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'ADD_NOTIFICATION', notification, notificationId });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { PlaylistPageActions } from '../../../src/static/js/utils/actions';
|
||||||
|
import dispatcher from '../../../src/static/js/utils/dispatcher';
|
||||||
|
|
||||||
|
// Mock the dispatcher module used by PlaylistPageActions
|
||||||
|
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
|
||||||
|
|
||||||
|
describe('utils/actions', () => {
|
||||||
|
describe('PlaylistPageActions', () => {
|
||||||
|
const dispatch = dispatcher.dispatch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(dispatcher.dispatch as jest.Mock).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadPlaylistData', () => {
|
||||||
|
it('Should dispatch LOAD_PLAYLIST_DATA action', () => {
|
||||||
|
PlaylistPageActions.loadPlaylistData();
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'LOAD_PLAYLIST_DATA' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleSave', () => {
|
||||||
|
it('Should dispatch TOGGLE_SAVE action', () => {
|
||||||
|
PlaylistPageActions.toggleSave();
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_SAVE' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updatePlaylist', () => {
|
||||||
|
it('Should dispatch UPDATE_PLAYLIST with provided title and description', () => {
|
||||||
|
const payload = { title: 'My Playlist', description: 'A description' };
|
||||||
|
PlaylistPageActions.updatePlaylist(payload);
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'UPDATE_PLAYLIST', playlist_data: payload });
|
||||||
|
});
|
||||||
|
|
||||||
|
// @todo: Revisit this behavior
|
||||||
|
it('Should dispatch UPDATE_PLAYLIST with empty strings for title and description', () => {
|
||||||
|
const payload = { title: '', description: '' };
|
||||||
|
PlaylistPageActions.updatePlaylist(payload);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'UPDATE_PLAYLIST', playlist_data: payload });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removePlaylist', () => {
|
||||||
|
it('Should dispatch REMOVE_PLAYLIST action', () => {
|
||||||
|
PlaylistPageActions.removePlaylist();
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'REMOVE_PLAYLIST' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removedMediaFromPlaylist', () => {
|
||||||
|
it('Should dispatch MEDIA_REMOVED_FROM_PLAYLIST with media and playlist ids', () => {
|
||||||
|
PlaylistPageActions.removedMediaFromPlaylist('m1', 'p1');
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({
|
||||||
|
type: 'MEDIA_REMOVED_FROM_PLAYLIST',
|
||||||
|
media_id: 'm1',
|
||||||
|
playlist_id: 'p1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// @todo: Revisit this behavior
|
||||||
|
it('Should dispatch MEDIA_REMOVED_FROM_PLAYLIST with empty ids as strings', () => {
|
||||||
|
PlaylistPageActions.removedMediaFromPlaylist('', '');
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({
|
||||||
|
type: 'MEDIA_REMOVED_FROM_PLAYLIST',
|
||||||
|
media_id: '',
|
||||||
|
playlist_id: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reorderedMediaInPlaylist', () => {
|
||||||
|
it('Should dispatch PLAYLIST_MEDIA_REORDERED with provided array', () => {
|
||||||
|
const items = [
|
||||||
|
{ id: '1', url: '/1', thumbnail_url: '/t1' },
|
||||||
|
{ id: '2', url: '/2', thumbnail_url: '/t2' },
|
||||||
|
];
|
||||||
|
PlaylistPageActions.reorderedMediaInPlaylist(items);
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'PLAYLIST_MEDIA_REORDERED', playlist_media: items });
|
||||||
|
});
|
||||||
|
|
||||||
|
// @todo: Revisit this behavior
|
||||||
|
it('Should dispatch PLAYLIST_MEDIA_REORDERED with empty array for playlist media', () => {
|
||||||
|
const items: any[] = [];
|
||||||
|
PlaylistPageActions.reorderedMediaInPlaylist(items);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'PLAYLIST_MEDIA_REORDERED', playlist_media: items });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { PlaylistViewActions } from '../../../src/static/js/utils/actions';
|
||||||
|
import dispatcher from '../../../src/static/js/utils/dispatcher';
|
||||||
|
|
||||||
|
// Mock the dispatcher module used by PlaylistViewActions
|
||||||
|
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
|
||||||
|
|
||||||
|
describe('utils/actions', () => {
|
||||||
|
describe('PlaylistViewActions', () => {
|
||||||
|
const dispatch = dispatcher.dispatch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(dispatcher.dispatch as jest.Mock).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleLoop', () => {
|
||||||
|
it('Should dispatch TOGGLE_LOOP action', () => {
|
||||||
|
PlaylistViewActions.toggleLoop();
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_LOOP' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleShuffle', () => {
|
||||||
|
it('Should dispatch TOGGLE_SHUFFLE action', () => {
|
||||||
|
PlaylistViewActions.toggleShuffle();
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_SHUFFLE' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleSave', () => {
|
||||||
|
it('Should dispatch TOGGLE_SAVE action', () => {
|
||||||
|
PlaylistViewActions.toggleSave();
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_SAVE' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { ProfilePageActions } from '../../../src/static/js/utils/actions';
|
||||||
|
import dispatcher from '../../../src/static/js/utils/dispatcher';
|
||||||
|
|
||||||
|
// Mock the dispatcher module used by ProfilePageActions
|
||||||
|
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
|
||||||
|
|
||||||
|
describe('utils/actions', () => {
|
||||||
|
describe('ProfilePageActions', () => {
|
||||||
|
const dispatch = dispatcher.dispatch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(dispatcher.dispatch as jest.Mock).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should dispatch LOAD_AUTHOR_DATA ', () => {
|
||||||
|
ProfilePageActions.load_author_data();
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'LOAD_AUTHOR_DATA' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should dispatch REMOVE_PROFILE ', () => {
|
||||||
|
ProfilePageActions.remove_profile();
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: 'REMOVE_PROFILE' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { SearchFieldActions } from '../../../src/static/js/utils/actions';
|
||||||
|
import dispatcher from '../../../src/static/js/utils/dispatcher';
|
||||||
|
|
||||||
|
// Mock the dispatcher module used by SearchFieldActions
|
||||||
|
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
|
||||||
|
|
||||||
|
describe('utils/actions', () => {
|
||||||
|
describe('SearchFieldActions', () => {
|
||||||
|
const dispatch = dispatcher.dispatch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(dispatcher.dispatch as jest.Mock).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requestPredictions', () => {
|
||||||
|
it('Should dispatch REQUEST_PREDICTIONS with provided query strings', () => {
|
||||||
|
SearchFieldActions.requestPredictions('cats');
|
||||||
|
SearchFieldActions.requestPredictions('');
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'REQUEST_PREDICTIONS', query: 'cats' });
|
||||||
|
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'REQUEST_PREDICTIONS', query: '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { VideoViewerActions } from '../../../src/static/js/utils/actions';
|
||||||
|
import dispatcher from '../../../src/static/js/utils/dispatcher';
|
||||||
|
|
||||||
|
// Mock the dispatcher module used by VideoViewerActions
|
||||||
|
jest.mock('../../../src/static/js/utils/dispatcher', () => ({ dispatch: jest.fn() }));
|
||||||
|
|
||||||
|
describe('utils/actions', () => {
|
||||||
|
describe('VideoViewerActions', () => {
|
||||||
|
const dispatch = dispatcher.dispatch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(dispatcher.dispatch as jest.Mock).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('set_viewer_mode', () => {
|
||||||
|
it('Should dispatch SET_VIEWER_MODE with "true" and "false" for enabling and disabling theater mode', () => {
|
||||||
|
VideoViewerActions.set_viewer_mode(true);
|
||||||
|
VideoViewerActions.set_viewer_mode(false);
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'SET_VIEWER_MODE', inTheaterMode: true });
|
||||||
|
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'SET_VIEWER_MODE', inTheaterMode: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('set_player_volume', () => {
|
||||||
|
it('Should dispatch SET_PLAYER_VOLUME with provided volume numbers', () => {
|
||||||
|
VideoViewerActions.set_player_volume(0);
|
||||||
|
VideoViewerActions.set_player_volume(0.75);
|
||||||
|
VideoViewerActions.set_player_volume(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(3);
|
||||||
|
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'SET_PLAYER_VOLUME', playerVolume: 0 });
|
||||||
|
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'SET_PLAYER_VOLUME', playerVolume: 0.75 });
|
||||||
|
expect(dispatch).toHaveBeenNthCalledWith(3, { type: 'SET_PLAYER_VOLUME', playerVolume: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('set_player_sound_muted', () => {
|
||||||
|
it('Should dispatch SET_PLAYER_SOUND_MUTED with "true" and "false"', () => {
|
||||||
|
VideoViewerActions.set_player_sound_muted(true);
|
||||||
|
VideoViewerActions.set_player_sound_muted(false);
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'SET_PLAYER_SOUND_MUTED', playerSoundMuted: true });
|
||||||
|
expect(dispatch).toHaveBeenNthCalledWith(2, {
|
||||||
|
type: 'SET_PLAYER_SOUND_MUTED',
|
||||||
|
playerSoundMuted: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('set_video_quality', () => {
|
||||||
|
it('Should dispatch SET_VIDEO_QUALITY with "auto" and numeric quality', () => {
|
||||||
|
VideoViewerActions.set_video_quality('auto');
|
||||||
|
VideoViewerActions.set_video_quality(720);
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'SET_VIDEO_QUALITY', quality: 'auto' });
|
||||||
|
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'SET_VIDEO_QUALITY', quality: 720 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('set_video_playback_speed', () => {
|
||||||
|
it('Should dispatch SET_VIDEO_PLAYBACK_SPEED with different speeds', () => {
|
||||||
|
VideoViewerActions.set_video_playback_speed(1.5);
|
||||||
|
VideoViewerActions.set_video_playback_speed(0.5);
|
||||||
|
VideoViewerActions.set_video_playback_speed(2);
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(3);
|
||||||
|
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'SET_VIDEO_PLAYBACK_SPEED', playbackSpeed: 1.5 });
|
||||||
|
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'SET_VIDEO_PLAYBACK_SPEED', playbackSpeed: 0.5 });
|
||||||
|
expect(dispatch).toHaveBeenNthCalledWith(3, { type: 'SET_VIDEO_PLAYBACK_SPEED', playbackSpeed: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { BrowserCache } from '../../../src/static/js/utils/classes/BrowserCache';
|
||||||
|
|
||||||
|
// Mocks for helpers used by BrowserCache
|
||||||
|
jest.mock('../../../src/static/js/utils/helpers/', () => ({
|
||||||
|
logErrorAndReturnError: jest.fn((args: any[]) => ({ error: true, args })),
|
||||||
|
logWarningAndReturnError: jest.fn((args: any[]) => ({ warning: true, args })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { logErrorAndReturnError } = jest.requireMock('../../../src/static/js/utils/helpers/');
|
||||||
|
|
||||||
|
describe('utils/classes', () => {
|
||||||
|
describe('BrowserCache', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns error when prefix is missing', () => {
|
||||||
|
const cache = BrowserCache(undefined, 3600);
|
||||||
|
expect(cache).toEqual(expect.objectContaining({ error: true }));
|
||||||
|
expect(logErrorAndReturnError).toHaveBeenCalledWith(['Cache object prefix is required']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Set and get returns stored primitive value before expiration', () => {
|
||||||
|
const cache = BrowserCache('prefix', 3600);
|
||||||
|
|
||||||
|
if (cache instanceof Error) {
|
||||||
|
expect(cache instanceof Error).toBe(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(cache.set('foo', 'bar')).toBe(true);
|
||||||
|
expect(cache.get('foo')).toBe('bar');
|
||||||
|
|
||||||
|
// Ensure value serialized in localStorage with namespaced key
|
||||||
|
const raw = localStorage.getItem('prefix[foo]') as string;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
expect(parsed.value).toBe('bar');
|
||||||
|
expect(typeof parsed.expire).toBe('number');
|
||||||
|
expect(parsed.expire).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Get returns null when expired', () => {
|
||||||
|
const cache = BrowserCache('prefix', 1);
|
||||||
|
|
||||||
|
if (cache instanceof Error) {
|
||||||
|
expect(cache instanceof Error).toBe(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set('exp', { a: 1 });
|
||||||
|
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.advanceTimersByTime(1_000);
|
||||||
|
|
||||||
|
expect(cache.get('exp')).toBeNull();
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Clear removes only keys for its prefix', () => {
|
||||||
|
const cacheA = BrowserCache('A', 3600);
|
||||||
|
const cacheB = BrowserCache('B', 3600);
|
||||||
|
|
||||||
|
if (cacheA instanceof Error) {
|
||||||
|
expect(cacheA instanceof Error).toBe(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cacheB instanceof Error) {
|
||||||
|
expect(cacheB instanceof Error).toBe(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheA.set('x', 1);
|
||||||
|
cacheB.set('x', 2);
|
||||||
|
|
||||||
|
expect(localStorage.getItem('A[x]')).toBeTruthy();
|
||||||
|
expect(localStorage.getItem('B[x]')).toBeTruthy();
|
||||||
|
|
||||||
|
cacheA.clear();
|
||||||
|
|
||||||
|
expect(localStorage.getItem('A[x]')).toBeNull();
|
||||||
|
expect(localStorage.getItem('B[x]')).toBeTruthy();
|
||||||
|
|
||||||
|
cacheB.clear();
|
||||||
|
|
||||||
|
expect(localStorage.getItem('A[x]')).toBeNull();
|
||||||
|
expect(localStorage.getItem('B[x]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { MediaDurationInfo } from '../../../src/static/js/utils/classes/MediaDurationInfo';
|
||||||
|
|
||||||
|
describe('utils/classes', () => {
|
||||||
|
describe('MediaDurationInfo', () => {
|
||||||
|
test('Initializes via constructor when seconds is a positive integer (<= 59)', () => {
|
||||||
|
const mdi = new MediaDurationInfo(42);
|
||||||
|
expect(mdi.toString()).toBe('0:42');
|
||||||
|
expect(mdi.ariaLabel()).toBe('42 seconds');
|
||||||
|
expect(mdi.ISO8601()).toBe('P0Y0M0DT0H0M42S');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Formats minutes and zero-pads seconds; no hours prefix under 60 minutes', () => {
|
||||||
|
const mdi = new MediaDurationInfo();
|
||||||
|
mdi.update(5 * 60 + 7);
|
||||||
|
expect(mdi.toString()).toBe('5:07');
|
||||||
|
expect(mdi.ariaLabel()).toBe('5 minutes, 7 seconds');
|
||||||
|
expect(mdi.ISO8601()).toBe('P0Y0M0DT0H5M7S');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Includes hours when duration >= 1 hour and zero-pads minutes when needed', () => {
|
||||||
|
const mdi = new MediaDurationInfo();
|
||||||
|
mdi.update(1 * 3600 + 2 * 60 + 3);
|
||||||
|
expect(mdi.toString()).toBe('1:02:03');
|
||||||
|
expect(mdi.ariaLabel()).toBe('1 hours, 2 minutes, 3 seconds');
|
||||||
|
expect(mdi.ISO8601()).toBe('P0Y0M0DT1H2M3S');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Accumulates hours when days are present (e.g., 1 day + 2:03:04 => 26:03:04)', () => {
|
||||||
|
const mdi = new MediaDurationInfo();
|
||||||
|
const seconds = 1 * 86400 + 2 * 3600 + 3 * 60 + 4; // 1d 2:03:04 => 26:03:04
|
||||||
|
mdi.update(seconds);
|
||||||
|
expect(mdi.toString()).toBe('26:03:04');
|
||||||
|
expect(mdi.ariaLabel()).toBe('26 hours, 3 minutes, 4 seconds');
|
||||||
|
expect(mdi.ISO8601()).toBe('P0Y0M0DT26H3M4S');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Large durations: multiple days correctly mapped into hours', () => {
|
||||||
|
const mdi = new MediaDurationInfo();
|
||||||
|
const seconds = 3 * 86400 + 10 * 3600 + 15 * 60 + 9; // 3d 10:15:09 => 82:15:09
|
||||||
|
mdi.update(seconds);
|
||||||
|
expect(mdi.toString()).toBe('82:15:09');
|
||||||
|
expect(mdi.ariaLabel()).toBe('82 hours, 15 minutes, 9 seconds');
|
||||||
|
expect(mdi.ISO8601()).toBe('P0Y0M0DT82H15M9S');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Caching: toString and ariaLabel recompute only after update()', () => {
|
||||||
|
const mdi = new MediaDurationInfo(59);
|
||||||
|
const firstToString = mdi.toString();
|
||||||
|
const firstAria = mdi.ariaLabel();
|
||||||
|
expect(firstToString).toBe('0:59');
|
||||||
|
expect(firstAria).toBe('59 seconds');
|
||||||
|
|
||||||
|
// Call again to hit cached path
|
||||||
|
expect(mdi.toString()).toBe(firstToString);
|
||||||
|
expect(mdi.ariaLabel()).toBe(firstAria);
|
||||||
|
|
||||||
|
// Update and ensure cache invalidates
|
||||||
|
mdi.update(60);
|
||||||
|
expect(mdi.toString()).toBe('1:00');
|
||||||
|
expect(mdi.ariaLabel()).toBe('1 minutes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ignores invalid (non-positive integer or zero) updates, retaining previous value', () => {
|
||||||
|
const mdi = new MediaDurationInfo(10);
|
||||||
|
expect(mdi.toString()).toBe('0:10');
|
||||||
|
|
||||||
|
mdi.update(1.23);
|
||||||
|
expect(mdi.toString()).toBe('0:10');
|
||||||
|
|
||||||
|
mdi.update(-5);
|
||||||
|
expect(mdi.toString()).toBe('0:10');
|
||||||
|
|
||||||
|
mdi.update('x');
|
||||||
|
expect(mdi.toString()).toBe('0:10');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Boundary conditions around a minute and an hour', () => {
|
||||||
|
const mdi = new MediaDurationInfo();
|
||||||
|
|
||||||
|
mdi.update(59);
|
||||||
|
expect(mdi.toString()).toBe('0:59');
|
||||||
|
|
||||||
|
mdi.update(60);
|
||||||
|
expect(mdi.toString()).toBe('1:00');
|
||||||
|
|
||||||
|
mdi.update(3599);
|
||||||
|
expect(mdi.toString()).toBe('59:59');
|
||||||
|
|
||||||
|
mdi.update(3600);
|
||||||
|
expect(mdi.toString()).toBe('1:00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
// @todo: Revisit this behavior
|
||||||
|
test('Constructs without initial seconds', () => {
|
||||||
|
const mdi = new MediaDurationInfo();
|
||||||
|
expect(typeof mdi.toString()).toBe('function');
|
||||||
|
expect(mdi.ariaLabel()).toBe('');
|
||||||
|
expect(mdi.ISO8601()).toBe('P0Y0M0DTundefinedHundefinedMundefinedS');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { UpNextLoaderView } from '../../../src/static/js/utils/classes/UpNextLoaderView';
|
||||||
|
|
||||||
|
// Minimal helpers mocks used by UpNextLoaderView
|
||||||
|
jest.mock('../../../src/static/js/utils/helpers/', () => ({
|
||||||
|
addClassname: jest.fn((el: any, cn: string) => el && el.classList && el.classList.add(cn)),
|
||||||
|
removeClassname: jest.fn((el: any, cn: string) => el && el.classList && el.classList.remove(cn)),
|
||||||
|
translateString: (s: string) => s,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { addClassname, removeClassname } = jest.requireMock('../../../src/static/js/utils/helpers/');
|
||||||
|
|
||||||
|
const makeNextItem = () => ({
|
||||||
|
url: '/next-url',
|
||||||
|
title: 'Next title',
|
||||||
|
author_name: 'Jane Doe',
|
||||||
|
thumbnail_url: 'https://example.com/thumb.jpg',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('utils/classes', () => {
|
||||||
|
describe('UpNextLoaderView', () => {
|
||||||
|
test('html() builds structure with expected classes and content', () => {
|
||||||
|
const v = new UpNextLoaderView(makeNextItem());
|
||||||
|
|
||||||
|
const root = v.html();
|
||||||
|
|
||||||
|
expect(root).toBeInstanceOf(HTMLElement);
|
||||||
|
expect(root.querySelector('.up-next-loader-inner')).not.toBeNull();
|
||||||
|
expect(root.querySelector('.up-next-label')!.textContent).toBe('Up Next');
|
||||||
|
expect(root.querySelector('.next-media-title')!.textContent).toBe('Next title');
|
||||||
|
expect(root.querySelector('.next-media-author')!.textContent).toBe('Jane Doe');
|
||||||
|
|
||||||
|
// poster background
|
||||||
|
const poster = root.querySelector('.next-media-poster') as HTMLElement;
|
||||||
|
expect(poster.style.backgroundImage).toContain('thumb.jpg');
|
||||||
|
|
||||||
|
// go-next link points to next url
|
||||||
|
const link = root.querySelector('.go-next a') as HTMLAnchorElement;
|
||||||
|
expect(link.getAttribute('href')).toBe('/next-url');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setVideoJsPlayerElem marks player with vjs-mediacms-has-up-next-view class', () => {
|
||||||
|
const v = new UpNextLoaderView(makeNextItem());
|
||||||
|
const player = document.createElement('div');
|
||||||
|
|
||||||
|
v.setVideoJsPlayerElem(player);
|
||||||
|
|
||||||
|
expect(addClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-has-up-next-view');
|
||||||
|
expect(v.vjsPlayerElem).toBe(player);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startTimer shows view, registers scroll, and navigates after 10s', () => {
|
||||||
|
const next = makeNextItem();
|
||||||
|
const v = new UpNextLoaderView(next);
|
||||||
|
const player = document.createElement('div');
|
||||||
|
|
||||||
|
v.setVideoJsPlayerElem(player);
|
||||||
|
v.startTimer();
|
||||||
|
|
||||||
|
expect(removeClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-up-next-hidden');
|
||||||
|
expect(removeClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-canceled-next');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelTimer clears timeout, stops scroll, and marks canceled', () => {
|
||||||
|
const v = new UpNextLoaderView(makeNextItem());
|
||||||
|
const player = document.createElement('div');
|
||||||
|
|
||||||
|
v.setVideoJsPlayerElem(player);
|
||||||
|
|
||||||
|
v.startTimer();
|
||||||
|
v.cancelTimer();
|
||||||
|
|
||||||
|
expect(addClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-canceled-next');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Cancel button click hides the view and cancels timer', () => {
|
||||||
|
const v = new UpNextLoaderView(makeNextItem());
|
||||||
|
const player = document.createElement('div');
|
||||||
|
v.setVideoJsPlayerElem(player);
|
||||||
|
|
||||||
|
v.startTimer();
|
||||||
|
const root = v.html();
|
||||||
|
const cancelBtn = root.querySelector('.up-next-cancel button') as HTMLButtonElement;
|
||||||
|
cancelBtn.click();
|
||||||
|
|
||||||
|
expect(addClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-canceled-next');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('showTimerView shows or starts timer based on flag', () => {
|
||||||
|
const v = new UpNextLoaderView(makeNextItem());
|
||||||
|
const player = document.createElement('div');
|
||||||
|
v.setVideoJsPlayerElem(player);
|
||||||
|
|
||||||
|
// beginTimer=false -> just show view
|
||||||
|
v.showTimerView(false);
|
||||||
|
expect(removeClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-up-next-hidden');
|
||||||
|
|
||||||
|
// beginTimer=true -> starts timer
|
||||||
|
v.showTimerView(true);
|
||||||
|
expect(removeClassname).toHaveBeenCalledWith(player, 'vjs-mediacms-canceled-next');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,749 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, fireEvent, act } from '@testing-library/react';
|
||||||
|
import { useBulkActions } from '../../../src/static/js/utils/hooks/useBulkActions';
|
||||||
|
|
||||||
|
// Mock translateString to return the input for easier assertions
|
||||||
|
jest.mock('../../../src/static/js/utils/helpers', () => ({
|
||||||
|
translateString: (s: string) => s,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Component that exposes hook state/handlers to DOM for testing
|
||||||
|
function HookConsumer() {
|
||||||
|
const hook = useBulkActions();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div data-testid="selected-count">{Array.from(hook.selectedMedia).length}</div>
|
||||||
|
<div data-testid="available-count">{hook.availableMediaIds.length}</div>
|
||||||
|
<div data-testid="show-confirm">{String(hook.showConfirmModal)}</div>
|
||||||
|
<div data-testid="confirm-message">{hook.confirmMessage}</div>
|
||||||
|
<div data-testid="list-key">{hook.listKey}</div>
|
||||||
|
<div data-testid="notification-message">{hook.notificationMessage}</div>
|
||||||
|
<div data-testid="show-notification">{String(hook.showNotification)}</div>
|
||||||
|
|
||||||
|
{/* @todo: It doesn't used */}
|
||||||
|
{/* <div data-testid="notification-type">{hook.notificationType}</div> */}
|
||||||
|
|
||||||
|
<div data-testid="show-permission">{String(hook.showPermissionModal)}</div>
|
||||||
|
<div data-testid="permission-type">{hook.permissionType || ''}</div>
|
||||||
|
<div data-testid="show-playlist">{String(hook.showPlaylistModal)}</div>
|
||||||
|
<div data-testid="show-change-owner">{String(hook.showChangeOwnerModal)}</div>
|
||||||
|
<div data-testid="show-publish-state">{String(hook.showPublishStateModal)}</div>
|
||||||
|
<div data-testid="show-category">{String(hook.showCategoryModal)}</div>
|
||||||
|
<div data-testid="show-tag">{String(hook.showTagModal)}</div>
|
||||||
|
|
||||||
|
<button data-testid="btn-handle-media-select" onClick={() => hook.handleMediaSelection('m1', true)} />
|
||||||
|
<button data-testid="btn-handle-media-deselect" onClick={() => hook.handleMediaSelection('m1', false)} />
|
||||||
|
<button
|
||||||
|
data-testid="btn-handle-items-update"
|
||||||
|
onClick={() => hook.handleItemsUpdate([{ id: 'a' }, { uid: 'b' }, { friendly_token: 'c' }])}
|
||||||
|
/>
|
||||||
|
<button data-testid="btn-select-all" onClick={() => hook.handleSelectAll()} />
|
||||||
|
<button data-testid="btn-deselect-all" onClick={() => hook.handleDeselectAll()} />
|
||||||
|
<button data-testid="btn-clear-selection" onClick={() => hook.clearSelection()} />
|
||||||
|
<button data-testid="btn-clear-refresh" onClick={() => hook.clearSelectionAndRefresh()} />
|
||||||
|
|
||||||
|
<button data-testid="btn-bulk-delete" onClick={() => hook.handleBulkAction('delete-media')} />
|
||||||
|
<button data-testid="btn-bulk-enable-comments" onClick={() => hook.handleBulkAction('enable-comments')} />
|
||||||
|
<button data-testid="btn-bulk-disable-comments" onClick={() => hook.handleBulkAction('disable-comments')} />
|
||||||
|
<button data-testid="btn-bulk-enable-download" onClick={() => hook.handleBulkAction('enable-download')} />
|
||||||
|
<button data-testid="btn-bulk-disable-download" onClick={() => hook.handleBulkAction('disable-download')} />
|
||||||
|
<button data-testid="btn-bulk-copy" onClick={() => hook.handleBulkAction('copy-media')} />
|
||||||
|
<button data-testid="btn-bulk-perm-viewer" onClick={() => hook.handleBulkAction('add-remove-coviewers')} />
|
||||||
|
<button data-testid="btn-bulk-perm-editor" onClick={() => hook.handleBulkAction('add-remove-coeditors')} />
|
||||||
|
<button data-testid="btn-bulk-perm-owner" onClick={() => hook.handleBulkAction('add-remove-coowners')} />
|
||||||
|
<button data-testid="btn-bulk-playlist" onClick={() => hook.handleBulkAction('add-remove-playlist')} />
|
||||||
|
<button data-testid="btn-bulk-change-owner" onClick={() => hook.handleBulkAction('change-owner')} />
|
||||||
|
<button data-testid="btn-bulk-publish" onClick={() => hook.handleBulkAction('publish-state')} />
|
||||||
|
<button data-testid="btn-bulk-category" onClick={() => hook.handleBulkAction('add-remove-category')} />
|
||||||
|
<button data-testid="btn-bulk-tag" onClick={() => hook.handleBulkAction('add-remove-tags')} />
|
||||||
|
<button data-testid="btn-bulk-unknown" onClick={() => hook.handleBulkAction('unknown-action')} />
|
||||||
|
|
||||||
|
<button data-testid="btn-confirm-proceed" onClick={() => hook.handleConfirmProceed()} />
|
||||||
|
<button data-testid="btn-confirm-cancel" onClick={() => hook.handleConfirmCancel()} />
|
||||||
|
<button data-testid="btn-perm-cancel" onClick={() => hook.handlePermissionModalCancel()} />
|
||||||
|
|
||||||
|
<button data-testid="btn-perm-success" onClick={() => hook.handlePermissionModalSuccess('perm ok')} />
|
||||||
|
<button data-testid="btn-perm-error" onClick={() => hook.handlePermissionModalError('perm err')} />
|
||||||
|
<button data-testid="btn-playlist-cancel" onClick={() => hook.handlePlaylistModalCancel()} />
|
||||||
|
|
||||||
|
<button data-testid="btn-playlist-success" onClick={() => hook.handlePlaylistModalSuccess('pl ok')} />
|
||||||
|
<button data-testid="btn-playlist-error" onClick={() => hook.handlePlaylistModalError('pl err')} />
|
||||||
|
<button data-testid="btn-change-owner-cancel" onClick={() => hook.handleChangeOwnerModalCancel()} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
data-testid="btn-change-owner-success"
|
||||||
|
onClick={() => hook.handleChangeOwnerModalSuccess('owner ok')}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
data-testid="btn-change-owner-error"
|
||||||
|
onClick={() => hook.handleChangeOwnerModalError('owner err')}
|
||||||
|
/>
|
||||||
|
<button data-testid="btn-publish-cancel" onClick={() => hook.handlePublishStateModalCancel()} />
|
||||||
|
|
||||||
|
<button data-testid="btn-publish-success" onClick={() => hook.handlePublishStateModalSuccess('pub ok')} />
|
||||||
|
<button data-testid="btn-publish-error" onClick={() => hook.handlePublishStateModalError('pub err')} />
|
||||||
|
<button data-testid="btn-category-cancel" onClick={() => hook.handleCategoryModalCancel()} />
|
||||||
|
|
||||||
|
<button data-testid="btn-category-success" onClick={() => hook.handleCategoryModalSuccess('cat ok')} />
|
||||||
|
<button data-testid="btn-category-error" onClick={() => hook.handleCategoryModalError('cat err')} />
|
||||||
|
<button data-testid="btn-tag-cancel" onClick={() => hook.handleTagModalCancel()} />
|
||||||
|
|
||||||
|
<button data-testid="btn-tag-success" onClick={() => hook.handleTagModalSuccess('tag ok')} />
|
||||||
|
<button data-testid="btn-tag-error" onClick={() => hook.handleTagModalError('tag err')} />
|
||||||
|
|
||||||
|
<div data-testid="csrf">{String(hook.getCsrfToken())}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useBulkActions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
document.cookie.split(';').forEach((c) => {
|
||||||
|
document.cookie = c.replace(/^ +/, '').replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/');
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch = jest.fn();
|
||||||
|
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Utility Functions', () => {
|
||||||
|
test('getCsrfToken reads csrftoken from cookies', () => {
|
||||||
|
document.cookie = 'csrftoken=abc123';
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
expect(getByTestId('csrf').textContent).toBe('abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getCsrfToken returns null when csrftoken is not present', () => {
|
||||||
|
// No cookie set, should return null
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
expect(getByTestId('csrf').textContent).toBe('null');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getCsrfToken returns null when document.cookie is empty', () => {
|
||||||
|
// Even if we try to set empty cookie, it should return null if no csrftoken
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
expect(getByTestId('csrf').textContent).toBe('null');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Selection Management', () => {
|
||||||
|
test('handleMediaSelection toggles selected media', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
expect(getByTestId('selected-count').textContent).toBe('1');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-deselect'));
|
||||||
|
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleItemsUpdate extracts ids correctly from items with different id types', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-items-update'));
|
||||||
|
expect(getByTestId('available-count').textContent).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleSelectAll selects all available items', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-items-update'));
|
||||||
|
fireEvent.click(getByTestId('btn-select-all'));
|
||||||
|
expect(getByTestId('selected-count').textContent).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleDeselectAll deselects all items', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-items-update'));
|
||||||
|
fireEvent.click(getByTestId('btn-select-all'));
|
||||||
|
fireEvent.click(getByTestId('btn-deselect-all'));
|
||||||
|
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearSelection clears all selected media', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
expect(getByTestId('selected-count').textContent).toBe('1');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-clear-selection'));
|
||||||
|
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearSelectionAndRefresh clears selection and increments listKey', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-items-update'));
|
||||||
|
fireEvent.click(getByTestId('btn-select-all'));
|
||||||
|
expect(getByTestId('list-key').textContent).toBe('0');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-clear-refresh'));
|
||||||
|
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||||
|
expect(getByTestId('list-key').textContent).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Bulk Actions - Modal Opening', () => {
|
||||||
|
test('handleBulkAction does nothing when no selection', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-delete'));
|
||||||
|
expect(getByTestId('show-confirm').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleBulkAction opens confirm modal for delete, enable/disable comments and download, copy', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-delete'));
|
||||||
|
expect(getByTestId('show-confirm').textContent).toBe('true');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-enable-comments'));
|
||||||
|
expect(getByTestId('show-confirm').textContent).toBe('true');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-disable-comments'));
|
||||||
|
expect(getByTestId('show-confirm').textContent).toBe('true');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-enable-download'));
|
||||||
|
expect(getByTestId('show-confirm').textContent).toBe('true');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-disable-download'));
|
||||||
|
expect(getByTestId('show-confirm').textContent).toBe('true');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-copy'));
|
||||||
|
expect(getByTestId('show-confirm').textContent).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleBulkAction opens permission modals with correct types', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-perm-viewer'));
|
||||||
|
expect(getByTestId('show-permission').textContent).toBe('true');
|
||||||
|
expect(getByTestId('permission-type').textContent).toBe('viewer');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-perm-editor'));
|
||||||
|
expect(getByTestId('permission-type').textContent).toBe('editor');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-perm-owner'));
|
||||||
|
expect(getByTestId('permission-type').textContent).toBe('owner');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleBulkAction opens other modals', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-playlist'));
|
||||||
|
expect(getByTestId('show-playlist').textContent).toBe('true');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-change-owner'));
|
||||||
|
expect(getByTestId('show-change-owner').textContent).toBe('true');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-publish'));
|
||||||
|
expect(getByTestId('show-publish-state').textContent).toBe('true');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-category'));
|
||||||
|
expect(getByTestId('show-category').textContent).toBe('true');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-tag'));
|
||||||
|
expect(getByTestId('show-tag').textContent).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleBulkAction with unknown action does nothing', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-unknown'));
|
||||||
|
expect(getByTestId('show-confirm').textContent).toBe('false');
|
||||||
|
expect(getByTestId('show-permission').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Confirm Modal Handlers', () => {
|
||||||
|
test('handleConfirmCancel closes confirm modal and resets state', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-delete'));
|
||||||
|
expect(getByTestId('show-confirm').textContent).toBe('true');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-confirm-cancel'));
|
||||||
|
expect(getByTestId('show-confirm').textContent).toBe('false');
|
||||||
|
expect(getByTestId('confirm-message').textContent).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Delete Media Execution', () => {
|
||||||
|
test('executeDeleteMedia success with notification', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-delete'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('notification-message').textContent).toContain('The media was deleted successfully');
|
||||||
|
expect(getByTestId('show-notification').textContent).toBe('true');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(5000);
|
||||||
|
});
|
||||||
|
expect(getByTestId('show-notification').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executeDeleteMedia handles response.ok = false', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-delete'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('notification-message').textContent).toContain('Failed to delete media');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executeDeleteMedia handles fetch rejection exception', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-delete'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('notification-message').textContent).toContain('Failed to delete media');
|
||||||
|
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Comments Management Execution', () => {
|
||||||
|
test('executeEnableComments success', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-enable-comments'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('notification-message').textContent).toContain('Successfully Enabled comments');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executeEnableComments handles response.ok = false', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-enable-comments'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('notification-message').textContent).toContain('Failed to enable comments');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executeEnableComments handles fetch rejection exception', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-enable-comments'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('notification-message').textContent).toContain('Failed to enable comments');
|
||||||
|
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executeDisableComments success', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-disable-comments'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('notification-message').textContent).toContain('Successfully Disabled comments');
|
||||||
|
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executeDisableComments handles response.ok = false', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-disable-comments'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('notification-message').textContent).toContain('Failed to disable comments');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executeDisableComments handles fetch rejection exception', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-disable-comments'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('notification-message').textContent).toContain('Failed to disable comments');
|
||||||
|
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Download Management Execution', () => {
|
||||||
|
test('executeEnableDownload success', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-enable-download'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('notification-message').textContent).toContain('Successfully Enabled Download');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executeEnableDownload handles response.ok = false', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-enable-download'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('notification-message').textContent).toContain('Failed to enable download');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executeEnableDownload handles fetch rejection exception', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-enable-download'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('notification-message').textContent).toContain('Failed to enable download');
|
||||||
|
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executeDisableDownload success', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-disable-download'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('notification-message').textContent).toContain('Successfully Disabled Download');
|
||||||
|
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executeDisableDownload handles response.ok = false', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-disable-download'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('notification-message').textContent).toContain('Failed to disable download');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executeDisableDownload handles fetch rejection exception', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-disable-download'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('notification-message').textContent).toContain('Failed to disable download');
|
||||||
|
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Copy Media Execution', () => {
|
||||||
|
test('executeCopyMedia success', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-copy'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('notification-message').textContent).toContain('Successfully Copied');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executeCopyMedia handles response.ok = false', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-copy'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('notification-message').textContent).toContain('Failed to copy media');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executeCopyMedia handles fetch rejection exception', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-copy'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(getByTestId('btn-confirm-proceed'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('notification-message').textContent).toContain('Failed to copy media');
|
||||||
|
expect(getByTestId('selected-count').textContent).toBe('0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Permission Modal Handlers', () => {
|
||||||
|
test('handlePermissionModalCancel closes permission modal', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-perm-viewer'));
|
||||||
|
expect(getByTestId('show-permission').textContent).toBe('true');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-perm-cancel'));
|
||||||
|
expect(getByTestId('show-permission').textContent).toBe('false');
|
||||||
|
expect(getByTestId('permission-type').textContent).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handlePermissionModalSuccess shows notification and closes modal', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-perm-success'));
|
||||||
|
expect(getByTestId('notification-message').textContent).toBe('perm ok');
|
||||||
|
expect(getByTestId('show-permission').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handlePermissionModalError shows error notification and closes modal', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-perm-error'));
|
||||||
|
expect(getByTestId('notification-message').textContent).toBe('perm err');
|
||||||
|
expect(getByTestId('show-permission').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Playlist Modal Handlers', () => {
|
||||||
|
test('handlePlaylistModalCancel closes playlist modal', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-playlist'));
|
||||||
|
expect(getByTestId('show-playlist').textContent).toBe('true');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-playlist-cancel'));
|
||||||
|
expect(getByTestId('show-playlist').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handlePlaylistModalSuccess shows notification and closes modal', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-playlist-success'));
|
||||||
|
expect(getByTestId('notification-message').textContent).toBe('pl ok');
|
||||||
|
expect(getByTestId('show-playlist').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handlePlaylistModalError shows error notification and closes modal', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-playlist-error'));
|
||||||
|
expect(getByTestId('notification-message').textContent).toBe('pl err');
|
||||||
|
expect(getByTestId('show-playlist').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Change Owner Modal Handlers', () => {
|
||||||
|
test('handleChangeOwnerModalCancel closes change owner modal', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-change-owner'));
|
||||||
|
expect(getByTestId('show-change-owner').textContent).toBe('true');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-change-owner-cancel'));
|
||||||
|
expect(getByTestId('show-change-owner').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleChangeOwnerModalSuccess shows notification and closes modal', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-change-owner-success'));
|
||||||
|
expect(getByTestId('notification-message').textContent).toBe('owner ok');
|
||||||
|
expect(getByTestId('show-change-owner').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleChangeOwnerModalError shows error notification and closes modal', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-change-owner-error'));
|
||||||
|
expect(getByTestId('notification-message').textContent).toBe('owner err');
|
||||||
|
expect(getByTestId('show-change-owner').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Publish State Modal Handlers', () => {
|
||||||
|
test('handlePublishStateModalCancel closes publish state modal', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-publish'));
|
||||||
|
expect(getByTestId('show-publish-state').textContent).toBe('true');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-publish-cancel'));
|
||||||
|
expect(getByTestId('show-publish-state').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handlePublishStateModalSuccess shows notification and closes modal', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-publish-success'));
|
||||||
|
expect(getByTestId('notification-message').textContent).toBe('pub ok');
|
||||||
|
expect(getByTestId('show-publish-state').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handlePublishStateModalError shows error notification and closes modal', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-publish-error'));
|
||||||
|
expect(getByTestId('notification-message').textContent).toBe('pub err');
|
||||||
|
expect(getByTestId('show-publish-state').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Category Modal Handlers', () => {
|
||||||
|
test('handleCategoryModalCancel closes category modal', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-category'));
|
||||||
|
expect(getByTestId('show-category').textContent).toBe('true');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-category-cancel'));
|
||||||
|
expect(getByTestId('show-category').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleCategoryModalSuccess shows notification and closes modal', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-category-success'));
|
||||||
|
expect(getByTestId('notification-message').textContent).toBe('cat ok');
|
||||||
|
expect(getByTestId('show-category').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleCategoryModalError shows error notification and closes modal', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-category-error'));
|
||||||
|
expect(getByTestId('notification-message').textContent).toBe('cat err');
|
||||||
|
expect(getByTestId('show-category').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Tag Modal Handlers', () => {
|
||||||
|
test('handleTagModalCancel closes tag modal', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-handle-media-select'));
|
||||||
|
fireEvent.click(getByTestId('btn-bulk-tag'));
|
||||||
|
expect(getByTestId('show-tag').textContent).toBe('true');
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-tag-cancel'));
|
||||||
|
expect(getByTestId('show-tag').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleTagModalSuccess shows notification and closes modal', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-tag-success'));
|
||||||
|
expect(getByTestId('notification-message').textContent).toBe('tag ok');
|
||||||
|
expect(getByTestId('show-tag').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleTagModalError shows error notification and closes modal', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('btn-tag-error'));
|
||||||
|
expect(getByTestId('notification-message').textContent).toBe('tag err');
|
||||||
|
expect(getByTestId('show-tag').textContent).toBe('false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { useItem } from '../../../src/static/js/utils/hooks/useItem';
|
||||||
|
|
||||||
|
// Mock the item components
|
||||||
|
jest.mock('../../../src/static/js/components/list-item/includes/items', () => ({
|
||||||
|
ItemDescription: ({ description }: { description: string }) => (
|
||||||
|
<div data-testid="item-description">{description}</div>
|
||||||
|
),
|
||||||
|
ItemMain: ({ children }: { children: React.ReactNode }) => <div data-testid="item-main">{children}</div>,
|
||||||
|
ItemMainInLink: ({ children, link, title }: { children: React.ReactNode; link: string; title: string }) => (
|
||||||
|
<div data-testid="item-main-in-link" data-link={link} data-title={title}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
ItemTitle: ({ title, ariaLabel }: { title: string; ariaLabel: string }) => (
|
||||||
|
<h3 data-testid="item-title" data-aria-label={ariaLabel}>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
),
|
||||||
|
ItemTitleLink: ({ title, link, ariaLabel }: { title: string; link: string; ariaLabel: string }) => (
|
||||||
|
<h3 data-testid="item-title-link" data-link={link} data-aria-label={ariaLabel}>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock PageStore
|
||||||
|
jest.mock('../../../src/static/js/utils/stores/PageStore.js', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
get: (key: string) => (key === 'config-site' ? { url: 'https://example.com' } : null),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// HookConsumer component to test the hook
|
||||||
|
function HookConsumer(props: any) {
|
||||||
|
const { titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper } = useItem(props);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div data-testid="title">{titleComponent()}</div>
|
||||||
|
<div data-testid="description">{descriptionComponent()}</div>
|
||||||
|
<div data-testid="thumbnail-url">{thumbnailUrl || 'null'}</div>
|
||||||
|
<div data-testid="wrapper-type">{(UnderThumbWrapper as any).name}</div>
|
||||||
|
<div data-testid="wrapper-component">
|
||||||
|
<div>Wrapper content</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapper consumer to test wrapper selection
|
||||||
|
function WrapperTest(props: any) {
|
||||||
|
const { UnderThumbWrapper } = useItem(props);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnderThumbWrapper link={props.link} title={props.title} data-testid="wrapper-test">
|
||||||
|
<span data-testid="wrapper-content">Content</span>
|
||||||
|
</UnderThumbWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('utils/hooks', () => {
|
||||||
|
describe('useItem', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('titleComponent Rendering', () => {
|
||||||
|
test('Renders ItemTitle when singleLinkContent is true', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<HookConsumer
|
||||||
|
title="Test Title"
|
||||||
|
description="Test Description"
|
||||||
|
link="https://example.com"
|
||||||
|
thumbnail=""
|
||||||
|
singleLinkContent={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId('title').querySelector('[data-testid="item-title"]')).toBeTruthy();
|
||||||
|
expect(getByTestId('title').querySelector('[data-testid="item-title-link"]')).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Renders ItemTitleLink when singleLinkContent is false', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<HookConsumer
|
||||||
|
title="Test Title"
|
||||||
|
description="Test Description"
|
||||||
|
link="https://example.com"
|
||||||
|
thumbnail=""
|
||||||
|
singleLinkContent={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId('title').querySelector('[data-testid="item-title"]')).toBeFalsy();
|
||||||
|
expect(getByTestId('title').querySelector('[data-testid="item-title-link"]')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Renders with default link when singleLinkContent is not provided', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<HookConsumer title="Test Title" description="Test Description" link="/media/test" thumbnail="" />
|
||||||
|
);
|
||||||
|
|
||||||
|
// Default is false for singleLinkContent
|
||||||
|
expect(getByTestId('title').querySelector('[data-testid="item-title-link"]')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('descriptionComponent Rendering', () => {
|
||||||
|
test('Renders single ItemDescription when hasMediaViewer is false', () => {
|
||||||
|
const { getByTestId, queryAllByTestId } = render(
|
||||||
|
<HookConsumer
|
||||||
|
title="Test Title"
|
||||||
|
description="My Description"
|
||||||
|
link="https://example.com"
|
||||||
|
thumbnail=""
|
||||||
|
hasMediaViewer={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const descriptions = queryAllByTestId('item-description');
|
||||||
|
expect(descriptions.length).toBe(1);
|
||||||
|
expect(descriptions[0].textContent).toBe('My Description');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Renders single ItemDescription when hasMediaViewerDescr is false', () => {
|
||||||
|
const { getByTestId, queryAllByTestId } = render(
|
||||||
|
<HookConsumer
|
||||||
|
title="Test Title"
|
||||||
|
description="My Description"
|
||||||
|
link="https://example.com"
|
||||||
|
thumbnail=""
|
||||||
|
hasMediaViewer={true}
|
||||||
|
hasMediaViewerDescr={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const descriptions = queryAllByTestId('item-description');
|
||||||
|
expect(descriptions.length).toBe(1);
|
||||||
|
expect(descriptions[0].textContent).toBe('My Description');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Renders two ItemDescriptions when hasMediaViewer and hasMediaViewerDescr are both true', () => {
|
||||||
|
const { queryAllByTestId } = render(
|
||||||
|
<HookConsumer
|
||||||
|
title="Test Title"
|
||||||
|
description="Main Description"
|
||||||
|
link="https://example.com"
|
||||||
|
thumbnail=""
|
||||||
|
hasMediaViewer={true}
|
||||||
|
hasMediaViewerDescr={true}
|
||||||
|
meta_description="Meta Description"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const descriptions = queryAllByTestId('item-description');
|
||||||
|
expect(descriptions.length).toBe(2);
|
||||||
|
expect(descriptions[0].textContent).toBe('Meta Description');
|
||||||
|
expect(descriptions[1].textContent).toBe('Main Description');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Trims description text', () => {
|
||||||
|
const { queryAllByTestId } = render(
|
||||||
|
<HookConsumer
|
||||||
|
title="Test Title"
|
||||||
|
description=" Description with spaces "
|
||||||
|
link="https://example.com"
|
||||||
|
thumbnail=""
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(queryAllByTestId('item-description')[0].textContent).toBe('Description with spaces');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Trims meta_description text', () => {
|
||||||
|
const { queryAllByTestId } = render(
|
||||||
|
<HookConsumer
|
||||||
|
title="Test Title"
|
||||||
|
description="Main Description"
|
||||||
|
link="https://example.com"
|
||||||
|
thumbnail=""
|
||||||
|
hasMediaViewer={true}
|
||||||
|
hasMediaViewerDescr={true}
|
||||||
|
meta_description=" Meta with spaces "
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(queryAllByTestId('item-description')[0].textContent).toBe('Meta with spaces');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('thumbnailUrl', () => {
|
||||||
|
test('Returns null when thumbnail is empty string', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<HookConsumer
|
||||||
|
title="Test Title"
|
||||||
|
description="Test Description"
|
||||||
|
link="https://example.com"
|
||||||
|
thumbnail=""
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId('thumbnail-url').textContent).toBe('null');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns formatted URL when thumbnail has value', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<HookConsumer
|
||||||
|
title="Test Title"
|
||||||
|
description="Test Description"
|
||||||
|
link="https://example.com"
|
||||||
|
thumbnail="/media/thumbnail.jpg"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId('thumbnail-url').textContent).toBe('https://example.com/media/thumbnail.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Handles absolute URLs as thumbnails', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<HookConsumer
|
||||||
|
title="Test Title"
|
||||||
|
description="Test Description"
|
||||||
|
link="https://example.com"
|
||||||
|
thumbnail="https://cdn.example.com/image.jpg"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// formatInnerLink should preserve absolute URLs
|
||||||
|
expect(getByTestId('thumbnail-url').textContent).toBe('https://cdn.example.com/image.jpg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UnderThumbWrapper', () => {
|
||||||
|
test('Uses ItemMainInLink when singleLinkContent is true', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<WrapperTest
|
||||||
|
title="Test Title"
|
||||||
|
description="Test Description"
|
||||||
|
link="https://example.com"
|
||||||
|
thumbnail=""
|
||||||
|
singleLinkContent={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// When singleLinkContent is true, UnderThumbWrapper should be ItemMainInLink
|
||||||
|
expect(getByTestId('item-main-in-link')).toBeTruthy();
|
||||||
|
expect(getByTestId('item-main-in-link').getAttribute('data-link')).toBe('https://example.com');
|
||||||
|
expect(getByTestId('item-main-in-link').getAttribute('data-title')).toBe('Test Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Uses ItemMain when singleLinkContent is false', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<WrapperTest
|
||||||
|
title="Test Title"
|
||||||
|
description="Test Description"
|
||||||
|
link="https://example.com"
|
||||||
|
thumbnail=""
|
||||||
|
singleLinkContent={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// When singleLinkContent is false, UnderThumbWrapper should be ItemMain
|
||||||
|
expect(getByTestId('item-main')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Uses ItemMain by default when singleLinkContent is not provided', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<WrapperTest
|
||||||
|
title="Test Title"
|
||||||
|
description="Test Description"
|
||||||
|
link="https://example.com"
|
||||||
|
thumbnail=""
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Default is singleLinkContent=false, so ItemMain
|
||||||
|
expect(getByTestId('item-main')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onMount callback', () => {
|
||||||
|
test('Calls onMount callback when component mounts', () => {
|
||||||
|
const onMountCallback = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<HookConsumer
|
||||||
|
title="Test Title"
|
||||||
|
description="Test Description"
|
||||||
|
link="https://example.com"
|
||||||
|
thumbnail=""
|
||||||
|
onMount={onMountCallback}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onMountCallback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Calls onMount only once on initial mount', () => {
|
||||||
|
const onMountCallback = jest.fn();
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<HookConsumer
|
||||||
|
title="Test Title"
|
||||||
|
description="Test Description"
|
||||||
|
link="https://example.com"
|
||||||
|
thumbnail=""
|
||||||
|
onMount={onMountCallback}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onMountCallback).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<HookConsumer
|
||||||
|
title="Updated Title"
|
||||||
|
description="Updated Description"
|
||||||
|
link="https://example.com"
|
||||||
|
thumbnail=""
|
||||||
|
onMount={onMountCallback}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should still be called only once (useEffect with empty dependency array)
|
||||||
|
expect(onMountCallback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Integration tests', () => {
|
||||||
|
test('Complete rendering with all props', () => {
|
||||||
|
const onMount = jest.fn();
|
||||||
|
const { getByTestId, queryAllByTestId } = render(
|
||||||
|
<HookConsumer
|
||||||
|
title="Complete Test"
|
||||||
|
description="Complete Description"
|
||||||
|
link="/media/complete"
|
||||||
|
thumbnail="/img/thumb.jpg"
|
||||||
|
type="media"
|
||||||
|
hasMediaViewer={true}
|
||||||
|
hasMediaViewerDescr={true}
|
||||||
|
meta_description="Complete Meta"
|
||||||
|
singleLinkContent={false}
|
||||||
|
onMount={onMount}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const descriptions = queryAllByTestId('item-description');
|
||||||
|
expect(descriptions.length).toBe(2);
|
||||||
|
expect(onMount).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getByTestId('thumbnail-url').textContent).toBe('https://example.com/img/thumb.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Minimal props required', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<HookConsumer title="Title" description="Description" link="/link" thumbnail="" />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId('title')).toBeTruthy();
|
||||||
|
expect(getByTestId('description')).toBeTruthy();
|
||||||
|
expect(getByTestId('thumbnail-url').textContent).toBe('null');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Renders with special characters in title and description', () => {
|
||||||
|
const { queryAllByTestId } = render(
|
||||||
|
<HookConsumer
|
||||||
|
title="Title with & < > special chars"
|
||||||
|
description={`Description with 'quotes' and "double quotes"`}
|
||||||
|
link="/media"
|
||||||
|
thumbnail=""
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const descriptions = queryAllByTestId('item-description');
|
||||||
|
expect(descriptions[0].textContent).toContain('Description with');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import React, { createRef } from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
|
||||||
|
// Stub style imports used by the hook so Jest doesn't try to parse SCSS
|
||||||
|
jest.mock('../../../src/static/js/components/item-list/ItemList.scss', () => ({}), { virtual: true });
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/components/item-list/includes/itemLists/initItemsList', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn((_lists: any[]) => [{ appendItems: jest.fn() }]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import initItemsList from '../../../src/static/js/components/item-list/includes/itemLists/initItemsList';
|
||||||
|
import { useItemList } from '../../../src/static/js/utils/hooks/useItemList';
|
||||||
|
|
||||||
|
function HookConsumer(props: any) {
|
||||||
|
const listRef = createRef<HTMLDivElement>();
|
||||||
|
const [items, countedItems, listHandler, setListHandler, onItemsLoad, onItemsCount, addListItems] = useItemList(
|
||||||
|
props,
|
||||||
|
listRef
|
||||||
|
) as any[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div ref={listRef} data-testid="list" className="list">
|
||||||
|
{(items as any[]).map((_, idx) => (
|
||||||
|
<div key={idx} className="item" data-testid={`itm-${idx}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div data-testid="counted">{String(countedItems)}</div>
|
||||||
|
<div data-testid="len">{items.length}</div>
|
||||||
|
<button data-testid="load-call" onClick={() => onItemsLoad([1, 2])} />
|
||||||
|
<button data-testid="count-call" onClick={() => onItemsCount(5)} />
|
||||||
|
<button data-testid="add-call" onClick={() => addListItems()} />
|
||||||
|
<button data-testid="set-handler" onClick={() => setListHandler({ foo: 'bar' })} />
|
||||||
|
<div data-testid="has-handler">{listHandler ? 'yes' : 'no'}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('utils/hooks', () => {
|
||||||
|
describe('useItemList', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Initial state: empty items and not counted', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
expect(getByTestId('counted').textContent).toBe('false');
|
||||||
|
expect(getByTestId('len').textContent).toBe('0');
|
||||||
|
expect(getByTestId('has-handler').textContent).toBe('no');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onItemsLoad updates items and renders item nodes', () => {
|
||||||
|
const { getByTestId, getByTestId: $ } = render(<HookConsumer />);
|
||||||
|
(getByTestId('load-call') as HTMLButtonElement).click();
|
||||||
|
expect(getByTestId('len').textContent).toBe('2');
|
||||||
|
expect($('itm-0')).toBeTruthy();
|
||||||
|
expect($('itm-1')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onItemsCount marks countedItems true and triggers callback if provided', () => {
|
||||||
|
const cb = jest.fn();
|
||||||
|
const { getByTestId } = render(<HookConsumer itemsCountCallback={cb} />);
|
||||||
|
(getByTestId('count-call') as HTMLButtonElement).click();
|
||||||
|
expect(getByTestId('counted').textContent).toBe('true');
|
||||||
|
expect(cb).toHaveBeenCalledWith(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addListItems initializes itemsListInstance and appends only new items', () => {
|
||||||
|
const mockInit = initItemsList as jest.Mock;
|
||||||
|
|
||||||
|
const { getByTestId, rerender } = render(<HookConsumer />);
|
||||||
|
|
||||||
|
const itemsLen = getByTestId('len') as HTMLDivElement;
|
||||||
|
const addBtn = getByTestId('add-call') as HTMLButtonElement;
|
||||||
|
const loadBtn = getByTestId('load-call') as HTMLButtonElement;
|
||||||
|
|
||||||
|
expect(itemsLen.textContent).toBe('0');
|
||||||
|
loadBtn.click();
|
||||||
|
expect(itemsLen.textContent).toBe('2');
|
||||||
|
|
||||||
|
expect(mockInit).toHaveBeenCalledTimes(0);
|
||||||
|
addBtn.click();
|
||||||
|
expect(mockInit).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(mockInit.mock.results[0].value[0].appendItems).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
loadBtn.click();
|
||||||
|
expect(itemsLen.textContent).toBe('2');
|
||||||
|
|
||||||
|
addBtn.click();
|
||||||
|
expect(mockInit).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockInit.mock.results[1].value[0].appendItems).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
rerender(<HookConsumer />);
|
||||||
|
|
||||||
|
addBtn.click();
|
||||||
|
expect(mockInit).toHaveBeenCalledTimes(3);
|
||||||
|
expect(mockInit.mock.results[2].value[0].appendItems).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addListItems does nothing when there are no .item elements in the ref', () => {
|
||||||
|
// Render, do not call onItemsLoad, then call addListItems
|
||||||
|
const mockInit = initItemsList as jest.Mock;
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
(getByTestId('add-call') as HTMLButtonElement).click();
|
||||||
|
expect(mockInit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('itemsLoadCallback is invoked when items change', () => {
|
||||||
|
const itemsLoadCallback = jest.fn();
|
||||||
|
const { getByTestId } = render(<HookConsumer itemsLoadCallback={itemsLoadCallback} />);
|
||||||
|
(getByTestId('load-call') as HTMLButtonElement).click();
|
||||||
|
expect(itemsLoadCallback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setListHandler updates listHandler', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer />);
|
||||||
|
expect(getByTestId('has-handler').textContent).toBe('no');
|
||||||
|
(getByTestId('set-handler') as HTMLButtonElement).click();
|
||||||
|
expect(getByTestId('has-handler').textContent).toBe('yes');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, fireEvent, act } from '@testing-library/react';
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||||
|
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/classes/', () => ({
|
||||||
|
BrowserCache: jest.fn().mockImplementation(() => ({
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/helpers/', () => ({
|
||||||
|
addClassname: jest.fn(),
|
||||||
|
removeClassname: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mockListHandler: any;
|
||||||
|
let mockInlineSliderInstance: any;
|
||||||
|
let addListItemsSpy = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/hooks/useItemList', () => ({
|
||||||
|
useItemList: (props: any, _ref: any) => {
|
||||||
|
mockListHandler = {
|
||||||
|
loadItems: jest.fn(),
|
||||||
|
totalPages: jest.fn().mockReturnValue(props.__totalPages ?? 1),
|
||||||
|
loadedAllItems: jest.fn().mockReturnValue(Boolean(props.__loadedAll ?? true)),
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
props.__items ?? [], // items
|
||||||
|
props.__countedItems ?? 0, // countedItems
|
||||||
|
mockListHandler, // listHandler
|
||||||
|
jest.fn(), // setListHandler
|
||||||
|
jest.fn(), // onItemsLoad
|
||||||
|
jest.fn(), // onItemsCount
|
||||||
|
addListItemsSpy, // addListItems
|
||||||
|
];
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/components/item-list/includes/itemLists/ItemsInlineSlider', () =>
|
||||||
|
jest.fn().mockImplementation(() => {
|
||||||
|
mockInlineSliderInstance = {
|
||||||
|
updateDataStateOnResize: jest.fn(),
|
||||||
|
updateDataState: jest.fn(),
|
||||||
|
scrollToCurrentSlide: jest.fn(),
|
||||||
|
nextSlide: jest.fn(),
|
||||||
|
previousSlide: jest.fn(),
|
||||||
|
hasNextSlide: jest.fn().mockReturnValue(true),
|
||||||
|
hasPreviousSlide: jest.fn().mockReturnValue(true),
|
||||||
|
loadItemsToFit: jest.fn().mockReturnValue(false),
|
||||||
|
loadMoreItems: jest.fn().mockReturnValue(false),
|
||||||
|
itemsFit: jest.fn().mockReturnValue(3),
|
||||||
|
};
|
||||||
|
return mockInlineSliderInstance;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/components/_shared', () => ({
|
||||||
|
CircleIconButton: ({ children, onClick }: any) => (
|
||||||
|
<button data-testid="circle-icon-button" onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useItemListInlineSlider } from '../../../src/static/js/utils/hooks/useItemListInlineSlider';
|
||||||
|
|
||||||
|
function HookConsumer(props: any) {
|
||||||
|
const tuple = useItemListInlineSlider(props);
|
||||||
|
const [
|
||||||
|
_items,
|
||||||
|
_countedItems,
|
||||||
|
_listHandler,
|
||||||
|
classname,
|
||||||
|
_setListHandler,
|
||||||
|
_onItemsCount,
|
||||||
|
_onItemsLoad,
|
||||||
|
_winResizeListener,
|
||||||
|
_sidebarVisibilityChangeListener,
|
||||||
|
itemsListWrapperRef,
|
||||||
|
_itemsListRef,
|
||||||
|
renderBeforeListWrap,
|
||||||
|
renderAfterListWrap,
|
||||||
|
] = tuple as any;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={itemsListWrapperRef}>
|
||||||
|
<div data-testid="class-list">{classname.list}</div>
|
||||||
|
<div data-testid="class-outer">{classname.listOuter}</div>
|
||||||
|
<div data-testid="render-before">{renderBeforeListWrap()}</div>
|
||||||
|
<div data-testid="render-after">{renderAfterListWrap()}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('utils/hooks', () => {
|
||||||
|
describe('useItemListInlineSlider', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
addListItemsSpy = jest.fn();
|
||||||
|
mockInlineSliderInstance = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns correct tuple of values from hook', () => {
|
||||||
|
const TestComponent = (props: any) => {
|
||||||
|
const tuple = useItemListInlineSlider(props);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div data-testid="tuple-length">{tuple.length}</div>
|
||||||
|
<div data-testid="has-items">{tuple[0] ? 'yes' : 'no'}</div>
|
||||||
|
<div data-testid="has-classname">{tuple[3] ? 'yes' : 'no'}</div>
|
||||||
|
<div data-testid="has-listeners">{typeof tuple[7] === 'function' ? 'yes' : 'no'}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getByTestId } = render(<TestComponent __items={[1, 2, 3]} />);
|
||||||
|
|
||||||
|
expect(getByTestId('tuple-length').textContent).toBe('13');
|
||||||
|
expect(getByTestId('has-classname').textContent).toBe('yes');
|
||||||
|
expect(getByTestId('has-listeners').textContent).toBe('yes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Computes classname.list and classname.listOuter with optional className prop', () => {
|
||||||
|
const { getByTestId, rerender } = render(<HookConsumer className=" extra " />);
|
||||||
|
|
||||||
|
expect(getByTestId('class-outer').textContent).toBe('items-list-outer list-inline list-slider extra ');
|
||||||
|
expect(getByTestId('class-list').textContent).toBe('items-list');
|
||||||
|
|
||||||
|
rerender(<HookConsumer />);
|
||||||
|
|
||||||
|
expect(getByTestId('class-outer').textContent).toBe('items-list-outer list-inline list-slider');
|
||||||
|
expect(getByTestId('class-list').textContent).toBe('items-list');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Invokes addListItems when items change', () => {
|
||||||
|
const { rerender } = render(<HookConsumer __items={[]} />);
|
||||||
|
expect(addListItemsSpy).toHaveBeenCalledTimes(1);
|
||||||
|
rerender(<HookConsumer __items={[1]} />);
|
||||||
|
expect(addListItemsSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextSlide loads more items when loadMoreItems returns true and not all items loaded', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer __items={[1, 2, 3]} __loadedAll={false} />);
|
||||||
|
|
||||||
|
mockInlineSliderInstance.loadMoreItems.mockReturnValue(true);
|
||||||
|
|
||||||
|
const renderAfter = getByTestId('render-after');
|
||||||
|
const nextButton = renderAfter.querySelector('button[data-testid="circle-icon-button"]');
|
||||||
|
|
||||||
|
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fireEvent.click(nextButton!);
|
||||||
|
|
||||||
|
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextSlide does not load items when all items already loaded', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer __items={[1, 2, 3]} __loadedAll={true} />);
|
||||||
|
|
||||||
|
mockInlineSliderInstance.loadMoreItems.mockReturnValue(false);
|
||||||
|
|
||||||
|
const renderAfter = getByTestId('render-after');
|
||||||
|
const nextButton = renderAfter.querySelector('button[data-testid="circle-icon-button"]');
|
||||||
|
|
||||||
|
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fireEvent.click(nextButton!);
|
||||||
|
|
||||||
|
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prevSlide calls inlineSlider.previousSlide and updates button view', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer __items={[1, 2, 3]} __loadedAll={false} />);
|
||||||
|
|
||||||
|
mockInlineSliderInstance.loadMoreItems.mockReturnValue(true);
|
||||||
|
|
||||||
|
const renderBefore = getByTestId('render-before');
|
||||||
|
const prevButton = renderBefore.querySelector('button[data-testid="circle-icon-button"]');
|
||||||
|
|
||||||
|
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fireEvent.click(prevButton!);
|
||||||
|
|
||||||
|
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prevSlide always scrolls to current slide regardless of item load state', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer __items={[1, 2, 3]} __loadedAll={true} />);
|
||||||
|
|
||||||
|
mockInlineSliderInstance.loadMoreItems.mockReturnValue(false);
|
||||||
|
|
||||||
|
const renderBefore = getByTestId('render-before');
|
||||||
|
const prevButton = renderBefore.querySelector('button[data-testid="circle-icon-button"]');
|
||||||
|
|
||||||
|
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fireEvent.click(prevButton!);
|
||||||
|
|
||||||
|
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
|
||||||
|
expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Button state updates based on hasNextSlide and hasPreviousSlide', () => {
|
||||||
|
const { getByTestId, rerender } = render(<HookConsumer __items={[1, 2, 3]} />);
|
||||||
|
|
||||||
|
const renderBefore = getByTestId('render-before');
|
||||||
|
const renderAfter = getByTestId('render-after');
|
||||||
|
|
||||||
|
// Initially should show buttons (default mock returns true)
|
||||||
|
expect(renderBefore.querySelector('button')).toBeTruthy();
|
||||||
|
expect(renderAfter.querySelector('button')).toBeTruthy();
|
||||||
|
|
||||||
|
// Now set hasNextSlide and hasPreviousSlide to false
|
||||||
|
mockInlineSliderInstance.hasNextSlide.mockReturnValue(false);
|
||||||
|
mockInlineSliderInstance.hasPreviousSlide.mockReturnValue(false);
|
||||||
|
|
||||||
|
// Trigger re-render by changing items
|
||||||
|
rerender(<HookConsumer __items={[1, 2, 3, 4]} />);
|
||||||
|
|
||||||
|
// The next and previous buttons should not be rendered now
|
||||||
|
const newRenderAfter = getByTestId('render-after');
|
||||||
|
const newRenderBefore = getByTestId('render-before');
|
||||||
|
expect(newRenderAfter.querySelector('button')).toBeNull();
|
||||||
|
expect(newRenderBefore.querySelector('button')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('winResizeListener and sidebarVisibilityChangeListener are returned as callable functions', () => {
|
||||||
|
const TestComponentWithListeners = (props: any) => {
|
||||||
|
const tuple = useItemListInlineSlider(props);
|
||||||
|
|
||||||
|
const winResizeListener = tuple[7]; // winResizeListener
|
||||||
|
const sidebarListener = tuple[8]; // sidebarVisibilityChangeListener
|
||||||
|
const wrapperRef = tuple[9]; // itemsListWrapperRef
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef as any} data-testid="wrapper">
|
||||||
|
<button data-testid="trigger-resize" onClick={winResizeListener as any}>
|
||||||
|
Trigger Resize
|
||||||
|
</button>
|
||||||
|
<button data-testid="trigger-sidebar" onClick={sidebarListener as any}>
|
||||||
|
Trigger Sidebars
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getByTestId } = render(<TestComponentWithListeners __items={[1, 2, 3]} />);
|
||||||
|
|
||||||
|
// Should not throw when called
|
||||||
|
const resizeButton = getByTestId('trigger-resize');
|
||||||
|
const sidebarButton = getByTestId('trigger-sidebar');
|
||||||
|
|
||||||
|
expect(() => fireEvent.click(resizeButton)).not.toThrow();
|
||||||
|
expect(() => fireEvent.click(sidebarButton)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('winResizeListener updates resizeDate state triggering resize effect', () => {
|
||||||
|
const TestComponent = (props: any) => {
|
||||||
|
const tuple = useItemListInlineSlider(props) as any;
|
||||||
|
const winResizeListener = tuple[7];
|
||||||
|
const wrapperRef = tuple[9];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef} data-testid="wrapper">
|
||||||
|
<button data-testid="trigger-resize" onClick={winResizeListener}>
|
||||||
|
Trigger Resize
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getByTestId } = render(<TestComponent __items={[1, 2, 3]} />);
|
||||||
|
|
||||||
|
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockInlineSliderInstance.updateDataStateOnResize).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId('trigger-resize'));
|
||||||
|
|
||||||
|
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockInlineSliderInstance.updateDataStateOnResize).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(200);
|
||||||
|
|
||||||
|
expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(3);
|
||||||
|
expect(mockInlineSliderInstance.updateDataStateOnResize).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||||
|
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/classes/', () => ({
|
||||||
|
BrowserCache: jest.fn().mockImplementation(() => ({
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mockListHandler: any;
|
||||||
|
let addListItemsSpy = jest.fn();
|
||||||
|
const mockRemoveListener = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/hooks/useItemList', () => ({
|
||||||
|
useItemList: (props: any, _ref: any) => {
|
||||||
|
mockListHandler = {
|
||||||
|
loadItems: jest.fn(),
|
||||||
|
totalPages: jest.fn().mockReturnValue(props.__totalPages ?? 1),
|
||||||
|
loadedAllItems: jest.fn().mockReturnValue(Boolean(props.__loadedAll ?? true)),
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
props.__items ?? [], // items
|
||||||
|
props.__countedItems ?? 0, // countedItems
|
||||||
|
mockListHandler, // listHandler
|
||||||
|
jest.fn(), // setListHandler
|
||||||
|
jest.fn(), // onItemsLoad
|
||||||
|
jest.fn(), // onItemsCount
|
||||||
|
addListItemsSpy, // addListItems
|
||||||
|
];
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/stores/', () => ({
|
||||||
|
PageStore: {
|
||||||
|
removeListener: mockRemoveListener,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useItemListLazyLoad } from '../../../src/static/js/utils/hooks/useItemListLazyLoad';
|
||||||
|
|
||||||
|
function HookConsumer(props: any) {
|
||||||
|
const tuple = useItemListLazyLoad(props);
|
||||||
|
|
||||||
|
const [
|
||||||
|
_items,
|
||||||
|
_countedItems,
|
||||||
|
_listHandler,
|
||||||
|
_setListHandler,
|
||||||
|
classname,
|
||||||
|
_onItemsCount,
|
||||||
|
_onItemsLoad,
|
||||||
|
_onWindowScroll,
|
||||||
|
_onDocumentVisibilityChange,
|
||||||
|
_itemsListWrapperRef,
|
||||||
|
_itemsListRef,
|
||||||
|
renderBeforeListWrap,
|
||||||
|
renderAfterListWrap,
|
||||||
|
] = tuple as any;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div data-testid="class-list">{classname.list}</div>
|
||||||
|
<div data-testid="class-outer">{classname.listOuter}</div>
|
||||||
|
<div data-testid="render-before">{renderBeforeListWrap()}</div>
|
||||||
|
<div data-testid="render-after">{renderAfterListWrap()}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HookConsumerWithRefs(props: any) {
|
||||||
|
const tuple = useItemListLazyLoad(props);
|
||||||
|
const [
|
||||||
|
_items,
|
||||||
|
_countedItems,
|
||||||
|
_listHandler,
|
||||||
|
_setListHandler,
|
||||||
|
classname,
|
||||||
|
_onItemsCount,
|
||||||
|
_onItemsLoad,
|
||||||
|
onWindowScroll,
|
||||||
|
onDocumentVisibilityChange,
|
||||||
|
itemsListWrapperRef,
|
||||||
|
itemsListRef,
|
||||||
|
renderBeforeListWrap,
|
||||||
|
renderAfterListWrap,
|
||||||
|
] = tuple as any;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={itemsListWrapperRef}>
|
||||||
|
<div data-testid="class-list">{classname.list}</div>
|
||||||
|
<div data-testid="class-outer">{classname.listOuter}</div>
|
||||||
|
<div ref={itemsListRef} data-testid="list-ref-node" />
|
||||||
|
<div data-testid="render-before">{renderBeforeListWrap()}</div>
|
||||||
|
<div data-testid="render-after">{renderAfterListWrap()}</div>
|
||||||
|
<button data-testid="trigger-visibility" onClick={onDocumentVisibilityChange} type="button">
|
||||||
|
visibility
|
||||||
|
</button>
|
||||||
|
<button data-testid="trigger-scroll" onClick={onWindowScroll} type="button">
|
||||||
|
scroll
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('utils/hooks', () => {
|
||||||
|
describe('useItemListLazyLoad', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
addListItemsSpy = jest.fn();
|
||||||
|
mockRemoveListener.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Computes classname.list and classname.listOuter with optional className prop', () => {
|
||||||
|
const { getByTestId, rerender } = render(<HookConsumer className=" extra " />);
|
||||||
|
expect(getByTestId('class-outer').textContent).toBe('items-list-outer extra');
|
||||||
|
expect(getByTestId('class-list').textContent).toBe('items-list');
|
||||||
|
rerender(<HookConsumer />);
|
||||||
|
expect(getByTestId('class-outer').textContent).toBe('items-list-outer');
|
||||||
|
expect(getByTestId('class-list').textContent).toBe('items-list');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Invokes addListItems when items change', () => {
|
||||||
|
const { rerender } = render(<HookConsumer __items={[]} />);
|
||||||
|
expect(addListItemsSpy).toHaveBeenCalledTimes(1);
|
||||||
|
rerender(<HookConsumer __items={[1]} />);
|
||||||
|
expect(addListItemsSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Renders nothing in renderBeforeListWrap and renderAfterListWrap', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<HookConsumer __items={[1]} __countedItems={1} __totalPages={3} __loadedAll={false} />
|
||||||
|
);
|
||||||
|
expect(getByTestId('render-before').textContent).toBe('');
|
||||||
|
expect(getByTestId('render-after').textContent).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Does not call listHandler.loadItems when refs are not attached', () => {
|
||||||
|
render(<HookConsumer __items={[1]} />);
|
||||||
|
expect(mockListHandler.loadItems).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Calls listHandler.loadItems when refs are set and scroll threshold is reached', async () => {
|
||||||
|
render(<HookConsumerWithRefs __items={[1]} __loadedAll={false} />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockListHandler.loadItems).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Calls PageStore.removeListener when refs are set and loadedAllItems is true', () => {
|
||||||
|
render(<HookConsumerWithRefs __items={[1]} __loadedAll={true} />);
|
||||||
|
expect(mockRemoveListener).toHaveBeenCalledWith('window_scroll', expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onDocumentVisibilityChange schedules onWindowScroll when document is visible', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout');
|
||||||
|
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
|
||||||
|
|
||||||
|
const { getByTestId } = render(<HookConsumerWithRefs __items={[1]} />);
|
||||||
|
fireEvent.click(getByTestId('trigger-visibility'));
|
||||||
|
|
||||||
|
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 10);
|
||||||
|
|
||||||
|
setTimeoutSpy.mockRestore();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onDocumentVisibilityChange does nothing when document is hidden', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout');
|
||||||
|
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
|
||||||
|
|
||||||
|
const { getByTestId } = render(<HookConsumerWithRefs __items={[1]} />);
|
||||||
|
fireEvent.click(getByTestId('trigger-visibility'));
|
||||||
|
|
||||||
|
expect(setTimeoutSpy).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
setTimeoutSpy.mockRestore();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, fireEvent } from '@testing-library/react';
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||||
|
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/classes/', () => ({
|
||||||
|
BrowserCache: jest.fn().mockImplementation(() => ({
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/helpers/', () => ({
|
||||||
|
translateString: (s: string) => s,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mockListHandler: any;
|
||||||
|
let mockOnItemsLoad = jest.fn();
|
||||||
|
let mockOnItemsCount = jest.fn();
|
||||||
|
let addListItemsSpy = jest.fn();
|
||||||
|
|
||||||
|
// Mock useItemList to control items, counts, and listHandler
|
||||||
|
jest.mock('../../../src/static/js/utils/hooks/useItemList', () => ({
|
||||||
|
useItemList: (props: any, _ref: any) => {
|
||||||
|
mockListHandler = {
|
||||||
|
loadItems: jest.fn(),
|
||||||
|
totalPages: jest.fn().mockReturnValue(props.__totalPages ?? 1),
|
||||||
|
loadedAllItems: jest.fn().mockReturnValue(Boolean(props.__loadedAll ?? true)),
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
props.__items ?? [], // items
|
||||||
|
props.__countedItems ?? 0, // countedItems
|
||||||
|
mockListHandler, // listHandler
|
||||||
|
jest.fn(), // setListHandler
|
||||||
|
mockOnItemsLoad, // onItemsLoad
|
||||||
|
mockOnItemsCount, // onItemsCount
|
||||||
|
addListItemsSpy, // addListItems
|
||||||
|
];
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useItemListSync } from '../../../src/static/js/utils/hooks/useItemListSync';
|
||||||
|
|
||||||
|
function HookConsumer(props: any) {
|
||||||
|
const tuple = useItemListSync(props);
|
||||||
|
|
||||||
|
const [
|
||||||
|
_countedItems,
|
||||||
|
_items,
|
||||||
|
_listHandler,
|
||||||
|
_setListHandler,
|
||||||
|
classname,
|
||||||
|
_itemsListWrapperRef,
|
||||||
|
_itemsListRef,
|
||||||
|
_onItemsCount,
|
||||||
|
_onItemsLoad,
|
||||||
|
renderBeforeListWrap,
|
||||||
|
renderAfterListWrap,
|
||||||
|
] = tuple as any;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* <div data-testid="counted">{String(countedItems)}</div> */}
|
||||||
|
{/* <div data-testid="items">{Array.isArray(items) ? items.length : 0}</div> */}
|
||||||
|
<div data-testid="class-list">{classname.list}</div>
|
||||||
|
<div data-testid="class-outer">{classname.listOuter}</div>
|
||||||
|
{/* <div data-testid="has-handler">{listHandler ? 'yes' : 'no'}</div> */}
|
||||||
|
{/* <div data-testid="wrapper-ref">{itemsListWrapperRef.current ? 'set' : 'unset'}</div> */}
|
||||||
|
{/* <div data-testid="list-ref">{itemsListRef.current ? 'set' : 'unset'}</div> */}
|
||||||
|
<div data-testid="render-before">{renderBeforeListWrap()}</div>
|
||||||
|
<div data-testid="render-after">{renderAfterListWrap()}</div>
|
||||||
|
{/* <button data-testid="call-on-load" onClick={() => onItemsLoad([])} /> */}
|
||||||
|
{/* <button data-testid="call-on-count" onClick={() => onItemsCount(0)} /> */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('utils/hooks', () => {
|
||||||
|
describe('useItemListSync', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockOnItemsLoad = jest.fn();
|
||||||
|
mockOnItemsCount = jest.fn();
|
||||||
|
addListItemsSpy = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Classname Management', () => {
|
||||||
|
test('Computes classname.listOuter with optional className prop', () => {
|
||||||
|
const { getByTestId, rerender } = render(<HookConsumer className=" extra " />);
|
||||||
|
expect(getByTestId('class-outer').textContent).toBe('items-list-outer extra');
|
||||||
|
expect(getByTestId('class-list').textContent).toBe('items-list');
|
||||||
|
rerender(<HookConsumer />);
|
||||||
|
expect(getByTestId('class-outer').textContent).toBe('items-list-outer');
|
||||||
|
expect(getByTestId('class-list').textContent).toBe('items-list');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Items Management', () => {
|
||||||
|
test('Invokes addListItems and afterItemsLoad when items change', () => {
|
||||||
|
const { rerender } = render(<HookConsumer __items={[]} />);
|
||||||
|
expect(addListItemsSpy).toHaveBeenCalledTimes(1);
|
||||||
|
rerender(<HookConsumer __items={[1]} />);
|
||||||
|
// useEffect runs again due to items change
|
||||||
|
expect(addListItemsSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Load More Button Rendering', () => {
|
||||||
|
test('Renders SHOW MORE button when more pages exist and not loaded all', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<HookConsumer __items={[1]} __countedItems={1} __totalPages={3} __loadedAll={false} />
|
||||||
|
);
|
||||||
|
const btn = getByTestId('render-after').querySelector('button.load-more') as HTMLButtonElement;
|
||||||
|
expect(btn).toBeTruthy();
|
||||||
|
expect(btn.textContent).toBe('SHOW MORE');
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(mockListHandler.loadItems).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hides SHOW MORE when totalPages <= 1', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
// With totalPages=1 the hook should not render the button regardless of loadedAll
|
||||||
|
<HookConsumer __items={[1, 2]} __countedItems={2} __totalPages={1} __loadedAll={true} />
|
||||||
|
);
|
||||||
|
expect(getByTestId('render-after').textContent).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hides SHOW MORE when loadedAllItems is true', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<HookConsumer __items={[1, 2, 3]} __countedItems={3} __totalPages={5} __loadedAll={true} />
|
||||||
|
);
|
||||||
|
expect(getByTestId('render-after').textContent).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Shows SHOW MORE when loadedAllItems is false even with totalPages > 1', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<HookConsumer __items={[1, 2]} __countedItems={2} __totalPages={2} __loadedAll={false} />
|
||||||
|
);
|
||||||
|
const btn = getByTestId('render-after').querySelector('button.load-more');
|
||||||
|
expect(btn).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns null from renderBeforeListWrap', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<HookConsumer __items={[1]} __countedItems={1} __totalPages={3} __loadedAll={false} />
|
||||||
|
);
|
||||||
|
expect(getByTestId('render-before').textContent).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act, render } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { useLayout } from '../../../src/static/js/utils/hooks/useLayout';
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/classes/', () => ({
|
||||||
|
BrowserCache: jest.fn().mockImplementation(() => ({
|
||||||
|
get: (key: string) => {
|
||||||
|
let result: any = undefined;
|
||||||
|
switch (key) {
|
||||||
|
case 'visible-sidebar':
|
||||||
|
result = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
set: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/dispatcher.js', () => ({
|
||||||
|
register: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||||
|
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { LayoutProvider } from '../../../src/static/js/utils/contexts';
|
||||||
|
|
||||||
|
describe('utils/hooks', () => {
|
||||||
|
describe('useLayout', () => {
|
||||||
|
test('Returns default value', () => {
|
||||||
|
let received: ReturnType<typeof useLayout> | undefined;
|
||||||
|
|
||||||
|
const Comp: React.FC = () => {
|
||||||
|
received = useLayout();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<LayoutProvider>
|
||||||
|
<Comp />
|
||||||
|
</LayoutProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(received).toStrictEqual({
|
||||||
|
enabledSidebar: false,
|
||||||
|
visibleSidebar: true,
|
||||||
|
visibleMobileSearch: false,
|
||||||
|
setVisibleSidebar: expect.any(Function),
|
||||||
|
toggleMobileSearch: expect.any(Function),
|
||||||
|
toggleSidebar: expect.any(Function),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns undefined value when used without a Provider', () => {
|
||||||
|
let received: any = 'init';
|
||||||
|
|
||||||
|
const Comp: React.FC = () => {
|
||||||
|
received = useLayout();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<Comp />);
|
||||||
|
|
||||||
|
expect(received).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Toggle sidebar', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
let received: ReturnType<typeof useLayout> | undefined;
|
||||||
|
|
||||||
|
const Comp: React.FC = () => {
|
||||||
|
received = useLayout();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<LayoutProvider>
|
||||||
|
<Comp />
|
||||||
|
</LayoutProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => received?.toggleSidebar());
|
||||||
|
jest.advanceTimersByTime(241);
|
||||||
|
expect(received?.visibleSidebar).toBe(false);
|
||||||
|
|
||||||
|
act(() => received?.toggleSidebar());
|
||||||
|
jest.advanceTimersByTime(241);
|
||||||
|
expect(received?.visibleSidebar).toBe(true);
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Toggle mobile search', () => {
|
||||||
|
let received: ReturnType<typeof useLayout> | undefined;
|
||||||
|
|
||||||
|
const Comp: React.FC = () => {
|
||||||
|
received = useLayout();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<LayoutProvider>
|
||||||
|
<Comp />
|
||||||
|
</LayoutProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => received?.toggleMobileSearch());
|
||||||
|
expect(received?.visibleMobileSearch).toBe(true);
|
||||||
|
|
||||||
|
act(() => received?.toggleMobileSearch());
|
||||||
|
expect(received?.visibleMobileSearch).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, fireEvent } from '@testing-library/react';
|
||||||
|
import { useManagementTableHeader } from '../../../src/static/js/utils/hooks/useManagementTableHeader';
|
||||||
|
|
||||||
|
function HookConsumer(props: {
|
||||||
|
order: 'asc' | 'desc';
|
||||||
|
selected: boolean;
|
||||||
|
sort: string;
|
||||||
|
type: 'comments' | 'media' | 'users';
|
||||||
|
onCheckAllRows?: (newSort: string, newOrder: 'asc' | 'desc') => void;
|
||||||
|
onClickColumnSort?: (newSelected: boolean, newType: 'comments' | 'media' | 'users') => void;
|
||||||
|
}) {
|
||||||
|
const tuple = useManagementTableHeader(props) as [
|
||||||
|
string,
|
||||||
|
'asc' | 'desc',
|
||||||
|
boolean,
|
||||||
|
React.MouseEventHandler,
|
||||||
|
() => void,
|
||||||
|
];
|
||||||
|
|
||||||
|
const [sort, order, isSelected, sortByColumn, checkAll] = tuple;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div data-testid="sort">{sort}</div>
|
||||||
|
<div data-testid="order">{order}</div>
|
||||||
|
<div data-testid="selected">{String(isSelected)}</div>
|
||||||
|
<button id="title" data-testid="col-title" onClick={sortByColumn} />
|
||||||
|
<button id="views" data-testid="col-views" onClick={sortByColumn} />
|
||||||
|
<button data-testid="check-all" onClick={checkAll} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('utils/hooks', () => {
|
||||||
|
describe('useManagementTableHeader', () => {
|
||||||
|
test('Returns a 5-tuple in expected order and reflects initial props', () => {
|
||||||
|
let tuple: any;
|
||||||
|
|
||||||
|
const Comp: React.FC = () => {
|
||||||
|
tuple = useManagementTableHeader({ sort: 'title', order: 'asc', selected: false });
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<Comp />);
|
||||||
|
|
||||||
|
expect(Array.isArray(tuple)).toBe(true);
|
||||||
|
expect(tuple).toHaveLength(5);
|
||||||
|
|
||||||
|
const [sort, order, isSelected] = tuple;
|
||||||
|
|
||||||
|
expect(sort).toBe('title');
|
||||||
|
expect(order).toBe('asc');
|
||||||
|
expect(isSelected).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sortByColumn toggles order when clicking same column and updates sort when clicking different column', () => {
|
||||||
|
const onClickColumnSort = jest.fn();
|
||||||
|
|
||||||
|
const { getByTestId, rerender } = render(
|
||||||
|
<HookConsumer
|
||||||
|
sort="title"
|
||||||
|
order="desc"
|
||||||
|
type="media"
|
||||||
|
selected={false}
|
||||||
|
onClickColumnSort={onClickColumnSort}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
expect(getByTestId('sort').textContent).toBe('title');
|
||||||
|
expect(getByTestId('order').textContent).toBe('desc');
|
||||||
|
|
||||||
|
// Click same column -> toggle order to asc
|
||||||
|
fireEvent.click(getByTestId('col-title'));
|
||||||
|
expect(onClickColumnSort).toHaveBeenLastCalledWith('title', 'asc');
|
||||||
|
|
||||||
|
// Rerender to ensure state settled in testing DOM
|
||||||
|
rerender(
|
||||||
|
<HookConsumer
|
||||||
|
sort="title"
|
||||||
|
order="asc"
|
||||||
|
type="media"
|
||||||
|
selected={false}
|
||||||
|
onClickColumnSort={onClickColumnSort}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click same column -> toggle order to desc
|
||||||
|
fireEvent.click(getByTestId('col-title'));
|
||||||
|
expect(onClickColumnSort).toHaveBeenLastCalledWith('title', 'desc');
|
||||||
|
|
||||||
|
// Click different column -> set sort to that column and default order desc
|
||||||
|
fireEvent.click(getByTestId('col-views'));
|
||||||
|
expect(onClickColumnSort).toHaveBeenLastCalledWith('views', 'desc');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkAll inverts current selection and invokes callback with newSelected and type', () => {
|
||||||
|
const onCheckAllRows = jest.fn();
|
||||||
|
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<HookConsumer sort="title" order="asc" selected={false} type="media" onCheckAllRows={onCheckAllRows} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId('selected').textContent).toBe('false');
|
||||||
|
fireEvent.click(getByTestId('check-all'));
|
||||||
|
|
||||||
|
// newSelected computed as !isSelected -> true
|
||||||
|
expect(onCheckAllRows).toHaveBeenCalledWith(true, 'media');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Effects update internal state when props change', () => {
|
||||||
|
const { getByTestId, rerender } = render(
|
||||||
|
<HookConsumer sort="title" order="asc" type="media" selected={false} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId('sort').textContent).toBe('title');
|
||||||
|
expect(getByTestId('order').textContent).toBe('asc');
|
||||||
|
expect(getByTestId('selected').textContent).toBe('false');
|
||||||
|
|
||||||
|
rerender(<HookConsumer sort="views" order="desc" type="media" selected={true} />);
|
||||||
|
|
||||||
|
expect(getByTestId('sort').textContent).toBe('views');
|
||||||
|
expect(getByTestId('order').textContent).toBe('desc');
|
||||||
|
expect(getByTestId('selected').textContent).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Does not throw when optional callbacks are not provided', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer sort="x" order="desc" type="media" selected={false} />);
|
||||||
|
expect(() => fireEvent.click(getByTestId('col-title'))).not.toThrow();
|
||||||
|
expect(() => fireEvent.click(getByTestId('check-all'))).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { useMediaFilter } from '../../../src/static/js/utils/hooks/useMediaFilter';
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/components/_shared/popup/PopupContent', () => ({
|
||||||
|
PopupContent: (props: any) => React.createElement('div', props, props.children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/components/_shared/popup/PopupTrigger', () => ({
|
||||||
|
PopupTrigger: (props: any) => React.createElement('div', props, props.children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function HookConsumer({ initial }: { initial: string }) {
|
||||||
|
const tuple = useMediaFilter(initial) as [
|
||||||
|
React.RefObject<any>,
|
||||||
|
string,
|
||||||
|
React.Dispatch<React.SetStateAction<string>>,
|
||||||
|
React.RefObject<any>,
|
||||||
|
React.ReactNode,
|
||||||
|
React.ReactNode,
|
||||||
|
];
|
||||||
|
|
||||||
|
const [containerRef, value, setValue, popupContentRef, PopupContent, PopupTrigger] = tuple;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div data-testid="container-ref">{containerRef && typeof containerRef === 'object' ? 'ok' : 'bad'}</div>
|
||||||
|
<div data-testid="value">{value}</div>
|
||||||
|
<button data-testid="set" onClick={() => setValue('updated')} />
|
||||||
|
<div data-testid="popup-ref">{popupContentRef && typeof popupContentRef === 'object' ? 'ok' : 'bad'}</div>
|
||||||
|
{typeof PopupContent === 'function' ? React.createElement(PopupContent, null, 'c') : null}
|
||||||
|
{typeof PopupTrigger === 'function' ? React.createElement(PopupTrigger, null, 't') : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('utils/hooks', () => {
|
||||||
|
describe('useMediaFilter', () => {
|
||||||
|
test('Returns a 6-tuple in expected order', () => {
|
||||||
|
let tuple: any;
|
||||||
|
|
||||||
|
const Comp: React.FC = () => {
|
||||||
|
tuple = useMediaFilter('init');
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<Comp />);
|
||||||
|
|
||||||
|
expect(Array.isArray(tuple)).toBe(true);
|
||||||
|
expect(tuple).toHaveLength(6);
|
||||||
|
|
||||||
|
const [containerRef, value, setValue, popupContentRef, PopupContent, PopupTrigger] = tuple;
|
||||||
|
|
||||||
|
expect(containerRef).toBeDefined();
|
||||||
|
expect(containerRef.current).toBe(null);
|
||||||
|
expect(value).toBe('init');
|
||||||
|
expect(typeof setValue).toBe('function');
|
||||||
|
expect(popupContentRef).toBeDefined();
|
||||||
|
expect(typeof PopupContent).toBe('function');
|
||||||
|
expect(typeof PopupTrigger).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Initial value is respected and can be updated via setter', () => {
|
||||||
|
const { getByTestId } = render(<HookConsumer initial="first" />);
|
||||||
|
expect(getByTestId('value').textContent).toBe('first');
|
||||||
|
getByTestId('set').click();
|
||||||
|
expect(getByTestId('value').textContent).toBe('updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('containerRef and popupContentRef are mutable ref objects', () => {
|
||||||
|
let data: any;
|
||||||
|
|
||||||
|
const Comp: React.FC = () => {
|
||||||
|
data = useMediaFilter('x');
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<Comp />);
|
||||||
|
|
||||||
|
const [containerRef, _value, _setValue, popupContentRef] = data;
|
||||||
|
|
||||||
|
expect(containerRef.current).toBe(null);
|
||||||
|
expect(popupContentRef.current).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PopupContent and PopupTrigger are stable functions', () => {
|
||||||
|
let first: any;
|
||||||
|
let second: any;
|
||||||
|
|
||||||
|
const First: React.FC = () => {
|
||||||
|
first = useMediaFilter('a');
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Second: React.FC = () => {
|
||||||
|
second = useMediaFilter('b');
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Parent: React.FC = () => (
|
||||||
|
<>
|
||||||
|
<First />
|
||||||
|
<Second />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<Parent />);
|
||||||
|
|
||||||
|
const [, , , , PopupContent1, PopupTrigger1] = first;
|
||||||
|
const [, , , , PopupContent2, PopupTrigger2] = second;
|
||||||
|
|
||||||
|
expect(typeof PopupContent1).toBe('function');
|
||||||
|
expect(typeof PopupTrigger1).toBe('function');
|
||||||
|
|
||||||
|
expect(PopupContent1).toBe(PopupContent2);
|
||||||
|
expect(PopupTrigger1).toBe(PopupTrigger2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { useMediaItem, itemClassname } from '../../../src/static/js/utils/hooks/useMediaItem';
|
||||||
|
|
||||||
|
// Mock dependencies used by useMediaItem
|
||||||
|
|
||||||
|
// @todo: Revisit this
|
||||||
|
jest.mock('../../../src/static/js/utils/stores/', () => ({
|
||||||
|
PageStore: { get: (_: string) => ({ url: 'https://example.com' }) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/components/list-item/includes/items', () => ({
|
||||||
|
MediaItemAuthor: ({ name }: any) => <div data-testid="author" data-name={name} />,
|
||||||
|
MediaItemAuthorLink: ({ name, link }: any) => (
|
||||||
|
<a data-testid="author-link" data-name={name} href={link || undefined} />
|
||||||
|
),
|
||||||
|
MediaItemMetaViews: ({ views }: any) => <span data-testid="views" data-views={views} />,
|
||||||
|
MediaItemMetaDate: ({ time, dateTime, text }: any) => (
|
||||||
|
<time data-testid="date" data-time={String(time)} data-datetime={String(dateTime)}>
|
||||||
|
{text}
|
||||||
|
</time>
|
||||||
|
),
|
||||||
|
MediaItemEditLink: ({ link }: any) => <a data-testid="edit" href={link} />,
|
||||||
|
MediaItemViewLink: ({ link }: any) => <a data-testid="view" href={link} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// @todo: Revisit this
|
||||||
|
// useItem returns titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper
|
||||||
|
jest.mock('../../../src/static/js/utils/hooks/useItem', () => ({
|
||||||
|
useItem: (props: any) => ({
|
||||||
|
titleComponent: () => <h3 data-testid="title">{props.title || 'title'}</h3>,
|
||||||
|
descriptionComponent: () => <p data-testid="desc">{props.description || 'desc'}</p>,
|
||||||
|
thumbnailUrl: props.thumb || 'thumb.jpg',
|
||||||
|
UnderThumbWrapper: ({ children }: any) => <div data-testid="under-thumb">{children}</div>,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function HookConsumer(props: any) {
|
||||||
|
const [TitleComp, DescComp, thumbUrl, UnderThumbComp, EditComp, MetaComp, ViewComp] = useMediaItem(props);
|
||||||
|
// The hook returns functions/components/values. To satisfy TS, render using React.createElement
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{typeof TitleComp === 'function' ? React.createElement(TitleComp) : null}
|
||||||
|
{typeof DescComp === 'function' ? React.createElement(DescComp) : null}
|
||||||
|
<div data-testid="thumb">{typeof thumbUrl === 'string' ? thumbUrl : ''}</div>
|
||||||
|
{typeof UnderThumbComp === 'function'
|
||||||
|
? React.createElement(
|
||||||
|
UnderThumbComp,
|
||||||
|
null,
|
||||||
|
typeof EditComp === 'function' ? React.createElement(EditComp) : null,
|
||||||
|
typeof MetaComp === 'function' ? React.createElement(MetaComp) : null,
|
||||||
|
typeof ViewComp === 'function' ? React.createElement(ViewComp) : null
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('utils/hooks', () => {
|
||||||
|
describe('useMediaItem', () => {
|
||||||
|
describe('itemClassname utility function', () => {
|
||||||
|
test('Returns default classname when no modifications', () => {
|
||||||
|
expect(itemClassname('base', '', false)).toBe('base');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Appends inherited classname when provided', () => {
|
||||||
|
expect(itemClassname('base', 'extra', false)).toBe('base extra');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Appends pl-active-item when isActiveInPlaylistPlayback is true', () => {
|
||||||
|
expect(itemClassname('base', '', true)).toBe('base pl-active-item');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Appends both inherited classname and active state', () => {
|
||||||
|
expect(itemClassname('base', 'extra', true)).toBe('base extra pl-active-item');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Basic Rendering', () => {
|
||||||
|
test('Renders basic components from useItem and edit/view links', () => {
|
||||||
|
// @todo: Revisit this
|
||||||
|
const props = {
|
||||||
|
title: 'My Title',
|
||||||
|
description: 'My Desc',
|
||||||
|
thumbnail: 'thumb.jpg',
|
||||||
|
link: '/watch/1',
|
||||||
|
singleLinkContent: true,
|
||||||
|
// hasMediaViewer:...
|
||||||
|
// hasMediaViewerDescr:...
|
||||||
|
// meta_description:...
|
||||||
|
// onMount:...
|
||||||
|
// type:...
|
||||||
|
// ------------------------------
|
||||||
|
editLink: '/edit/1',
|
||||||
|
showSelection: true,
|
||||||
|
// publishLink: ...
|
||||||
|
// hideAuthor:...
|
||||||
|
author_name: 'Author',
|
||||||
|
author_link: '/u/author',
|
||||||
|
// hideViews:...
|
||||||
|
views: 10,
|
||||||
|
// hideDate:...
|
||||||
|
publish_date: '2020-01-01T00:00:00Z',
|
||||||
|
// hideAllMeta:...
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getByTestId, queryByTestId } = render(<HookConsumer {...props} />);
|
||||||
|
|
||||||
|
expect(getByTestId('title').textContent).toBe(props.title);
|
||||||
|
expect(getByTestId('desc').textContent).toBe(props.description);
|
||||||
|
expect(getByTestId('thumb').textContent).toBe('thumb.jpg');
|
||||||
|
|
||||||
|
expect(getByTestId('edit').getAttribute('href')).toBe(props.editLink);
|
||||||
|
|
||||||
|
expect(getByTestId('views').getAttribute('data-views')).toBe(props.views.toString());
|
||||||
|
expect(getByTestId('date')).toBeTruthy();
|
||||||
|
expect(getByTestId('view').getAttribute('href')).toBe(props.link);
|
||||||
|
expect(queryByTestId('author')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('View Link Selection', () => {
|
||||||
|
test('Uses publishLink when provided and showSelection=true', () => {
|
||||||
|
const props = {
|
||||||
|
editLink: '/edit/2',
|
||||||
|
link: '/watch/2',
|
||||||
|
publishLink: '/publish/2',
|
||||||
|
showSelection: true,
|
||||||
|
singleLinkContent: true,
|
||||||
|
author_name: 'A',
|
||||||
|
author_link: '',
|
||||||
|
views: 0,
|
||||||
|
publish_date: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getByTestId } = render(<HookConsumer {...props} />);
|
||||||
|
|
||||||
|
expect(getByTestId('view').getAttribute('href')).toBe(props.publishLink);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Visibility Controls', () => {
|
||||||
|
test('Hides author, views, and date based on props', () => {
|
||||||
|
const props = {
|
||||||
|
editLink: '/e',
|
||||||
|
link: '/l',
|
||||||
|
showSelection: true,
|
||||||
|
hideAuthor: true,
|
||||||
|
hideViews: true,
|
||||||
|
hideDate: true,
|
||||||
|
publish_date: '2020-01-01T00:00:00Z',
|
||||||
|
views: 5,
|
||||||
|
author_name: 'Hidden',
|
||||||
|
author_link: '/u/x',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { queryByTestId } = render(<HookConsumer {...props} />);
|
||||||
|
|
||||||
|
expect(queryByTestId('author')).toBeNull();
|
||||||
|
expect(queryByTestId('views')).toBeNull();
|
||||||
|
expect(queryByTestId('date')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Author link resolves using formatInnerLink and PageStore base url when singleLinkContent=false', () => {
|
||||||
|
const props = {
|
||||||
|
editLink: '/e',
|
||||||
|
link: '/l',
|
||||||
|
showSelection: true,
|
||||||
|
singleLinkContent: false,
|
||||||
|
hideAuthor: false,
|
||||||
|
author_name: 'John',
|
||||||
|
author_link: '/u/john',
|
||||||
|
publish_date: '2020-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = render(<HookConsumer {...props} />);
|
||||||
|
|
||||||
|
const a = container.querySelector('[data-testid="author-link"]') as HTMLAnchorElement;
|
||||||
|
|
||||||
|
expect(a).toBeTruthy();
|
||||||
|
expect(a.getAttribute('href')).toBe(`https://example.com${props.author_link}`);
|
||||||
|
expect(a.getAttribute('data-name')).toBe(props.author_name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Meta Visibility', () => {
|
||||||
|
test('Meta wrapper hidden when hideAllMeta=true', () => {
|
||||||
|
const props = {
|
||||||
|
editLink: '/e',
|
||||||
|
link: '/l',
|
||||||
|
showSelection: true,
|
||||||
|
hideAllMeta: true,
|
||||||
|
publish_date: '2020-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { queryByTestId } = render(<HookConsumer {...props} />);
|
||||||
|
|
||||||
|
expect(queryByTestId('author')).toBeNull();
|
||||||
|
expect(queryByTestId('views')).toBeNull();
|
||||||
|
expect(queryByTestId('date')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Meta wrapper hidden individually by hideAuthor, hideViews, hideDate', () => {
|
||||||
|
const props = {
|
||||||
|
editLink: '/e',
|
||||||
|
link: '/l',
|
||||||
|
showSelection: true,
|
||||||
|
hideAuthor: true,
|
||||||
|
hideViews: false,
|
||||||
|
hideDate: false,
|
||||||
|
publish_date: '2020-01-01T00:00:00Z',
|
||||||
|
views: 5,
|
||||||
|
author_name: 'Test',
|
||||||
|
author_link: '/u/test',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { queryByTestId } = render(<HookConsumer {...props} />);
|
||||||
|
|
||||||
|
expect(queryByTestId('author')).toBeNull();
|
||||||
|
expect(queryByTestId('views')).toBeTruthy();
|
||||||
|
expect(queryByTestId('date')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases & Date Handling', () => {
|
||||||
|
test('Handles views when hideViews is false', () => {
|
||||||
|
const props = {
|
||||||
|
editLink: '/e',
|
||||||
|
link: '/l',
|
||||||
|
showSelection: true,
|
||||||
|
hideViews: false,
|
||||||
|
views: 100,
|
||||||
|
publish_date: '2020-01-01T00:00:00Z',
|
||||||
|
author_name: 'A',
|
||||||
|
author_link: '/u/a',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getByTestId } = render(<HookConsumer {...props} />);
|
||||||
|
expect(getByTestId('views')).toBeTruthy();
|
||||||
|
expect(getByTestId('views').getAttribute('data-views')).toBe('100');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Renders without showSelection', () => {
|
||||||
|
const props = {
|
||||||
|
editLink: '/e',
|
||||||
|
link: '/l',
|
||||||
|
showSelection: false,
|
||||||
|
publish_date: '2020-01-01T00:00:00Z',
|
||||||
|
author_name: 'A',
|
||||||
|
author_link: '/u/a',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { queryByTestId } = render(<HookConsumer {...props} />);
|
||||||
|
expect(queryByTestId('view')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Handles numeric publish_date correctly', () => {
|
||||||
|
const props = {
|
||||||
|
editLink: '/e',
|
||||||
|
link: '/l',
|
||||||
|
showSelection: true,
|
||||||
|
publish_date: 1577836800000, // 2020-01-01 as timestamp
|
||||||
|
author_name: 'A',
|
||||||
|
author_link: '/u/a',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getByTestId } = render(<HookConsumer {...props} />);
|
||||||
|
expect(getByTestId('date')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Handles empty author_link by setting it to null', () => {
|
||||||
|
const props = {
|
||||||
|
editLink: '/e',
|
||||||
|
link: '/l',
|
||||||
|
showSelection: true,
|
||||||
|
singleLinkContent: false,
|
||||||
|
author_name: 'Anonymous',
|
||||||
|
author_link: '', // Empty link
|
||||||
|
publish_date: '2020-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = render(<HookConsumer {...props} />);
|
||||||
|
const authorLink = container.querySelector('[data-testid="author-link"]') as HTMLAnchorElement;
|
||||||
|
expect(authorLink).toBeTruthy();
|
||||||
|
expect(authorLink.getAttribute('href')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
|
||||||
|
// Mock popup components to avoid SCSS imports breaking Jest
|
||||||
|
jest.mock('../../../src/static/js/components/_shared/popup/Popup.jsx', () => {
|
||||||
|
const React = require('react');
|
||||||
|
const Popup = React.forwardRef((props: any, _ref: any) => React.createElement('div', props, props.children));
|
||||||
|
return { __esModule: true, default: Popup };
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/components/_shared/popup/PopupContent.jsx', () => ({
|
||||||
|
PopupContent: (props: any) => React.createElement('div', props, props.children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/components/_shared/popup/PopupTrigger.jsx', () => ({
|
||||||
|
PopupTrigger: (props: any) => React.createElement('div', props, props.children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { usePopup } from '../../../src/static/js/utils/hooks/usePopup';
|
||||||
|
|
||||||
|
describe('utils/hooks', () => {
|
||||||
|
describe('usePopup', () => {
|
||||||
|
test('Returns a 3-tuple: [ref, PopupContent, PopupTrigger]', () => {
|
||||||
|
let value: any;
|
||||||
|
|
||||||
|
const Comp: React.FC = () => {
|
||||||
|
value = usePopup();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<Comp />);
|
||||||
|
|
||||||
|
expect(Array.isArray(value)).toBe(true);
|
||||||
|
expect(value).toHaveLength(3);
|
||||||
|
|
||||||
|
const [ref, PopupContent, PopupTrigger] = value;
|
||||||
|
|
||||||
|
expect(ref).toBeDefined();
|
||||||
|
expect(ref.current).toBe(null);
|
||||||
|
|
||||||
|
expect(typeof PopupContent).toBe('function');
|
||||||
|
expect(typeof PopupTrigger).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Tuple components are stable functions and refs are unique per call', () => {
|
||||||
|
let results: any[] = [];
|
||||||
|
|
||||||
|
const Comp: React.FC = () => {
|
||||||
|
results.push(usePopup());
|
||||||
|
results.push(usePopup());
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<Comp />);
|
||||||
|
|
||||||
|
const [ref1, PopupContent1, PopupTrigger1] = results[0];
|
||||||
|
const [ref2, PopupContent2, PopupTrigger2] = results[1];
|
||||||
|
|
||||||
|
expect(typeof PopupContent1).toBe('function');
|
||||||
|
expect(typeof PopupTrigger1).toBe('function');
|
||||||
|
|
||||||
|
expect(PopupContent1).toBe(PopupContent2);
|
||||||
|
expect(PopupTrigger1).toBe(PopupTrigger2);
|
||||||
|
|
||||||
|
expect(ref1).not.toBe(ref2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act, render } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { useTheme as useThemeHook } from '../../../src/static/js/utils/hooks/useTheme';
|
||||||
|
|
||||||
|
import { sampleMediaCMSConfig } from '../../tests-constants';
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/classes/', () => ({
|
||||||
|
BrowserCache: jest.fn().mockImplementation(() => ({
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/dispatcher.js', () => ({
|
||||||
|
register: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function getRenderers(ThemeProvider: React.FC<{ children: React.ReactNode }>, useTheme: typeof useThemeHook) {
|
||||||
|
const data: { current: any } = { current: undefined };
|
||||||
|
|
||||||
|
const Comp: React.FC = () => {
|
||||||
|
data.current = useTheme();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper: typeof ThemeProvider = ({ children }) => <ThemeProvider>{children}</ThemeProvider>;
|
||||||
|
|
||||||
|
return { Comp, wrapper, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThemeConfig(override?: {
|
||||||
|
logo?: Partial<(typeof sampleMediaCMSConfig.theme)['logo']>;
|
||||||
|
mode?: (typeof sampleMediaCMSConfig.theme)['mode'];
|
||||||
|
switch?: Partial<(typeof sampleMediaCMSConfig.theme)['switch']>;
|
||||||
|
}) {
|
||||||
|
const { logo, mode, switch: sw } = override ?? {};
|
||||||
|
const { lightMode, darkMode } = logo ?? {};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
logo: {
|
||||||
|
lightMode: { img: lightMode?.img ?? '/img/light.png', svg: lightMode?.svg ?? '/img/light.svg' },
|
||||||
|
darkMode: { img: darkMode?.img ?? '/img/dark.png', svg: darkMode?.svg ?? '/img/dark.svg' },
|
||||||
|
},
|
||||||
|
mode: mode ?? 'dark',
|
||||||
|
switch: { enabled: sw?.enabled ?? true, position: sw?.position ?? 'sidebar' },
|
||||||
|
};
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('utils/hooks', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useTheme', () => {
|
||||||
|
const themeConfig = getThemeConfig();
|
||||||
|
const darkThemeConfig = getThemeConfig({ mode: 'dark' });
|
||||||
|
|
||||||
|
// @todo: Revisit this
|
||||||
|
test.each([
|
||||||
|
[
|
||||||
|
darkThemeConfig,
|
||||||
|
{
|
||||||
|
logo: darkThemeConfig.logo.darkMode.svg,
|
||||||
|
currentThemeMode: darkThemeConfig.mode,
|
||||||
|
changeThemeMode: expect.any(Function),
|
||||||
|
themeModeSwitcher: themeConfig.switch,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])('Validate value', async (theme, expectedResult) => {
|
||||||
|
jest.doMock('../../../src/static/js/utils/settings/config', () => ({
|
||||||
|
config: jest.fn(() => ({ ...jest.requireActual('../../tests-constants').sampleMediaCMSConfig, theme })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { ThemeProvider } = await import('../../../src/static/js/utils/contexts/ThemeContext');
|
||||||
|
const { useTheme } = await import('../../../src/static/js/utils/hooks/useTheme');
|
||||||
|
|
||||||
|
const { Comp, wrapper, data } = getRenderers(ThemeProvider, useTheme);
|
||||||
|
|
||||||
|
render(<Comp />, { wrapper });
|
||||||
|
|
||||||
|
expect(data.current).toStrictEqual(expectedResult);
|
||||||
|
|
||||||
|
act(() => data.current.changeThemeMode());
|
||||||
|
|
||||||
|
const newThemeMode = 'light' === expectedResult.currentThemeMode ? 'dark' : 'light';
|
||||||
|
const newThemeLogo =
|
||||||
|
'light' === newThemeMode ? themeConfig.logo.lightMode.svg : themeConfig.logo.darkMode.svg;
|
||||||
|
|
||||||
|
expect(data.current).toStrictEqual({
|
||||||
|
...expectedResult,
|
||||||
|
logo: newThemeLogo,
|
||||||
|
currentThemeMode: newThemeMode,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { UserProvider } from '../../../src/static/js/utils/contexts/UserContext';
|
||||||
|
import { useUser } from '../../../src/static/js/utils/hooks/useUser';
|
||||||
|
import { sampleMediaCMSConfig } from '../../tests-constants';
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||||
|
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function getRenderers() {
|
||||||
|
const data: { current: any } = { current: undefined };
|
||||||
|
|
||||||
|
const Comp: React.FC = () => {
|
||||||
|
data.current = useUser();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => <UserProvider>{children}</UserProvider>;
|
||||||
|
|
||||||
|
return { Comp, wrapper, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('utils/hooks', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useUser', () => {
|
||||||
|
test('Validate value', () => {
|
||||||
|
const { Comp, wrapper, data } = getRenderers();
|
||||||
|
|
||||||
|
render(<Comp />, { wrapper });
|
||||||
|
|
||||||
|
expect(data.current).toStrictEqual({
|
||||||
|
isAnonymous: sampleMediaCMSConfig.member.is.anonymous,
|
||||||
|
username: sampleMediaCMSConfig.member.username,
|
||||||
|
thumbnail: sampleMediaCMSConfig.member.thumbnail,
|
||||||
|
userCan: sampleMediaCMSConfig.member.can,
|
||||||
|
pages: sampleMediaCMSConfig.member.pages,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,739 @@
|
|||||||
|
import { csrfToken, deleteRequest, getRequest, postRequest, putRequest } from '../../../src/static/js/utils/helpers';
|
||||||
|
|
||||||
|
const MEDIA_ID = 'MEDIA_ID';
|
||||||
|
const PLAYLIST_ID = 'PLAYLIST_ID';
|
||||||
|
|
||||||
|
window.history.pushState({}, '', `/?m=${MEDIA_ID}&pl=${PLAYLIST_ID}`);
|
||||||
|
|
||||||
|
import store from '../../../src/static/js/utils/stores/MediaPageStore';
|
||||||
|
|
||||||
|
import { sampleGlobalMediaCMS, sampleMediaCMSConfig } from '../../tests-constants';
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/classes/', () => ({
|
||||||
|
BrowserCache: jest.fn().mockImplementation(() => ({
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||||
|
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/helpers', () => ({
|
||||||
|
BrowserEvents: jest.fn().mockImplementation(() => ({
|
||||||
|
doc: jest.fn(),
|
||||||
|
win: jest.fn(),
|
||||||
|
})),
|
||||||
|
csrfToken: jest.fn(),
|
||||||
|
deleteRequest: jest.fn(),
|
||||||
|
exportStore: jest.fn((store) => store),
|
||||||
|
getRequest: jest.fn(),
|
||||||
|
postRequest: jest.fn(),
|
||||||
|
putRequest: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('utils/store', () => {
|
||||||
|
describe('MediaPageStore', () => {
|
||||||
|
const handler = store.actions_handler.bind(store);
|
||||||
|
|
||||||
|
const onLoadedViewerPlaylistData = jest.fn();
|
||||||
|
const onLoadedPagePlaylistData = jest.fn();
|
||||||
|
const onLoadedViewerPlaylistError = jest.fn();
|
||||||
|
const onLoadedVideoData = jest.fn();
|
||||||
|
const onLoadedImageData = jest.fn();
|
||||||
|
const onLoadedMediaData = jest.fn();
|
||||||
|
const onLoadedMediaError = jest.fn();
|
||||||
|
const onCommentsLoad = jest.fn();
|
||||||
|
const onUsersLoad = jest.fn();
|
||||||
|
const onPlaylistsLoad = jest.fn();
|
||||||
|
const onLikedMediaFailedRequest = jest.fn();
|
||||||
|
const onLikedMedia = jest.fn();
|
||||||
|
const onDislikedMediaFailedRequest = jest.fn();
|
||||||
|
const onDislikedMedia = jest.fn();
|
||||||
|
const onReportedMedia = jest.fn();
|
||||||
|
const onPlaylistCreationCompleted = jest.fn();
|
||||||
|
const onPlaylistCreationFailed = jest.fn();
|
||||||
|
const onMediaPlaylistAdditionCompleted = jest.fn();
|
||||||
|
const onMediaPlaylistAdditionFailed = jest.fn();
|
||||||
|
const onMediaPlaylistRemovalCompleted = jest.fn();
|
||||||
|
const onMediaPlaylistRemovalFailed = jest.fn();
|
||||||
|
const onCopiedMediaLink = jest.fn();
|
||||||
|
const onCopiedEmbedMediaCode = jest.fn();
|
||||||
|
const onMediaDelete = jest.fn();
|
||||||
|
const onMediaDeleteFail = jest.fn();
|
||||||
|
const onCommentDeleteFail = jest.fn();
|
||||||
|
const onCommentDelete = jest.fn();
|
||||||
|
const onCommentSubmitFail = jest.fn();
|
||||||
|
const onCommentSubmit = jest.fn();
|
||||||
|
|
||||||
|
store.on('loaded_viewer_playlist_data', onLoadedViewerPlaylistData);
|
||||||
|
store.on('loaded_page_playlist_data', onLoadedPagePlaylistData);
|
||||||
|
store.on('loaded_viewer_playlist_error', onLoadedViewerPlaylistError);
|
||||||
|
store.on('loaded_video_data', onLoadedVideoData);
|
||||||
|
store.on('loaded_image_data', onLoadedImageData);
|
||||||
|
store.on('loaded_media_data', onLoadedMediaData);
|
||||||
|
store.on('loaded_media_error', onLoadedMediaError);
|
||||||
|
store.on('comments_load', onCommentsLoad);
|
||||||
|
store.on('users_load', onUsersLoad);
|
||||||
|
store.on('playlists_load', onPlaylistsLoad);
|
||||||
|
store.on('liked_media_failed_request', onLikedMediaFailedRequest);
|
||||||
|
store.on('liked_media', onLikedMedia);
|
||||||
|
store.on('disliked_media_failed_request', onDislikedMediaFailedRequest);
|
||||||
|
store.on('disliked_media', onDislikedMedia);
|
||||||
|
store.on('reported_media', onReportedMedia);
|
||||||
|
store.on('playlist_creation_completed', onPlaylistCreationCompleted);
|
||||||
|
store.on('playlist_creation_failed', onPlaylistCreationFailed);
|
||||||
|
store.on('media_playlist_addition_completed', onMediaPlaylistAdditionCompleted);
|
||||||
|
store.on('media_playlist_addition_failed', onMediaPlaylistAdditionFailed);
|
||||||
|
store.on('media_playlist_removal_completed', onMediaPlaylistRemovalCompleted);
|
||||||
|
store.on('media_playlist_removal_failed', onMediaPlaylistRemovalFailed);
|
||||||
|
store.on('copied_media_link', onCopiedMediaLink);
|
||||||
|
store.on('copied_embed_media_code', onCopiedEmbedMediaCode);
|
||||||
|
store.on('media_delete', onMediaDelete);
|
||||||
|
store.on('media_delete_fail', onMediaDeleteFail);
|
||||||
|
store.on('comment_delete_fail', onCommentDeleteFail);
|
||||||
|
store.on('comment_delete', onCommentDelete);
|
||||||
|
store.on('comment_submit_fail', onCommentSubmitFail);
|
||||||
|
store.on('comment_submit', onCommentSubmit);
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
(globalThis as any).window.MediaCMS = {
|
||||||
|
// mediaId: MEDIA_ID, // @note: It doesn't belong in 'sampleGlobalMediaCMS, but it could be used
|
||||||
|
features: sampleGlobalMediaCMS.features,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete (globalThis as any).window.MediaCMS;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Validate initial values', () => {
|
||||||
|
expect(store.get('users')).toStrictEqual([]);
|
||||||
|
expect(store.get('playlists')).toStrictEqual([]);
|
||||||
|
expect(store.get('media-load-error-type')).toBe(null);
|
||||||
|
expect(store.get('media-load-error-message')).toBe(null);
|
||||||
|
expect(store.get('media-comments')).toStrictEqual([]);
|
||||||
|
expect(store.get('media-data')).toBe(null);
|
||||||
|
expect(store.get('media-id')).toBe(MEDIA_ID);
|
||||||
|
expect(store.get('media-url')).toBe('N/A');
|
||||||
|
expect(store.get('media-edit-subtitle-url')).toBe(null);
|
||||||
|
expect(store.get('media-likes')).toBe('N/A');
|
||||||
|
expect(store.get('media-dislikes')).toBe('N/A');
|
||||||
|
expect(store.get('media-summary')).toBe(null);
|
||||||
|
expect(store.get('media-categories')).toStrictEqual([]);
|
||||||
|
expect(store.get('media-tags')).toStrictEqual([]);
|
||||||
|
expect(store.get('media-type')).toBe(null);
|
||||||
|
expect(store.get('media-original-url')).toBe(null);
|
||||||
|
expect(store.get('media-thumbnail-url')).toBe(null);
|
||||||
|
expect(store.get('user-liked-media')).toBe(false);
|
||||||
|
expect(store.get('user-disliked-media')).toBe(false);
|
||||||
|
expect(store.get('media-author-thumbnail-url')).toBe(null);
|
||||||
|
expect(store.get('playlist-data')).toBe(null);
|
||||||
|
expect(store.get('playlist-id')).toBe(null);
|
||||||
|
expect(store.get('playlist-next-media-url')).toBe(null);
|
||||||
|
expect(store.get('playlist-previous-media-url')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Trigger and validate actions behavior', () => {
|
||||||
|
const MEDIA_DATA = {
|
||||||
|
add_subtitle_url: '/MEDIA_DATA_ADD_SUBTITLE_URL',
|
||||||
|
author_thumbnail: 'MEDIA_DATA_AUTHOR_THUMBNAIL',
|
||||||
|
categories_info: [
|
||||||
|
{ title: 'Art', url: '/search?c=Art' },
|
||||||
|
{ title: 'Documentary', url: '/search?c=Documentary' },
|
||||||
|
],
|
||||||
|
likes: 12,
|
||||||
|
dislikes: 4,
|
||||||
|
media_type: 'video',
|
||||||
|
original_media_url: 'MEDIA_DATA_ORIGINAL_MEDIA_URL',
|
||||||
|
reported_times: 0,
|
||||||
|
summary: 'MEDIA_DATA_SUMMARY',
|
||||||
|
tags_info: [
|
||||||
|
{ title: 'and', url: '/search?t=and' },
|
||||||
|
{ title: 'behavior', url: '/search?t=behavior' },
|
||||||
|
],
|
||||||
|
thumbnail_url: 'MEDIA_DATA_THUMBNAIL_URL',
|
||||||
|
url: '/MEDIA_DATA_URL',
|
||||||
|
};
|
||||||
|
const PLAYLIST_DATA = {
|
||||||
|
playlist_media: [
|
||||||
|
{ friendly_token: `${MEDIA_ID}_2`, url: '/PLAYLIT_MEDIA_URL_2' },
|
||||||
|
{ friendly_token: MEDIA_ID, url: '/PLAYLIT_MEDIA_URL_1' },
|
||||||
|
{ friendly_token: `${MEDIA_ID}_3`, url: '/PLAYLIT_MEDIA_URL_3' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const USER_PLAYLIST_DATA = { playlist_media: [{ url: 'm=PLAYLIST_MEDIA_ID' }] };
|
||||||
|
|
||||||
|
test('Action type: "LOAD_MEDIA_DATA"', () => {
|
||||||
|
const MEDIA_API_URL = `${sampleMediaCMSConfig.api.media}/${MEDIA_ID}`;
|
||||||
|
const MEDIA_COMMENTS_API_URL = `${sampleMediaCMSConfig.api.media}/${MEDIA_ID}/comments`;
|
||||||
|
const PLAYLIST_API_URL = `${sampleMediaCMSConfig.api.playlists}/${PLAYLIST_ID}`;
|
||||||
|
const USERS_API_URL = sampleMediaCMSConfig.api.users;
|
||||||
|
const USER_PLAYLISTS_API_URL = `${sampleMediaCMSConfig.api.user.playlists}${sampleMediaCMSConfig.member.username}`;
|
||||||
|
const USER_PLAYLIST_API_URL = `${sampleMediaCMSConfig.site.url}/${'PLAYLIST_API_URL'.replace(/^\//g, '')}`;
|
||||||
|
|
||||||
|
const MEDIA_COMMENTS_RESULTS = ['COMMENT_ID_1'];
|
||||||
|
const USERS_RESULTS = ['USER_ID_1'];
|
||||||
|
const USER_PLAYLISTS_RESULTS = [
|
||||||
|
{
|
||||||
|
url: `/${PLAYLIST_ID}`,
|
||||||
|
user: sampleMediaCMSConfig.member.username,
|
||||||
|
title: 'PLAYLIST_TITLE',
|
||||||
|
description: 'PLAYLIST_DECRIPTION',
|
||||||
|
add_date: 'PLAYLIST_ADD_DATE',
|
||||||
|
api_url: 'PLAYLIST_API_URL',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
(getRequest as jest.Mock).mockImplementation((url, _cache, successCallback, _failCallback) => {
|
||||||
|
if (url === PLAYLIST_API_URL) {
|
||||||
|
return successCallback({ data: PLAYLIST_DATA });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === USER_PLAYLIST_API_URL) {
|
||||||
|
return successCallback({ data: USER_PLAYLIST_DATA });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === MEDIA_API_URL) {
|
||||||
|
return successCallback({ data: MEDIA_DATA });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === USERS_API_URL) {
|
||||||
|
return successCallback({ data: { count: USERS_RESULTS.length, results: USERS_RESULTS } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === MEDIA_COMMENTS_API_URL) {
|
||||||
|
return successCallback({
|
||||||
|
data: { count: MEDIA_COMMENTS_RESULTS.length, results: MEDIA_COMMENTS_RESULTS },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === USER_PLAYLISTS_API_URL) {
|
||||||
|
return successCallback({
|
||||||
|
data: { count: USER_PLAYLISTS_RESULTS.length, results: USER_PLAYLISTS_RESULTS },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
handler({ type: 'LOAD_MEDIA_DATA' });
|
||||||
|
|
||||||
|
expect(getRequest).toHaveBeenCalledTimes(6);
|
||||||
|
|
||||||
|
expect(getRequest).toHaveBeenCalledWith(
|
||||||
|
PLAYLIST_API_URL,
|
||||||
|
false,
|
||||||
|
store.playlistDataResponse,
|
||||||
|
store.playlistDataErrorResponse
|
||||||
|
);
|
||||||
|
expect(getRequest).toHaveBeenCalledWith(
|
||||||
|
MEDIA_API_URL,
|
||||||
|
false,
|
||||||
|
store.dataResponse,
|
||||||
|
store.dataErrorResponse
|
||||||
|
);
|
||||||
|
expect(getRequest).toHaveBeenCalledWith(MEDIA_COMMENTS_API_URL, false, store.commentsResponse);
|
||||||
|
expect(getRequest).toHaveBeenCalledWith(USERS_API_URL, false, store.usersResponse);
|
||||||
|
expect(getRequest).toHaveBeenCalledWith(USER_PLAYLISTS_API_URL, false, store.playlistsResponse);
|
||||||
|
expect(getRequest).toHaveBeenCalledWith(USER_PLAYLIST_API_URL, false, expect.any(Function));
|
||||||
|
|
||||||
|
expect(onLoadedViewerPlaylistData).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onLoadedPagePlaylistData).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onLoadedViewerPlaylistError).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onLoadedVideoData).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onLoadedImageData).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onLoadedMediaData).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onLoadedMediaError).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onCommentsLoad).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onUsersLoad).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onPlaylistsLoad).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onLikedMediaFailedRequest).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onLikedMedia).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onDislikedMediaFailedRequest).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onDislikedMedia).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onReportedMedia).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onPlaylistCreationCompleted).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onPlaylistCreationFailed).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onMediaPlaylistAdditionCompleted).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onMediaPlaylistAdditionFailed).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onMediaPlaylistRemovalCompleted).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onMediaPlaylistRemovalFailed).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onCopiedMediaLink).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onCopiedEmbedMediaCode).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onMediaDelete).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onMediaDeleteFail).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onCommentDeleteFail).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onCommentDelete).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onCommentSubmitFail).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onCommentSubmit).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
expect(store.isVideo()).toBeTruthy();
|
||||||
|
|
||||||
|
expect(store.get('users')).toStrictEqual(USERS_RESULTS);
|
||||||
|
expect(store.get('playlists')).toStrictEqual([
|
||||||
|
{
|
||||||
|
playlist_id: PLAYLIST_ID,
|
||||||
|
title: 'PLAYLIST_TITLE',
|
||||||
|
description: 'PLAYLIST_DECRIPTION',
|
||||||
|
add_date: 'PLAYLIST_ADD_DATE',
|
||||||
|
media_list: ['PLAYLIST_MEDIA_ID'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(store.get('media-load-error-type')).toBe(null);
|
||||||
|
expect(store.get('media-load-error-message')).toBe(null);
|
||||||
|
expect(store.get('media-comments')).toStrictEqual(MEDIA_COMMENTS_RESULTS);
|
||||||
|
expect(store.get('media-data')).toBe(MEDIA_DATA);
|
||||||
|
expect(store.get('media-id')).toBe(MEDIA_ID);
|
||||||
|
expect(store.get('media-url')).toBe(MEDIA_DATA.url);
|
||||||
|
expect(store.get('media-edit-subtitle-url')).toBe(MEDIA_DATA.add_subtitle_url);
|
||||||
|
expect(store.get('media-likes')).toBe(MEDIA_DATA.likes);
|
||||||
|
expect(store.get('media-dislikes')).toBe(MEDIA_DATA.dislikes);
|
||||||
|
expect(store.get('media-summary')).toBe(MEDIA_DATA.summary);
|
||||||
|
expect(store.get('media-categories')).toStrictEqual(MEDIA_DATA.categories_info);
|
||||||
|
expect(store.get('media-tags')).toStrictEqual(MEDIA_DATA.tags_info);
|
||||||
|
expect(store.get('media-type')).toBe(MEDIA_DATA.media_type);
|
||||||
|
expect(store.get('media-original-url')).toBe(MEDIA_DATA.original_media_url);
|
||||||
|
expect(store.get('media-thumbnail-url')).toBe(MEDIA_DATA.thumbnail_url);
|
||||||
|
expect(store.get('user-liked-media')).toBe(false);
|
||||||
|
expect(store.get('user-disliked-media')).toBe(false);
|
||||||
|
expect(store.get('media-author-thumbnail-url')).toBe(`/${MEDIA_DATA.author_thumbnail}`);
|
||||||
|
expect(store.get('playlist-data')).toBe(PLAYLIST_DATA);
|
||||||
|
expect(store.get('playlist-id')).toBe(PLAYLIST_ID);
|
||||||
|
expect(store.get('playlist-next-media-url')).toBe(
|
||||||
|
`${PLAYLIST_DATA.playlist_media[2].url}&pl=${PLAYLIST_ID}`
|
||||||
|
);
|
||||||
|
expect(store.get('playlist-previous-media-url')).toBe(
|
||||||
|
`${PLAYLIST_DATA.playlist_media[0].url}&pl=${PLAYLIST_ID}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "LIKE_MEDIA"', () => {
|
||||||
|
// Mock the CSRF token
|
||||||
|
const mockCSRFtoken = 'test-csrf-token';
|
||||||
|
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||||
|
|
||||||
|
// Mock post request
|
||||||
|
(postRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _postData, _configData, _sync, successCallback, _failCallback) =>
|
||||||
|
successCallback({ data: {} })
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({ type: 'LIKE_MEDIA' });
|
||||||
|
|
||||||
|
// Verify postRequest was called with correct parameters
|
||||||
|
expect(postRequest).toHaveBeenCalledWith(
|
||||||
|
`${sampleMediaCMSConfig.api.media}/${MEDIA_ID}/actions`,
|
||||||
|
{ type: 'like' },
|
||||||
|
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||||
|
false,
|
||||||
|
store.likeActionResponse,
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onLikedMedia).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(store.get('media-likes')).toBe(MEDIA_DATA.likes + 1);
|
||||||
|
expect(store.get('media-dislikes')).toBe(MEDIA_DATA.dislikes);
|
||||||
|
expect(store.get('user-liked-media')).toBe(true);
|
||||||
|
expect(store.get('user-disliked-media')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "DISLIKE_MEDIA"', () => {
|
||||||
|
handler({ type: 'DISLIKE_MEDIA' });
|
||||||
|
|
||||||
|
expect(postRequest).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onDislikedMedia).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
expect(store.get('media-likes')).toBe(MEDIA_DATA.likes + 1);
|
||||||
|
expect(store.get('media-dislikes')).toBe(MEDIA_DATA.dislikes);
|
||||||
|
expect(store.get('user-liked-media')).toBe(true);
|
||||||
|
expect(store.get('user-disliked-media')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "REPORT_MEDIA"', () => {
|
||||||
|
const REPORT_DESCRIPTION = 'REPORT_DESCRIPTION';
|
||||||
|
|
||||||
|
// Mock the CSRF token
|
||||||
|
const mockCSRFtoken = 'test-csrf-token';
|
||||||
|
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||||
|
|
||||||
|
// Mock post request
|
||||||
|
(postRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _postData, _configData, _sync, successCallback, _failCallback) =>
|
||||||
|
successCallback({ data: {} })
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({ type: 'REPORT_MEDIA', reportDescription: REPORT_DESCRIPTION });
|
||||||
|
|
||||||
|
// Verify postRequest was called with correct parameters
|
||||||
|
expect(postRequest).toHaveBeenCalledWith(
|
||||||
|
`${sampleMediaCMSConfig.api.media}/${MEDIA_ID}/actions`,
|
||||||
|
{ type: 'report', extra_info: REPORT_DESCRIPTION },
|
||||||
|
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||||
|
false,
|
||||||
|
store.reportActionResponse,
|
||||||
|
store.reportActionResponse
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onReportedMedia).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "COPY_SHARE_LINK"', () => {
|
||||||
|
document.execCommand = jest.fn(); // @deprecated
|
||||||
|
const inputElement = document.createElement('input');
|
||||||
|
handler({ type: 'COPY_SHARE_LINK', inputElement });
|
||||||
|
expect(onCopiedMediaLink).toHaveBeenCalledTimes(1);
|
||||||
|
expect(document.execCommand).toHaveBeenCalledWith('copy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "COPY_EMBED_MEDIA_CODE"', () => {
|
||||||
|
document.execCommand = jest.fn(); // @deprecated
|
||||||
|
const inputElement = document.createElement('input');
|
||||||
|
handler({ type: 'COPY_EMBED_MEDIA_CODE', inputElement });
|
||||||
|
expect(onCopiedEmbedMediaCode).toHaveBeenCalledTimes(1);
|
||||||
|
expect(document.execCommand).toHaveBeenCalledWith('copy');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Action type: "REMOVE_MEDIA"', () => {
|
||||||
|
const mockCSRFtoken = 'test-csrf-token';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock the CSRF token
|
||||||
|
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||||
|
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Verify deleteRequest was called with correct parameters
|
||||||
|
expect(deleteRequest).toHaveBeenCalledWith(
|
||||||
|
`${sampleMediaCMSConfig.api.media}/${MEDIA_ID}`,
|
||||||
|
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||||
|
false,
|
||||||
|
store.removeMediaResponse,
|
||||||
|
store.removeMediaFail
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fast-forward time
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Successful', () => {
|
||||||
|
// Mock delete request
|
||||||
|
(deleteRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _configData, _sync, successCallback, _failCallback) => successCallback({ status: 204 })
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({ type: 'REMOVE_MEDIA' });
|
||||||
|
|
||||||
|
expect(onMediaDelete).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onMediaDelete).toHaveBeenCalledWith(MEDIA_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Failed', () => {
|
||||||
|
// Mock delete request
|
||||||
|
(deleteRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _configData, _sync, _successCallback, failCallback) => failCallback({})
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({ type: 'REMOVE_MEDIA' });
|
||||||
|
|
||||||
|
expect(onMediaDeleteFail).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Action type: "SUBMIT_COMMENT"', () => {
|
||||||
|
const COMMENT_TEXT = 'COMMENT_TEXT';
|
||||||
|
const COMMENT_UID = 'COMMENT_UID';
|
||||||
|
|
||||||
|
const mockCSRFtoken = 'test-csrf-token';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock the CSRF token
|
||||||
|
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||||
|
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Verify postRequest was called with correct parameters
|
||||||
|
expect(postRequest).toHaveBeenCalledWith(
|
||||||
|
`${sampleMediaCMSConfig.api.media}/${MEDIA_ID}/comments`,
|
||||||
|
{ text: COMMENT_TEXT },
|
||||||
|
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||||
|
false,
|
||||||
|
store.submitCommentResponse,
|
||||||
|
store.submitCommentFail
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fast-forward time
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Successful', () => {
|
||||||
|
// Mock post request
|
||||||
|
(postRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _postData, _configData, _sync, successCallback, _failCallback) =>
|
||||||
|
successCallback({ data: { uid: COMMENT_UID }, status: 201 })
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({ type: 'SUBMIT_COMMENT', commentText: COMMENT_TEXT });
|
||||||
|
|
||||||
|
expect(onCommentSubmit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCommentSubmit).toHaveBeenCalledWith(COMMENT_UID);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Failed', () => {
|
||||||
|
// Mock post request
|
||||||
|
(postRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _postData, _configData, _sync, _successCallback, failCallback) => failCallback()
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({ type: 'SUBMIT_COMMENT', commentText: COMMENT_TEXT });
|
||||||
|
|
||||||
|
expect(onCommentSubmitFail).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Action type: "DELETE_COMMENT"', () => {
|
||||||
|
const COMMENT_ID = 'COMMENT_ID';
|
||||||
|
|
||||||
|
const mockCSRFtoken = 'test-csrf-token';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock the CSRF token
|
||||||
|
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||||
|
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Verify deleteRequest was called with correct parameters
|
||||||
|
expect(deleteRequest).toHaveBeenCalledWith(
|
||||||
|
`${sampleMediaCMSConfig.api.media}/${MEDIA_ID}/comments/${COMMENT_ID}`,
|
||||||
|
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||||
|
false,
|
||||||
|
store.removeCommentResponse,
|
||||||
|
store.removeCommentFail
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fast-forward time
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Successful', () => {
|
||||||
|
// Mock delete request
|
||||||
|
(deleteRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _configData, _sync, successCallback, _failCallback) => successCallback({ status: 204 })
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({ type: 'DELETE_COMMENT', commentId: COMMENT_ID });
|
||||||
|
|
||||||
|
expect(onCommentDelete).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Failed', () => {
|
||||||
|
// Mock delete request
|
||||||
|
(deleteRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _configData, _sync, _successCallback, failCallback) => failCallback()
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({ type: 'DELETE_COMMENT', commentId: COMMENT_ID });
|
||||||
|
|
||||||
|
expect(onCommentDeleteFail).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Action type: "CREATE_PLAYLIST"', () => {
|
||||||
|
const NEW_PLAYLIST_DATA = {
|
||||||
|
title: 'NEW_PLAYLIST_DATA_TITLE',
|
||||||
|
description: 'NEW_PLAYLIST_DATA_DESCRIPTION',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCSRFtoken = 'test-csrf-token';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock the CSRF token
|
||||||
|
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Verify postRequest was called with correct parameters
|
||||||
|
expect(postRequest).toHaveBeenCalledWith(
|
||||||
|
sampleMediaCMSConfig.api.playlists,
|
||||||
|
NEW_PLAYLIST_DATA,
|
||||||
|
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||||
|
false,
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Successful', () => {
|
||||||
|
const NEW_PLAYLIST_RESPONSE_DATA = { uid: 'COMMENT_UID' };
|
||||||
|
|
||||||
|
// Mock post request
|
||||||
|
(postRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _postData, _configData, _sync, successCallback, _failCallback) =>
|
||||||
|
successCallback({ data: NEW_PLAYLIST_RESPONSE_DATA, status: 201 })
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({ type: 'CREATE_PLAYLIST', playlist_data: NEW_PLAYLIST_DATA });
|
||||||
|
|
||||||
|
// Verify postRequest was called with correct parameters
|
||||||
|
expect(postRequest).toHaveBeenCalledWith(
|
||||||
|
sampleMediaCMSConfig.api.playlists,
|
||||||
|
NEW_PLAYLIST_DATA,
|
||||||
|
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||||
|
false,
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onPlaylistCreationCompleted).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onPlaylistCreationCompleted).toHaveBeenCalledWith(NEW_PLAYLIST_RESPONSE_DATA);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Failed', () => {
|
||||||
|
// Mock post request
|
||||||
|
(postRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _postData, _configData, _sync, _successCallback, failCallback) => failCallback()
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({ type: 'CREATE_PLAYLIST', playlist_data: NEW_PLAYLIST_DATA });
|
||||||
|
|
||||||
|
expect(onPlaylistCreationFailed).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Action type: "ADD_MEDIA_TO_PLAYLIST"', () => {
|
||||||
|
const NEW_PLAYLIST_MEDIA_ID = 'NEW_PLAYLIST_MEDIA_ID';
|
||||||
|
const mockCSRFtoken = 'test-csrf-token';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock the CSRF token
|
||||||
|
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Verify postRequest was called with correct parameters
|
||||||
|
expect(putRequest).toHaveBeenCalledWith(
|
||||||
|
`${sampleMediaCMSConfig.api.playlists}/${PLAYLIST_ID}`,
|
||||||
|
{ type: 'add', media_friendly_token: NEW_PLAYLIST_MEDIA_ID },
|
||||||
|
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||||
|
false,
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Successful', () => {
|
||||||
|
// Mock put request
|
||||||
|
(putRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _putData, _configData, _sync, successCallback, _failCallback) =>
|
||||||
|
successCallback({ data: {} })
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({
|
||||||
|
type: 'ADD_MEDIA_TO_PLAYLIST',
|
||||||
|
playlist_id: PLAYLIST_ID,
|
||||||
|
media_id: NEW_PLAYLIST_MEDIA_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onMediaPlaylistAdditionCompleted).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Failed', () => {
|
||||||
|
// Mock put request
|
||||||
|
(putRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _putData, _configData, _sync, _successCallback, failCallback) => failCallback()
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({
|
||||||
|
type: 'ADD_MEDIA_TO_PLAYLIST',
|
||||||
|
playlist_id: PLAYLIST_ID,
|
||||||
|
media_id: NEW_PLAYLIST_MEDIA_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onMediaPlaylistAdditionFailed).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Action type: "REMOVE_MEDIA_FROM_PLAYLIST"', () => {
|
||||||
|
// Mock the CSRF token
|
||||||
|
const mockCSRFtoken = 'test-csrf-token';
|
||||||
|
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Verify postRequest was called with correct parameters
|
||||||
|
expect(putRequest).toHaveBeenCalledWith(
|
||||||
|
`${sampleMediaCMSConfig.api.playlists}/${PLAYLIST_ID}`,
|
||||||
|
{ type: 'remove', media_friendly_token: MEDIA_ID },
|
||||||
|
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||||
|
false,
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Successful', () => {
|
||||||
|
// Mock put request
|
||||||
|
(putRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _putData, _configData, _sync, successCallback, _failCallback) =>
|
||||||
|
successCallback({ data: {} })
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({ type: 'REMOVE_MEDIA_FROM_PLAYLIST', playlist_id: PLAYLIST_ID, media_id: MEDIA_ID });
|
||||||
|
|
||||||
|
expect(onMediaPlaylistRemovalCompleted).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Failed', () => {
|
||||||
|
// Mock put request
|
||||||
|
(putRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _putData, _configData, _sync, _successCallback, failCallback) => failCallback()
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({ type: 'REMOVE_MEDIA_FROM_PLAYLIST', playlist_id: PLAYLIST_ID, media_id: MEDIA_ID });
|
||||||
|
|
||||||
|
expect(onMediaPlaylistRemovalFailed).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "APPEND_NEW_PLAYLIST"', () => {
|
||||||
|
const NEW_USER_PLAYLIST = {
|
||||||
|
add_date: 'PLAYLIST_ADD_DATE_2',
|
||||||
|
description: 'PLAYLIST_DECRIPTION_2',
|
||||||
|
media_list: ['PLAYLIST_MEDIA_ID'],
|
||||||
|
playlist_id: 'PLAYLIST_ID',
|
||||||
|
title: 'PLAYLIST_TITLE_2',
|
||||||
|
};
|
||||||
|
|
||||||
|
handler({ type: 'APPEND_NEW_PLAYLIST', playlist_data: NEW_USER_PLAYLIST });
|
||||||
|
|
||||||
|
expect(onPlaylistsLoad).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(store.get('playlists')).toStrictEqual([
|
||||||
|
{
|
||||||
|
add_date: 'PLAYLIST_ADD_DATE',
|
||||||
|
description: 'PLAYLIST_DECRIPTION',
|
||||||
|
media_list: ['PLAYLIST_MEDIA_ID'],
|
||||||
|
playlist_id: PLAYLIST_ID,
|
||||||
|
title: 'PLAYLIST_TITLE',
|
||||||
|
},
|
||||||
|
NEW_USER_PLAYLIST,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { BrowserCache } from '../../../src/static/js/utils/classes';
|
||||||
|
import store from '../../../src/static/js/utils/stores/PageStore';
|
||||||
|
|
||||||
|
import { sampleMediaCMSConfig } from '../../tests-constants';
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/classes/', () => ({
|
||||||
|
BrowserCache: jest.fn().mockImplementation(() => ({
|
||||||
|
get: (key: string) => (key === 'media-auto-play' ? false : undefined),
|
||||||
|
set: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||||
|
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/helpers', () => ({
|
||||||
|
BrowserEvents: jest.fn().mockImplementation(() => ({
|
||||||
|
doc: jest.fn(),
|
||||||
|
win: jest.fn(),
|
||||||
|
})),
|
||||||
|
exportStore: jest.fn((store) => store),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('utils/store', () => {
|
||||||
|
afterAll(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PageStore', () => {
|
||||||
|
const handler = store.actions_handler.bind(store);
|
||||||
|
|
||||||
|
const onInit = jest.fn();
|
||||||
|
const onToggleAutoPlay = jest.fn();
|
||||||
|
const onAddNotification = jest.fn();
|
||||||
|
|
||||||
|
store.on('page_init', onInit);
|
||||||
|
store.on('switched_media_auto_play', onToggleAutoPlay);
|
||||||
|
store.on('added_notification', onAddNotification);
|
||||||
|
|
||||||
|
test('Validate initial values', () => {
|
||||||
|
// BrowserCache mock
|
||||||
|
expect(store.get('browser-cache').get('media-auto-play')).toBe(false);
|
||||||
|
expect(store.get('browser-cache').get('ANY')).toBe(undefined);
|
||||||
|
|
||||||
|
// Autoplay media files
|
||||||
|
expect(store.get('media-auto-play')).toBe(false);
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
expect(store.get('config-contents')).toStrictEqual(sampleMediaCMSConfig.contents);
|
||||||
|
expect(store.get('config-enabled')).toStrictEqual(sampleMediaCMSConfig.enabled);
|
||||||
|
expect(store.get('config-media-item')).toStrictEqual(sampleMediaCMSConfig.media.item);
|
||||||
|
expect(store.get('config-options')).toStrictEqual(sampleMediaCMSConfig.options);
|
||||||
|
expect(store.get('config-site')).toStrictEqual(sampleMediaCMSConfig.site);
|
||||||
|
|
||||||
|
// Playlists API path
|
||||||
|
expect(store.get('api-playlists')).toStrictEqual(sampleMediaCMSConfig.api.playlists);
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
expect(store.get('notifications')).toStrictEqual([]);
|
||||||
|
expect(store.get('notifications-size')).toBe(0);
|
||||||
|
|
||||||
|
expect(store.get('current-page')).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Trigger and validate browser events behavior', () => {
|
||||||
|
const docVisChange = jest.fn();
|
||||||
|
const winScroll = jest.fn();
|
||||||
|
const winResize = jest.fn();
|
||||||
|
|
||||||
|
store.on('document_visibility_change', docVisChange);
|
||||||
|
store.on('window_scroll', winScroll);
|
||||||
|
store.on('window_resize', winResize);
|
||||||
|
|
||||||
|
store.onDocumentVisibilityChange();
|
||||||
|
store.onWindowScroll();
|
||||||
|
store.onWindowResize();
|
||||||
|
|
||||||
|
expect(docVisChange).toHaveBeenCalled();
|
||||||
|
expect(winScroll).toHaveBeenCalled();
|
||||||
|
expect(winResize).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Trigger and validate actions behavior', () => {
|
||||||
|
test('Action type: "INIT_PAGE"', () => {
|
||||||
|
handler({ type: 'INIT_PAGE', page: 'home' });
|
||||||
|
expect(onInit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.get('current-page')).toBe('home');
|
||||||
|
|
||||||
|
handler({ type: 'INIT_PAGE', page: 'about' });
|
||||||
|
expect(onInit).toHaveBeenCalledTimes(2);
|
||||||
|
expect(store.get('current-page')).toBe('about');
|
||||||
|
|
||||||
|
handler({ type: 'INIT_PAGE', page: 'profile' });
|
||||||
|
expect(onInit).toHaveBeenCalledTimes(3);
|
||||||
|
expect(store.get('current-page')).toBe('profile');
|
||||||
|
|
||||||
|
expect(onInit).toHaveBeenCalledWith();
|
||||||
|
|
||||||
|
expect(onToggleAutoPlay).toHaveBeenCalledTimes(0);
|
||||||
|
expect(onAddNotification).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "TOGGLE_AUTO_PLAY"', () => {
|
||||||
|
const browserCacheInstance = (BrowserCache as jest.Mock).mock.results[0].value;
|
||||||
|
const browserCacheSetSpy = browserCacheInstance.set;
|
||||||
|
|
||||||
|
const initialValue = store.get('media-auto-play');
|
||||||
|
|
||||||
|
handler({ type: 'TOGGLE_AUTO_PLAY' });
|
||||||
|
|
||||||
|
expect(onToggleAutoPlay).toHaveBeenCalledWith();
|
||||||
|
expect(onToggleAutoPlay).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(store.get('media-auto-play')).toBe(!initialValue);
|
||||||
|
expect(browserCacheSetSpy).toHaveBeenCalledWith('media-auto-play', !initialValue);
|
||||||
|
|
||||||
|
browserCacheSetSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "ADD_NOTIFICATION"', () => {
|
||||||
|
const notificationMsg1 = 'NOTIFICATION_MSG_1';
|
||||||
|
const notificationMsg2 = 'NOTIFICATION_MSG_2';
|
||||||
|
const invalidNotification = 44;
|
||||||
|
|
||||||
|
// Add notification
|
||||||
|
handler({ type: 'ADD_NOTIFICATION', notification: notificationMsg1 });
|
||||||
|
expect(onAddNotification).toHaveBeenCalledWith();
|
||||||
|
expect(onAddNotification).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.get('notifications-size')).toBe(1);
|
||||||
|
|
||||||
|
const currentNotifications = store.get('notifications');
|
||||||
|
expect(currentNotifications.length).toBe(1);
|
||||||
|
expect(typeof currentNotifications[0][0]).toBe('string');
|
||||||
|
expect(currentNotifications[0][1]).toBe(notificationMsg1);
|
||||||
|
|
||||||
|
expect(store.get('notifications-size')).toBe(0);
|
||||||
|
expect(store.get('notifications')).toStrictEqual([]);
|
||||||
|
|
||||||
|
// Add another notification
|
||||||
|
handler({ type: 'ADD_NOTIFICATION', notification: notificationMsg2 });
|
||||||
|
|
||||||
|
expect(onAddNotification).toHaveBeenCalledWith();
|
||||||
|
expect(onAddNotification).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
expect(store.get('notifications-size')).toBe(1);
|
||||||
|
expect(store.get('notifications')[0][1]).toBe(notificationMsg2);
|
||||||
|
|
||||||
|
expect(store.get('notifications-size')).toBe(0);
|
||||||
|
expect(store.get('notifications')).toStrictEqual([]);
|
||||||
|
|
||||||
|
// Add invalid notification
|
||||||
|
handler({ type: 'ADD_NOTIFICATION', notification: invalidNotification });
|
||||||
|
expect(onAddNotification).toHaveBeenCalledWith();
|
||||||
|
expect(onAddNotification).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
expect(store.get('notifications-size')).toBe(0);
|
||||||
|
expect(store.get('notifications')).toStrictEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import {
|
||||||
|
publishedOnDate,
|
||||||
|
getRequest,
|
||||||
|
postRequest,
|
||||||
|
deleteRequest,
|
||||||
|
csrfToken,
|
||||||
|
} from '../../../src/static/js/utils/helpers';
|
||||||
|
import store from '../../../src/static/js/utils/stores/PlaylistPageStore';
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||||
|
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/helpers', () => ({
|
||||||
|
publishedOnDate: jest.fn(),
|
||||||
|
exportStore: jest.fn((store) => store),
|
||||||
|
getRequest: jest.fn(),
|
||||||
|
postRequest: jest.fn(),
|
||||||
|
deleteRequest: jest.fn(),
|
||||||
|
csrfToken: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('utils/store', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
(globalThis as any).window.MediaCMS = { playlistId: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete (globalThis as any).window.MediaCMS;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PlaylistPageStore', () => {
|
||||||
|
const handler = store.actions_handler.bind(store);
|
||||||
|
|
||||||
|
const onLoadedPlaylistData = jest.fn();
|
||||||
|
const onLoadedPlaylistEerror = jest.fn();
|
||||||
|
const onLoadedMediaError = jest.fn();
|
||||||
|
const onPlaylistUpdateCompleted = jest.fn();
|
||||||
|
const onPlaylistUpdateFailed = jest.fn();
|
||||||
|
const onPlaylistRemovalCompleted = jest.fn();
|
||||||
|
const onPlaylistRemovalFailed = jest.fn();
|
||||||
|
const onSavedUpdated = jest.fn();
|
||||||
|
const onReorderedMediaInPlaylist = jest.fn();
|
||||||
|
const onRemovedMediaFromPlaylist = jest.fn();
|
||||||
|
|
||||||
|
store.on('loaded_playlist_data', onLoadedPlaylistData);
|
||||||
|
store.on('loaded_playlist_error', onLoadedPlaylistEerror);
|
||||||
|
store.on('loaded_media_error', onLoadedMediaError); // @todo: It doesn't get called
|
||||||
|
store.on('playlist_update_completed', onPlaylistUpdateCompleted);
|
||||||
|
store.on('playlist_update_failed', onPlaylistUpdateFailed);
|
||||||
|
store.on('playlist_removal_completed', onPlaylistRemovalCompleted);
|
||||||
|
store.on('playlist_removal_failed', onPlaylistRemovalFailed);
|
||||||
|
store.on('saved-updated', onSavedUpdated);
|
||||||
|
store.on('reordered_media_in_playlist', onReorderedMediaInPlaylist);
|
||||||
|
store.on('removed_media_from_playlist', onRemovedMediaFromPlaylist);
|
||||||
|
|
||||||
|
test('Validate initial values', () => {
|
||||||
|
expect(store.get('INVALID_TYPE')).toBe(null);
|
||||||
|
expect(store.get('playlistId')).toBe(null);
|
||||||
|
expect(store.get('logged-in-user-playlist')).toBe(false);
|
||||||
|
expect(store.get('playlist-media')).toStrictEqual([]);
|
||||||
|
expect(store.get('visibility')).toBe('public');
|
||||||
|
expect(store.get('visibility-icon')).toBe(null);
|
||||||
|
// // expect(store.get('total-items')).toBe(0); // @todo: It throws error
|
||||||
|
expect(store.get('views-count')).toBe('N/A');
|
||||||
|
expect(store.get('title')).toBe(null);
|
||||||
|
expect(store.get('edit-link')).toBe('#');
|
||||||
|
expect(store.get('thumb')).toBe(null);
|
||||||
|
expect(store.get('description')).toBe(null);
|
||||||
|
expect(store.get('author-username')).toBe(null);
|
||||||
|
expect(store.get('author-name')).toBe(null);
|
||||||
|
expect(store.get('author-link')).toBe(null);
|
||||||
|
expect(store.get('author-thumb')).toBe(null);
|
||||||
|
expect(store.get('saved-playlist')).toBe(false);
|
||||||
|
expect(store.get('date-label')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Trigger and validate actions behavior', () => {
|
||||||
|
test('Action type: "LOAD_PLAYLIST_DATA" - failed', () => {
|
||||||
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const loadDataSpy = jest.spyOn(store, 'loadData');
|
||||||
|
|
||||||
|
handler({ type: 'LOAD_PLAYLIST_DATA' });
|
||||||
|
|
||||||
|
expect(loadDataSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(loadDataSpy).toHaveReturnedWith(false);
|
||||||
|
|
||||||
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith('Invalid playlist id:', '');
|
||||||
|
|
||||||
|
expect(store.get('playlistId')).toBe(null);
|
||||||
|
|
||||||
|
loadDataSpy.mockRestore();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "LOAD_PLAYLIST_DATA" - completed successful', () => {
|
||||||
|
const playlistId = 'PLAYLIST_ID_1';
|
||||||
|
window.history.pushState({}, '', `/playlists/${playlistId}`);
|
||||||
|
|
||||||
|
// Mock get request
|
||||||
|
const mockGetRequestResponse = {
|
||||||
|
data: {
|
||||||
|
add_date: Date.now(),
|
||||||
|
description: 'DESCRIPTION',
|
||||||
|
playlist_media: [],
|
||||||
|
title: 'TITLE',
|
||||||
|
user: 'USER',
|
||||||
|
user_thumbnail_url: 'USER_THUMB_URL',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(getRequest as jest.Mock).mockImplementation((_url, _cache, successCallback, _failCallback) =>
|
||||||
|
successCallback(mockGetRequestResponse)
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadDataSpy = jest.spyOn(store, 'loadData');
|
||||||
|
const dataResponseSpy = jest.spyOn(store, 'dataResponse');
|
||||||
|
|
||||||
|
handler({ type: 'LOAD_PLAYLIST_DATA' });
|
||||||
|
|
||||||
|
expect(store.get('playlistId')).toBe(playlistId);
|
||||||
|
expect(store.get('author-name')).toBe(mockGetRequestResponse.data.user);
|
||||||
|
expect(store.get('author-link')).toBe(`/user/${mockGetRequestResponse.data.user}`);
|
||||||
|
expect(store.get('author-thumb')).toBe(`/${mockGetRequestResponse.data.user_thumbnail_url}`);
|
||||||
|
|
||||||
|
expect(store.get('date-label')).toBe('Created on undefined');
|
||||||
|
expect(publishedOnDate).toHaveBeenCalledWith(new Date(mockGetRequestResponse.data.add_date), 3);
|
||||||
|
|
||||||
|
expect(loadDataSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(loadDataSpy).toHaveReturnedWith(undefined);
|
||||||
|
|
||||||
|
expect(dataResponseSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dataResponseSpy).toHaveBeenCalledWith(mockGetRequestResponse);
|
||||||
|
|
||||||
|
// Verify getRequest was called with correct parameters
|
||||||
|
expect(getRequest).toHaveBeenCalledWith(
|
||||||
|
store.playlistAPIUrl,
|
||||||
|
false,
|
||||||
|
store.dataResponse,
|
||||||
|
store.dataErrorResponse
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onLoadedPlaylistData).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onLoadedPlaylistData).toHaveBeenCalledWith();
|
||||||
|
|
||||||
|
loadDataSpy.mockRestore();
|
||||||
|
dataResponseSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "LOAD_PLAYLIST_DATA" - completed with error', () => {
|
||||||
|
const playlistId = 'PLAYLIST_ID_2';
|
||||||
|
window.history.pushState({}, '', `/playlists/${playlistId}`);
|
||||||
|
|
||||||
|
// Mock get request
|
||||||
|
const mockGetRequestResponse = { type: 'private' };
|
||||||
|
(getRequest as jest.Mock).mockImplementation((_url, _cache, _successCallback, failCallback) =>
|
||||||
|
failCallback(mockGetRequestResponse)
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadDataSpy = jest.spyOn(store, 'loadData');
|
||||||
|
const dataErrorResponseSpy = jest.spyOn(store, 'dataErrorResponse');
|
||||||
|
|
||||||
|
handler({ type: 'LOAD_PLAYLIST_DATA' });
|
||||||
|
|
||||||
|
expect(store.get('playlistId')).toBe(playlistId);
|
||||||
|
|
||||||
|
expect(loadDataSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(loadDataSpy).toHaveReturnedWith(undefined);
|
||||||
|
|
||||||
|
expect(dataErrorResponseSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dataErrorResponseSpy).toHaveBeenCalledWith(mockGetRequestResponse);
|
||||||
|
|
||||||
|
// Verify getRequest was called with correct parameters
|
||||||
|
expect(getRequest).toHaveBeenCalledWith(
|
||||||
|
store.playlistAPIUrl,
|
||||||
|
false,
|
||||||
|
store.dataResponse,
|
||||||
|
store.dataErrorResponse
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onLoadedPlaylistEerror).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onLoadedPlaylistEerror).toHaveBeenCalledWith();
|
||||||
|
|
||||||
|
loadDataSpy.mockRestore();
|
||||||
|
dataErrorResponseSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "TOGGLE_SAVE"', () => {
|
||||||
|
const initialValue = store.get('saved-playlist');
|
||||||
|
|
||||||
|
handler({ type: 'TOGGLE_SAVE' });
|
||||||
|
|
||||||
|
expect(onSavedUpdated).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSavedUpdated).toHaveBeenCalledWith();
|
||||||
|
|
||||||
|
expect(store.get('saved-playlist')).toBe(!initialValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "UPDATE_PLAYLIST" - failed', () => {
|
||||||
|
// Mock (updated) playlist data
|
||||||
|
const mockPlaylistData = { title: 'PLAYLIST_TITLE', description: 'PLAYLIST_DESCRIPTION' };
|
||||||
|
|
||||||
|
// Mock the CSRF token
|
||||||
|
const mockCSRFtoken = 'test-csrf-token';
|
||||||
|
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||||
|
|
||||||
|
// Mock post request
|
||||||
|
(postRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _postData, _configData, _sync, _successCallback, failCallback) => failCallback()
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialStoreData = {
|
||||||
|
title: store.get('title'),
|
||||||
|
description: store.get('description'),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(store.get('title')).toBe(initialStoreData.title);
|
||||||
|
expect(store.get('description')).toBe(initialStoreData.description);
|
||||||
|
|
||||||
|
handler({ type: 'UPDATE_PLAYLIST', playlist_data: mockPlaylistData });
|
||||||
|
|
||||||
|
expect(store.get('title')).toBe(initialStoreData.title);
|
||||||
|
expect(store.get('description')).toBe(initialStoreData.description);
|
||||||
|
|
||||||
|
// Verify postRequest was called with correct parameters
|
||||||
|
expect(postRequest).toHaveBeenCalledWith(
|
||||||
|
store.playlistAPIUrl,
|
||||||
|
mockPlaylistData,
|
||||||
|
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||||
|
false,
|
||||||
|
store.onPlaylistUpdateCompleted,
|
||||||
|
store.onPlaylistUpdateFailed
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onPlaylistUpdateFailed).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "UPDATE_PLAYLIST" - successful', () => {
|
||||||
|
// Mock (updated) playlist data
|
||||||
|
const mockPlaylistData = { title: 'PLAYLIST_TITLE', description: 'PLAYLIST_DESCRIPTION' };
|
||||||
|
|
||||||
|
// Mock the CSRF token
|
||||||
|
const mockCSRFtoken = 'test-csrf-token';
|
||||||
|
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||||
|
|
||||||
|
// Mock post request
|
||||||
|
(postRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _postData, _configData, _sync, successCallback, _failCallback) =>
|
||||||
|
successCallback({ data: mockPlaylistData })
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialStoreData = {
|
||||||
|
title: store.get('title'),
|
||||||
|
description: store.get('description'),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(store.get('title')).toBe(initialStoreData.title);
|
||||||
|
expect(store.get('description')).toBe(initialStoreData.description);
|
||||||
|
|
||||||
|
handler({ type: 'UPDATE_PLAYLIST', playlist_data: mockPlaylistData });
|
||||||
|
|
||||||
|
expect(store.get('title')).toBe(mockPlaylistData.title);
|
||||||
|
expect(store.get('description')).toBe(mockPlaylistData.description);
|
||||||
|
|
||||||
|
// Verify postRequest was called with correct parameters
|
||||||
|
expect(postRequest).toHaveBeenCalledWith(
|
||||||
|
store.playlistAPIUrl,
|
||||||
|
mockPlaylistData,
|
||||||
|
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||||
|
false,
|
||||||
|
store.onPlaylistUpdateCompleted,
|
||||||
|
store.onPlaylistUpdateFailed
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onPlaylistUpdateCompleted).toHaveBeenCalledWith(mockPlaylistData);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "REMOVE_PLAYLIST" - failed', () => {
|
||||||
|
// Mock the CSRF token
|
||||||
|
const mockCSRFtoken = 'test-csrf-token';
|
||||||
|
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||||
|
|
||||||
|
// Mock delete request
|
||||||
|
(deleteRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _config, _sync, _successCallback, failCallback) => failCallback()
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({ type: 'REMOVE_PLAYLIST' });
|
||||||
|
|
||||||
|
// Verify deleteRequest was called with correct parameters
|
||||||
|
expect(deleteRequest).toHaveBeenCalledWith(
|
||||||
|
store.playlistAPIUrl,
|
||||||
|
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||||
|
false,
|
||||||
|
store.onPlaylistRemovalCompleted,
|
||||||
|
store.onPlaylistRemovalFailed
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onPlaylistRemovalFailed).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "REMOVE_PLAYLIST" - completed successful', () => {
|
||||||
|
// Mock the CSRF token
|
||||||
|
const mockCSRFtoken = 'test-csrf-token';
|
||||||
|
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||||
|
|
||||||
|
// Mock delete request
|
||||||
|
const deleteRequestResponse = { status: 204 };
|
||||||
|
(deleteRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _config, _sync, successCallback, _failCallback) => successCallback(deleteRequestResponse)
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({ type: 'REMOVE_PLAYLIST' });
|
||||||
|
|
||||||
|
// Verify deleteRequest was called with correct parameters
|
||||||
|
expect(deleteRequest).toHaveBeenCalledWith(
|
||||||
|
store.playlistAPIUrl,
|
||||||
|
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||||
|
false,
|
||||||
|
store.onPlaylistRemovalCompleted,
|
||||||
|
store.onPlaylistRemovalFailed
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onPlaylistRemovalCompleted).toHaveBeenCalledWith(deleteRequestResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "REMOVE_PLAYLIST" - completed with invalid status code', () => {
|
||||||
|
// Mock the CSRF token
|
||||||
|
const mockCSRFtoken = 'test-csrf-token';
|
||||||
|
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||||
|
|
||||||
|
// Mock delete request
|
||||||
|
const deleteRequestResponse = { status: 403 };
|
||||||
|
(deleteRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _config, _sync, successCallback, _failCallback) => successCallback(deleteRequestResponse)
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({ type: 'REMOVE_PLAYLIST' });
|
||||||
|
|
||||||
|
// Verify deleteRequest was called with correct parameters
|
||||||
|
expect(deleteRequest).toHaveBeenCalledWith(
|
||||||
|
store.playlistAPIUrl,
|
||||||
|
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||||
|
false,
|
||||||
|
store.onPlaylistRemovalCompleted,
|
||||||
|
store.onPlaylistRemovalFailed
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onPlaylistRemovalFailed).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "PLAYLIST_MEDIA_REORDERED"', () => {
|
||||||
|
// Mock playlist media data
|
||||||
|
const mockPlaylistMedia = [
|
||||||
|
{ thumbnail_url: 'THUMB_URL_1', url: '?id=MEDIA_ID_1' },
|
||||||
|
{ thumbnail_url: 'THUMB_URL_2', url: '?id=MEDIA_ID_2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
handler({ type: 'PLAYLIST_MEDIA_REORDERED', playlist_media: mockPlaylistMedia });
|
||||||
|
|
||||||
|
expect(onReorderedMediaInPlaylist).toHaveBeenCalledWith();
|
||||||
|
|
||||||
|
expect(store.get('playlist-media')).toStrictEqual(mockPlaylistMedia);
|
||||||
|
expect(store.get('thumb')).toBe(mockPlaylistMedia[0].thumbnail_url);
|
||||||
|
expect(store.get('total-items')).toBe(mockPlaylistMedia.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "MEDIA_REMOVED_FROM_PLAYLIST"', () => {
|
||||||
|
// Mock playlist media data
|
||||||
|
const mockPlaylistMedia = [
|
||||||
|
{ thumbnail_url: 'THUMB_URL_1', url: '?id=MEDIA_ID_1' },
|
||||||
|
{ thumbnail_url: 'THUMB_URL_2', url: '?id=MEDIA_ID_2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
handler({ type: 'PLAYLIST_MEDIA_REORDERED', playlist_media: mockPlaylistMedia });
|
||||||
|
|
||||||
|
handler({ type: 'MEDIA_REMOVED_FROM_PLAYLIST', media_id: 'MEDIA_ID_2' });
|
||||||
|
|
||||||
|
expect(store.get('playlist-media')).toStrictEqual([mockPlaylistMedia[0]]);
|
||||||
|
expect(store.get('thumb')).toBe(mockPlaylistMedia[0].thumbnail_url);
|
||||||
|
expect(store.get('total-items')).toBe(mockPlaylistMedia.length - 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { BrowserCache } from '../../../src/static/js/utils/classes/';
|
||||||
|
import store from '../../../src/static/js/utils/stores/PlaylistViewStore';
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/classes/', () => ({
|
||||||
|
BrowserCache: jest.fn().mockImplementation(() => ({
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||||
|
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/helpers', () => ({
|
||||||
|
BrowserEvents: jest.fn().mockImplementation(() => ({
|
||||||
|
doc: jest.fn(),
|
||||||
|
win: jest.fn(),
|
||||||
|
})),
|
||||||
|
exportStore: jest.fn((store) => store),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('utils/store', () => {
|
||||||
|
describe('PlaylistViewStore', () => {
|
||||||
|
const browserCacheInstance = (BrowserCache as jest.Mock).mock.results[0].value;
|
||||||
|
const browserCacheSetSpy = browserCacheInstance.set;
|
||||||
|
|
||||||
|
const handler = store.actions_handler.bind(store);
|
||||||
|
|
||||||
|
const onLoopRepeatUpdated = jest.fn();
|
||||||
|
const onShuffleUpdated = jest.fn();
|
||||||
|
const onSavedUpdated = jest.fn();
|
||||||
|
|
||||||
|
store.on('loop-repeat-updated', onLoopRepeatUpdated);
|
||||||
|
store.on('shuffle-updated', onShuffleUpdated);
|
||||||
|
store.on('saved-updated', onSavedUpdated);
|
||||||
|
|
||||||
|
test('Validate initial values', () => {
|
||||||
|
expect(store.get('INVALID_TYPE')).toBe(null);
|
||||||
|
expect(store.get('logged-in-user-playlist')).toBe(false);
|
||||||
|
expect(store.get('enabled-loop')).toBe(undefined);
|
||||||
|
expect(store.get('enabled-shuffle')).toBe(undefined);
|
||||||
|
expect(store.get('saved-playlist')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Trigger and validate actions behavior', () => {
|
||||||
|
// @todo: Revisit the behavior of this action
|
||||||
|
test('Action type: "TOGGLE_LOOP"', () => {
|
||||||
|
handler({ type: 'TOGGLE_LOOP' });
|
||||||
|
|
||||||
|
expect(onLoopRepeatUpdated).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onLoopRepeatUpdated).toHaveBeenCalledWith();
|
||||||
|
|
||||||
|
expect(store.get('enabled-loop')).toBe(undefined);
|
||||||
|
|
||||||
|
expect(browserCacheSetSpy).toHaveBeenCalledWith('loopPlaylist[null]', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// @todo: Revisit the behavior of this action
|
||||||
|
test('Action type: "TOGGLE_SHUFFLE"', () => {
|
||||||
|
handler({ type: 'TOGGLE_SHUFFLE' });
|
||||||
|
|
||||||
|
expect(onShuffleUpdated).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onShuffleUpdated).toHaveBeenCalledWith();
|
||||||
|
|
||||||
|
expect(store.get('enabled-shuffle')).toBe(undefined);
|
||||||
|
|
||||||
|
expect(browserCacheSetSpy).toHaveBeenCalledWith('shufflePlaylist[null]', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "TOGGLE_SAVE"', () => {
|
||||||
|
const initialValue = store.get('saved-playlist');
|
||||||
|
|
||||||
|
handler({ type: 'TOGGLE_SAVE' });
|
||||||
|
|
||||||
|
expect(onSavedUpdated).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSavedUpdated).toHaveBeenCalledWith();
|
||||||
|
|
||||||
|
expect(store.get('saved-playlist')).toBe(!initialValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { getRequest, deleteRequest, csrfToken } from '../../../src/static/js/utils/helpers';
|
||||||
|
import store from '../../../src/static/js/utils/stores/ProfilePageStore';
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||||
|
config: jest.fn(() => ({
|
||||||
|
...jest.requireActual('../../tests-constants').sampleMediaCMSConfig,
|
||||||
|
api: { ...jest.requireActual('../../tests-constants').sampleMediaCMSConfig.api, users: '' },
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/helpers', () => ({
|
||||||
|
getRequest: jest.fn(),
|
||||||
|
deleteRequest: jest.fn(),
|
||||||
|
csrfToken: jest.fn(),
|
||||||
|
exportStore: jest.fn((store) => store),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('utils/store', () => {
|
||||||
|
const mockAuthorData = { username: 'testuser', name: 'Test User' };
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
(globalThis as any).window.MediaCMS = { profileId: mockAuthorData.username };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete (globalThis as any).window.MediaCMS;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ProfilePageStore', () => {
|
||||||
|
const handler = store.actions_handler.bind(store);
|
||||||
|
|
||||||
|
const onProfileDelete = jest.fn();
|
||||||
|
const onProfileDeleteFail = jest.fn();
|
||||||
|
const onLoadAuthorData = jest.fn();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
store.on('profile_delete', onProfileDelete);
|
||||||
|
store.on('profile_delete_fail', onProfileDeleteFail);
|
||||||
|
store.on('load-author-data', onLoadAuthorData);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset store state
|
||||||
|
store.authorData = null;
|
||||||
|
store.removingProfile = false;
|
||||||
|
store.authorQuery = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Trigger and validate actions behavior', () => {
|
||||||
|
test('Action type: "REMOVE_PROFILE" - successful deletion', async () => {
|
||||||
|
// Set up author data
|
||||||
|
store.authorData = mockAuthorData;
|
||||||
|
|
||||||
|
// Mock the CSRF token
|
||||||
|
const mockCSRFtoken = 'test-csrf-token';
|
||||||
|
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||||
|
|
||||||
|
// Mock delete request
|
||||||
|
(deleteRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _config, _sync, successCallback, _failCallback) => successCallback({ status: 204 })
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({ type: 'REMOVE_PROFILE' });
|
||||||
|
|
||||||
|
// Verify deleteRequest was called with correct parameters
|
||||||
|
expect(deleteRequest).toHaveBeenCalledWith(
|
||||||
|
'/testuser', // API URL constructed from config + username
|
||||||
|
{ headers: { 'X-CSRFToken': mockCSRFtoken } },
|
||||||
|
false,
|
||||||
|
store.removeProfileResponse,
|
||||||
|
store.removeProfileFail
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify event was emitted
|
||||||
|
expect(onProfileDelete).toHaveBeenCalledWith(mockAuthorData.username);
|
||||||
|
expect(onProfileDelete).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "REMOVE_PROFILE" - deletion failure', async () => {
|
||||||
|
// Set up author data
|
||||||
|
store.authorData = mockAuthorData;
|
||||||
|
|
||||||
|
// Mock the CSRF token
|
||||||
|
const mockCSRFtoken = 'test-csrf-token';
|
||||||
|
(csrfToken as jest.Mock).mockReturnValue(mockCSRFtoken);
|
||||||
|
|
||||||
|
// Mock delete request
|
||||||
|
(deleteRequest as jest.Mock).mockImplementation(
|
||||||
|
(_url, _config, _sync, _successCallback, failCallback) => failCallback.call(store)
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({ type: 'REMOVE_PROFILE' });
|
||||||
|
|
||||||
|
// Wait for the setTimeout in removeProfileFail
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
// Verify event was emitted
|
||||||
|
expect(onProfileDeleteFail).toHaveBeenCalledWith(mockAuthorData.username);
|
||||||
|
expect(onProfileDeleteFail).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "REMOVE_PROFILE" - prevents duplicate calls while removing', () => {
|
||||||
|
// Set up author data
|
||||||
|
store.authorData = mockAuthorData;
|
||||||
|
|
||||||
|
handler({ type: 'REMOVE_PROFILE' });
|
||||||
|
expect(deleteRequest).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
store.removingProfile = true;
|
||||||
|
handler({ type: 'REMOVE_PROFILE' });
|
||||||
|
expect(deleteRequest).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
store.removingProfile = false;
|
||||||
|
handler({ type: 'REMOVE_PROFILE' });
|
||||||
|
expect(deleteRequest).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "LOAD_AUTHOR_DATA"', async () => {
|
||||||
|
(getRequest as jest.Mock).mockImplementation((_url, _cache, successCallback, _failCallback) =>
|
||||||
|
successCallback({ data: mockAuthorData })
|
||||||
|
);
|
||||||
|
|
||||||
|
handler({ type: 'LOAD_AUTHOR_DATA' });
|
||||||
|
|
||||||
|
// Verify getRequest was called with correct parameters
|
||||||
|
expect(getRequest).toHaveBeenCalledWith('/testuser', false, store.onDataLoad, store.onDataLoadFail);
|
||||||
|
|
||||||
|
// Verify event was emitted
|
||||||
|
expect(onLoadAuthorData).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Verify author data was processed correctly
|
||||||
|
expect(store.get('author-data')).toStrictEqual(mockAuthorData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Getter methods', () => {
|
||||||
|
test('Validate initial values', () => {
|
||||||
|
expect(store.get('INVALID_TYPE')).toBe(undefined);
|
||||||
|
expect(store.get('author-data')).toBe(null);
|
||||||
|
expect(store.get('author-query')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get("author-data") returns authorData', () => {
|
||||||
|
store.authorData = mockAuthorData;
|
||||||
|
expect(store.get('author-data')).toBe(mockAuthorData);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get("author-query") - without "aq" parameter in URL', () => {
|
||||||
|
window.history.pushState({}, '', '/path');
|
||||||
|
expect(store.get('author-query')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get("author-query") - with "aq" parameter in URL', () => {
|
||||||
|
window.history.pushState({}, '', '/path?aq=AUTHOR_QUERY');
|
||||||
|
expect(store.get('author-query')).toBe('AUTHOR_QUERY');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get("author-query") - empty search string', () => {
|
||||||
|
window.history.pushState({}, '', '/path?aq');
|
||||||
|
expect(store.get('author-query')).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
const urlParams = { q: 'search_query', c: 'category_1', t: 'tag_1' };
|
||||||
|
window.history.pushState({}, '', `/?q=${urlParams.q}&c=${urlParams.c}&t=${urlParams.t}`);
|
||||||
|
|
||||||
|
import store from '../../../src/static/js/utils/stores/SearchFieldStore';
|
||||||
|
import { getRequest } from '../../../src/static/js/utils/helpers';
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||||
|
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/helpers', () => ({
|
||||||
|
exportStore: jest.fn((store) => store),
|
||||||
|
getRequest: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('utils/store', () => {
|
||||||
|
afterAll(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SearchFieldStore', () => {
|
||||||
|
const handler = store.actions_handler.bind(store);
|
||||||
|
|
||||||
|
const onLoadPredictions = jest.fn();
|
||||||
|
|
||||||
|
store.on('load_predictions', onLoadPredictions);
|
||||||
|
|
||||||
|
test('Validate initial values based on URL params', async () => {
|
||||||
|
expect(store.get('INVALID_TYPE')).toBe(null);
|
||||||
|
expect(store.get('search-query')).toBe(urlParams.q);
|
||||||
|
expect(store.get('search-categories')).toBe(urlParams.c);
|
||||||
|
expect(store.get('search-tags')).toBe(urlParams.t);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "Action type: "TOGGLE_VIEWER_MODE"', async () => {
|
||||||
|
const predictionsQuery_1 = 'predictions_query_1';
|
||||||
|
const predictionsQuery_2 = 'predictions_query_2';
|
||||||
|
|
||||||
|
const response_1 = { data: [{ title: 'Prediction 1' }, { title: 'Prediction 2' }] };
|
||||||
|
const response_2 = { data: [{ title: 'Prediction 3' }, { title: 'Prediction 4' }] };
|
||||||
|
|
||||||
|
(getRequest as jest.Mock)
|
||||||
|
.mockImplementationOnce((_url, _cache, successCallback, _failCallback) => successCallback(response_1))
|
||||||
|
.mockImplementationOnce((_url, _cache, successCallback, _failCallback) => successCallback(response_2));
|
||||||
|
|
||||||
|
handler({ type: 'REQUEST_PREDICTIONS', query: predictionsQuery_1 });
|
||||||
|
handler({ type: 'REQUEST_PREDICTIONS', query: predictionsQuery_2 });
|
||||||
|
|
||||||
|
expect(onLoadPredictions).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
expect(onLoadPredictions).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
predictionsQuery_1,
|
||||||
|
response_1.data.map(({ title }) => title)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onLoadPredictions).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
predictionsQuery_2,
|
||||||
|
response_2.data.map(({ title }) => title)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { BrowserCache } from '../../../src/static/js/utils/classes/';
|
||||||
|
import store from '../../../src/static/js/utils/stores/VideoViewerStore';
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/classes/', () => ({
|
||||||
|
BrowserCache: jest.fn().mockImplementation(() => ({
|
||||||
|
get: (key: string) => {
|
||||||
|
let result: any = undefined;
|
||||||
|
switch (key) {
|
||||||
|
case 'player-volume':
|
||||||
|
result = 0.6;
|
||||||
|
break;
|
||||||
|
case 'player-sound-muted':
|
||||||
|
result = false;
|
||||||
|
break;
|
||||||
|
case 'in-theater-mode':
|
||||||
|
result = true;
|
||||||
|
break;
|
||||||
|
case 'video-quality':
|
||||||
|
result = 720;
|
||||||
|
break;
|
||||||
|
case 'video-playback-speed':
|
||||||
|
result = 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
set: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/settings/config', () => ({
|
||||||
|
config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../../src/static/js/utils/helpers', () => ({
|
||||||
|
BrowserEvents: jest.fn().mockImplementation(() => ({
|
||||||
|
doc: jest.fn(),
|
||||||
|
win: jest.fn(),
|
||||||
|
})),
|
||||||
|
exportStore: jest.fn((store) => store),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('utils/store', () => {
|
||||||
|
describe('VideoViewerStore', () => {
|
||||||
|
const browserCacheInstance = (BrowserCache as jest.Mock).mock.results[0].value;
|
||||||
|
const browserCacheSetSpy = browserCacheInstance.set;
|
||||||
|
|
||||||
|
const handler = store.actions_handler.bind(store);
|
||||||
|
|
||||||
|
const onChangedViewerMode = jest.fn();
|
||||||
|
const onChangedPlayerVolume = jest.fn();
|
||||||
|
const onChangedPlayerSoundMuted = jest.fn();
|
||||||
|
const onChangedVideoQuality = jest.fn();
|
||||||
|
const onChangedVideoPlaybackSpeed = jest.fn();
|
||||||
|
|
||||||
|
store.on('changed_viewer_mode', onChangedViewerMode);
|
||||||
|
store.on('changed_player_volume', onChangedPlayerVolume);
|
||||||
|
store.on('changed_player_sound_muted', onChangedPlayerSoundMuted);
|
||||||
|
store.on('changed_video_quality', onChangedVideoQuality);
|
||||||
|
store.on('changed_video_playback_speed', onChangedVideoPlaybackSpeed);
|
||||||
|
|
||||||
|
test('Validate initial values', () => {
|
||||||
|
expect(store.get('player-volume')).toBe(0.6);
|
||||||
|
expect(store.get('player-sound-muted')).toBe(false);
|
||||||
|
expect(store.get('in-theater-mode')).toBe(true);
|
||||||
|
expect(store.get('video-data')).toBe(undefined); // @todo: Revisit this behavior
|
||||||
|
expect(store.get('video-quality')).toBe(720);
|
||||||
|
expect(store.get('video-playback-speed')).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Trigger and validate actions behavior', () => {
|
||||||
|
test('Action type: "TOGGLE_VIEWER_MODE"', () => {
|
||||||
|
const initialValue = store.get('in-theater-mode');
|
||||||
|
|
||||||
|
handler({ type: 'TOGGLE_VIEWER_MODE' });
|
||||||
|
|
||||||
|
expect(onChangedViewerMode).toHaveBeenCalledWith();
|
||||||
|
expect(onChangedViewerMode).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(store.get('in-theater-mode')).toBe(!initialValue);
|
||||||
|
expect(browserCacheSetSpy).toHaveBeenCalledWith('in-theater-mode', !initialValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "SET_VIEWER_MODE"', () => {
|
||||||
|
const initialValue = store.get('in-theater-mode');
|
||||||
|
const newValue = !initialValue;
|
||||||
|
|
||||||
|
handler({ type: 'SET_VIEWER_MODE', inTheaterMode: newValue });
|
||||||
|
|
||||||
|
expect(onChangedViewerMode).toHaveBeenCalledWith();
|
||||||
|
expect(onChangedViewerMode).toHaveBeenCalledTimes(2); // The first time called by 'TOGGLE_VIEWER_MODE' action.
|
||||||
|
|
||||||
|
expect(store.get('in-theater-mode')).toBe(newValue);
|
||||||
|
expect(browserCacheSetSpy).toHaveBeenCalledWith('in-theater-mode', newValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "SET_PLAYER_VOLUME"', () => {
|
||||||
|
const newValue = 0.3;
|
||||||
|
|
||||||
|
handler({ type: 'SET_PLAYER_VOLUME', playerVolume: newValue });
|
||||||
|
|
||||||
|
expect(onChangedPlayerVolume).toHaveBeenCalledWith();
|
||||||
|
expect(onChangedPlayerVolume).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(store.get('player-volume')).toBe(newValue);
|
||||||
|
expect(browserCacheSetSpy).toHaveBeenCalledWith('player-volume', newValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "SET_PLAYER_SOUND_MUTED"', () => {
|
||||||
|
const initialValue = store.get('player-sound-muted');
|
||||||
|
const newValue = !initialValue;
|
||||||
|
|
||||||
|
handler({ type: 'SET_PLAYER_SOUND_MUTED', playerSoundMuted: newValue });
|
||||||
|
|
||||||
|
expect(onChangedPlayerSoundMuted).toHaveBeenCalledWith();
|
||||||
|
expect(onChangedPlayerSoundMuted).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(store.get('player-sound-muted')).toBe(newValue);
|
||||||
|
expect(browserCacheSetSpy).toHaveBeenCalledWith('player-sound-muted', newValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "SET_VIDEO_QUALITY"', () => {
|
||||||
|
const newValue = 1080;
|
||||||
|
|
||||||
|
handler({ type: 'SET_VIDEO_QUALITY', quality: newValue });
|
||||||
|
|
||||||
|
expect(onChangedVideoQuality).toHaveBeenCalledWith();
|
||||||
|
expect(onChangedVideoQuality).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(store.get('video-quality')).toBe(newValue);
|
||||||
|
expect(browserCacheSetSpy).toHaveBeenCalledWith('video-quality', newValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Action type: "SET_VIDEO_PLAYBACK_SPEED"', () => {
|
||||||
|
const newValue = 1.5;
|
||||||
|
|
||||||
|
handler({ type: 'SET_VIDEO_PLAYBACK_SPEED', playbackSpeed: newValue });
|
||||||
|
|
||||||
|
expect(onChangedVideoPlaybackSpeed).toHaveBeenCalledWith();
|
||||||
|
expect(onChangedVideoPlaybackSpeed).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(store.get('video-playback-speed')).toBe(newValue);
|
||||||
|
expect(browserCacheSetSpy).toHaveBeenCalledWith('video-playback-speed', newValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,7 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
from .keys import ensure_keys_exist
|
|
||||||
|
|
||||||
|
|
||||||
class LtiConfig(AppConfig):
|
class LtiConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'lti'
|
name = 'lti'
|
||||||
verbose_name = 'LTI 1.3 Integration'
|
verbose_name = 'LTI 1.3 Integration'
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
"""Initialize LTI app - ensure keys exist"""
|
|
||||||
try:
|
|
||||||
ensure_keys_exist()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|||||||
@@ -21,10 +21,3 @@ def get_jwks():
|
|||||||
"""
|
"""
|
||||||
public_key = load_public_key()
|
public_key = load_public_key()
|
||||||
return {'keys': [public_key]}
|
return {'keys': [public_key]}
|
||||||
|
|
||||||
|
|
||||||
def ensure_keys_exist():
|
|
||||||
"""Ensure key pair exists in database, generate if not"""
|
|
||||||
from .models import LTIToolKeys
|
|
||||||
|
|
||||||
LTIToolKeys.get_or_create_keys()
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mediacms",
|
"name": "mediacms",
|
||||||
"version": "8.0.3",
|
"version": "8.1.3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@semantic-release/changelog": "^6.0.3",
|
"@semantic-release/changelog": "^6.0.3",
|
||||||
"@semantic-release/git": "^10.0.1",
|
"@semantic-release/git": "^10.0.1",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
@@ -22,7 +23,10 @@ class SAMLAccountAdapter(DefaultSocialAccountAdapter):
|
|||||||
|
|
||||||
def populate_user(self, request, sociallogin, data):
|
def populate_user(self, request, sociallogin, data):
|
||||||
user = sociallogin.user
|
user = sociallogin.user
|
||||||
user.username = sociallogin.account.uid
|
raw_uid = sociallogin.account.uid or ""
|
||||||
|
# Match the user URL pattern in users/urls.py: only [\w@._-] is reverse-able.
|
||||||
|
sanitized = re.sub(r"[^\w.@-]", "_", raw_uid, flags=re.ASCII)
|
||||||
|
user.username = sanitized[:150] if sanitized else raw_uid
|
||||||
for item in ["name", "first_name", "last_name"]:
|
for item in ["name", "first_name", "last_name"]:
|
||||||
if data.get(item):
|
if data.get(item):
|
||||||
setattr(user, item, data[item])
|
setattr(user, item, data[item])
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
exclude = .git,*migrations*
|
exclude = .git,*migrations*
|
||||||
max-line-length = 119
|
max-line-length = 119
|
||||||
#ignore=F401,F403,E501,W503
|
#ignore=F401,F403,E501,W503
|
||||||
ignore=E501
|
ignore=E501,E203
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -34,7 +34,6 @@ class TestImports(TestCase):
|
|||||||
from files.views import CommentDetail # noqa: F401
|
from files.views import CommentDetail # noqa: F401
|
||||||
from files.views import CommentList # noqa: F401
|
from files.views import CommentList # noqa: F401
|
||||||
from files.views import EncodeProfileList # noqa: F401
|
from files.views import EncodeProfileList # noqa: F401
|
||||||
from files.views import EncodingDetail # noqa: F401
|
|
||||||
from files.views import MediaActions # noqa: F401
|
from files.views import MediaActions # noqa: F401
|
||||||
from files.views import MediaBulkUserActions # noqa: F401
|
from files.views import MediaBulkUserActions # noqa: F401
|
||||||
from files.views import MediaDetail # noqa: F401
|
from files.views import MediaDetail # noqa: F401
|
||||||
|
|||||||
+2
-2
@@ -7,8 +7,8 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
@deconstructible
|
@deconstructible
|
||||||
class ASCIIUsernameValidator(validators.RegexValidator):
|
class ASCIIUsernameValidator(validators.RegexValidator):
|
||||||
regex = r"^[\w.@]+$"
|
regex = r"^[\w.@-]+$"
|
||||||
message = _("Enter a valid username. This value may contain only " "English letters and numbers")
|
message = _("Enter a valid username. This value may contain only English letters, numbers, and '_', '.', '@', '-'.")
|
||||||
flags = re.ASCII
|
flags = re.ASCII
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
from allauth.account.adapter import get_adapter
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.contrib.auth.password_validation import validate_password
|
||||||
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
from django.core.mail import EmailMessage
|
from django.core.mail import EmailMessage
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
@@ -275,6 +278,11 @@ class UserList(APIView):
|
|||||||
if not all([username, password, email, name]):
|
if not all([username, password, email, name]):
|
||||||
return Response({"detail": "username, password, email, and name are required."}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": "username, password, email, and name are required."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
try:
|
||||||
|
username = get_adapter().clean_username(username, shallow=True)
|
||||||
|
except DjangoValidationError as e:
|
||||||
|
return Response({"detail": e.messages[0]}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
if User.objects.filter(username=username).exists():
|
if User.objects.filter(username=username).exists():
|
||||||
return Response({"detail": "A user with that username already exists."}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": "A user with that username already exists."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
@@ -369,9 +377,15 @@ class UserDetail(APIView):
|
|||||||
|
|
||||||
if action == "change_password":
|
if action == "change_password":
|
||||||
# Permission to edit user is already checked by self.get_user -> self.check_object_permissions
|
# Permission to edit user is already checked by self.get_user -> self.check_object_permissions
|
||||||
|
if user.is_superuser and not request.user.is_superuser:
|
||||||
|
raise PermissionDenied("You do not have permission to change a superuser's password.")
|
||||||
password = request.data.get("password")
|
password = request.data.get("password")
|
||||||
if not password:
|
if not password:
|
||||||
return Response({"detail": "Password is required"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": "Password is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
try:
|
||||||
|
validate_password(password, user=user)
|
||||||
|
except DjangoValidationError as exc:
|
||||||
|
return Response({"detail": list(exc.messages)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
user.set_password(password)
|
user.set_password(password)
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user