mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/nagelm/1441
This commit is contained in:
commit
9ee5ba2535
57 changed files with 9166 additions and 1569 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -17,6 +17,7 @@ celerybeat-schedule*
|
|||
dump.rdb
|
||||
debugpy*
|
||||
uwsgi.sock
|
||||
.uwsgi-reload
|
||||
package-lock.json
|
||||
models
|
||||
.idea
|
||||
|
|
|
|||
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -10,22 +10,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Added
|
||||
|
||||
- **Native XZ decompression for EPG sources and uploaded M3U playlists.** `.xz`-compressed XMLTV and M3U files are now decompressed using Python's stdlib `lzma` module. EPG ingestion detects XZ via magic bytes, file extension (including compound names like `.xml.xz`), or MIME type; uploaded M3U `.xz` playlists stream line-by-line like `.gz`. The `/data/epgs` auto-import scanner now includes `.xz` files alongside `.xml`, `.gz`, and `.zip`. (Closes #1414) — Thanks [@MotWakorb](https://github.com/MotWakorb)
|
||||
- **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{stream_id}/{timestamp}/{duration}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to clients such as iPlayTV (Apple TV) and TiviMate. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on the Stats page in dedicated connection cards (separate from live and VOD) and respect per-channel access rules. (Closes #133) — Thanks [@cedric-marcoux](https://github.com/cedric-marcoux)
|
||||
- **REST catch-up API for native apps.** `POST /api/catchup/sessions/` (JWT or API key) mints a server-side playback session and returns a tokenless `playback_url` at `/proxy/catchup/<channel_uuid>?session_id=...` for headerless video players. `DELETE /api/catchup/sessions/<session_id>/` ends the session. OpenAPI docs cover handshake and idle TTL behaviour.
|
||||
- **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{duration}/{timestamp}/{channel_id}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to IPTV clients. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on the Stats page in dedicated connection cards (separate from live and VOD) and respect per-channel access rules. (Closes #133) — Thanks [@cedric-marcoux](https://github.com/cedric-marcoux)
|
||||
- **Query-style catch-up compatibility.** Accepts `/streaming/timeshift.php` query-string catch-up requests in addition to the path-style endpoint. Path and query requests now use client-supplied duration hints when present, add a short provider-lag buffer, and fall back to EPG duration, then 120 minutes. Thanks [@dillardblom](https://github.com/dillardblom)
|
||||
- **REST catch-up API for native apps.** `POST /api/catchup/sessions/` (JWT or API key) mints a server-side playback session and returns a tokenless `playback_url` at `/proxy/catchup/<channel_uuid>?session_id=...` for headerless video players. Sessions accept an optional programme `duration` in minutes, using the same buffered duration handling as XC clients. `POST /api/catchup/sessions/<session_id>/position/` lets native apps report local playhead / pause state for accurate admin stats without seeking the provider. `DELETE /api/catchup/sessions/<session_id>/` ends the session. OpenAPI docs cover handshake and idle TTL behaviour.
|
||||
- **Catch-up admin APIs.** `GET /proxy/catchup/stats/` lists active catch-up viewers; `POST /proxy/catchup/programs/` returns batch EPG metadata for those sessions; `POST /proxy/catchup/stop_client/` stops a viewer from the admin UI.
|
||||
- **Catch-up Stats UI.** Dedicated catch-up connection cards show channel logo, programme preview (watched/remaining timeline), playback position, bitrate, and a stop button. Stats refresh in real time via WebSocket (`timeshift_stats`) with desktop notifications when catch-up sessions start or end.
|
||||
- **Catch-up Stats UI.** Dedicated catch-up connection cards show channel logo, programme preview (watched/remaining timeline), playback position, bitrate, and a stop button. When playback continues past the original programme into the catch-up buffer, the card advances to the next EPG programme. Stats refresh in real time via WebSocket (`timeshift_stats`) with desktop notifications when catch-up sessions start or end.
|
||||
- **Catch-up flags** (`tv_archive`, `tv_archive_duration`) denormalized at import time for zero-cost output queries; end-of-refresh SQL rollup updates channels linked to the refreshed account and self-heals stale flags on those channels only (e.g. after catch-up streams are removed or an account is deactivated).
|
||||
- **Strictly-UTC API surface, automatic per-provider timezone.** `server_info.timezone` is always `UTC` and the XC EPG `start`/`end` strings are emitted in UTC; the proxy converts the requested instant to the serving provider's own reported timezone (`server_info.timezone` captured on account refresh) at request time. No timezone to configure.
|
||||
- **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams.
|
||||
- **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams. Attempts prefer streams whose `catchup_days` cover the requested programme age, then fall back to shorter archives in channel order.
|
||||
- **Provider pool accounting.** Catch-up reserves a provider profile slot (`connection_pool`) before connecting upstream — same contract as live and VOD — and releases it when the session ends. When the default profile is at capacity it walks the account's alternate profiles with their own resolved credentials, and answers `503` when every profile is full.
|
||||
- **Per-client session pool.** The first request without `session_id` and no fingerprint match receives a `301` redirect to the same URL with a stable `?session_id=` query parameter; that ID becomes the client identity for stats, stop keys, and pool coordination. Reconnects that omit `session_id` but match an existing pool entry (same user, IP, and user-agent) are served immediately without a redirect, matching IPTV client fast-forward behaviour. Each viewer gets their own Redis-backed pool entry even when watching the same programme; idle sessions can be reclaimed via fingerprint matching. Plain GET restarts stream from byte 0 with upstream `Content-Length` when known (provider-faithful). Real scrubs within a session stop the in-flight stream through the standard stop-key mechanism; parallel startup probes (full-file, open-ended, and EOF tail `Range` requests) are deferred with `503` instead of opening extra upstream connections. Parallel HTTP probes from the same `session_id` for the same programme do not count as extra streams toward the user limit; distinct sessions still each consume a slot.
|
||||
- **`xmltv_prev_days_override` under `Settings → EPG`** (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level.
|
||||
- **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via setting or `?prev_days=` URL parameter.
|
||||
- Verbose timeshift logging follows the standard logger DEBUG level.
|
||||
- **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via per-user `epg_prev_days` or `?prev_days=` URL parameter.
|
||||
- **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer.
|
||||
- **Catch-up indicators in Channels and Streams tables.** Channels and streams with catch-up enabled show a grey history icon beside the name (tooltip includes archive days when known). Expanded channel stream rows show a catch-up badge. Channel and stream API serializers expose `is_catchup` and `catchup_days` for the UI. The Channels and Streams table filter menus include **Only Catch-up** to narrow each list to catch-up entries.
|
||||
- **Combined connection stats API.** `GET /proxy/stats/` (admin) returns live, VOD, and catch-up connection stats in one JSON response (`live`, `vod`, `catchup`, `timestamp`). The Stats page polls this endpoint instead of separate live and VOD stats requests.
|
||||
- **Frontend unit tests extended to table components.** `ChannelsTable`, `StreamsTable`, `M3UsTable`, `EPGsTable`, `LogosTable`, `CustomTable`, and related header/editable subcomponents now have Vitest + Testing Library suites. Table business logic was extracted into `frontend/src/utils/tables/*` (with shared `M3uTableUtils` for M3U/EPG sort headers) and covered by dedicated util tests. `dateTimeUtils.formatDuration` gained a `human` precision mode for readable content lengths. — Thanks [@nick4810](https://github.com/nick4810)
|
||||
- **Frontend unit tests extended to plugin, backup, and settings components.** `AboutModal`, `PluginDetailPanel`, `BackupManager`, `AvailablePluginCard`, `ConnectionSecurityPanel`, and `UserLimitsForm` were refactored with business logic moved into `frontend/src/utils/components/*` and `PluginsUtils`; `pluginUtils` (version comparison, compatibility labels, install/downgrade detection) now lives under `utils/components/`. Vitest + Testing Library suites were added for those components plus `PluginWarnings` and `EpgSettingsForm`. — Thanks [@nick4810](https://github.com/nick4810)
|
||||
- **Frontend unit tests extended to plugin, backup, and settings components.** `AboutModal`, `PluginDetailPanel`, `BackupManager`, `AvailablePluginCard`, `ConnectionSecurityPanel`, and `UserLimitsForm` were refactored with business logic moved into `frontend/src/utils/components/*` and `PluginsUtils`; `pluginUtils` (version comparison, compatibility labels, install/downgrade detection) now lives under `utils/components/`. Vitest + Testing Library suites were added for those components plus `PluginWarnings`. — Thanks [@nick4810](https://github.com/nick4810)
|
||||
- **Frontend unit tests extended to forms, modals, Connect, and Plugin Browse.** `OutputProfile`, `ServerGroup`, `CreateChannelModal`, `ProfileModal`, `Connect`, `ConnectLogs`, `PluginBrowse`, and `useEpgPreview` now have Vitest + Testing Library suites. Repo-management UI was extracted from `PluginBrowse` into `ManageReposModal`; form/schema helpers moved into `OutputProfileUtils` and `ServerGroupUtils`, with plugin-repo settings helpers added to `PluginsUtils`. — Thanks [@nick4810](https://github.com/nick4810)
|
||||
|
||||
### Changed
|
||||
|
||||
|
|
@ -42,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Fixed
|
||||
|
||||
- **Swagger/OpenAPI schema generation no longer fails under concurrent gevent requests.** Concurrent hits to `/api/schema/` (for example Swagger UI loading) could race on DRF's shared `AutoSchema` state and raise `AssertionError: Schema generation REQUIRES a view instance`, leaving Swagger empty. Cold builds could also hang the gevent uWSGI worker. Schema introspection now runs in a real OS thread, builds are single-flight per process, and the result is cached in Django's cache (Redis) so all workers share it.
|
||||
- **`/proxy/ts/change_stream` now persists `stream_id` and waits for the real switch outcome in multi-worker deployments.** When the request landed on a worker that didn't own the channel, the owning worker's `STREAM_SWITCH` pubsub handler performed the switch but wrote only `url`/`user_agent` back to the channel's Redis metadata, so `GET /proxy/ts/status` kept reporting the old `stream_id` indefinitely; the handler now persists the full switch metadata (`stream_id`, `m3u_profile`, stream name) via the same `_update_channel_metadata()` helper the direct owner path uses. `stream_name` from the initial `get_stream_info_for_switch()` lookup is now forwarded through the pubsub payload (and from `change_stream` / `next_stream` on the direct owner path) so the owner no longer re-queries the database when writing metadata. The endpoint also no longer returns an optimistic 200 on the non-owner path: the requesting worker waits (up to 15s) for the owner to confirm via the `switch_status` Redis key and answers 502 if the owner reports failure or 504 if no confirmation arrives (e.g. owner worker gone); `next_stream` gets the same treatment. Requesting a switch to the URL the channel is already playing is now treated as success (with a metadata refresh) instead of a silent no-op, and the `switch_status` key now expires (60s TTL) instead of persisting forever. (Fixes #1412)
|
||||
- **XC live streams, `/panel_api.php`, and `/xmltv.php` no longer crash when a visible channel has no channel number.** Since `channel_number` became nullable, auto-sync and compact numbering can leave a channel with `effective_channel_number=None` while it remains visible in output (`hidden_from_output=False`). The XC live-stream number map skipped those rows but still tried to emit them, causing `KeyError` on `get_live_streams` and a 500 on `/panel_api.php` (`xc_get_info`). Authenticated XMLTV export (`/xmltv.php`, profile EPG with a logged-in user) had the same gap and could fail chunk-cache rebuilds with `KeyError` on `channel_num_map[channel.id]`. Null-number channels are now deferred into the existing collision-free integer assignment pass (same second loop as fractional numbers) and receive the next available integer; `_xc_channel_entry` also falls back to `channel.id` if a row is still unmapped. HDHR lineup behaviour is unchanged (null-number channels remain omitted there).
|
||||
- **System events and Connect dispatch no longer close the DB connection mid-call outside worker contexts.** `log_system_event()` and `dispatch_event_system()` always called `close_old_connections()` in `finally` blocks, which broke Django `TestCase` transactions and could interrupt in-flight ORM work when Connect/plugin handlers ran synchronously. Closes are now limited to gevent uWSGI workers (and the Celery sync-dispatch path where gevent is patched but no hub runs). Celery workers still reset connections via existing `task_postrun` / `task_prerun` hooks on the standard PostgreSQL backend (no geventpool).
|
||||
|
|
|
|||
200
apps/api/schema_views.py
Normal file
200
apps/api/schema_views.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"""OpenAPI schema views that are safe under gevent concurrency.
|
||||
|
||||
DRF's AutoSchema descriptor keeps mutable per-class state (``self.view``).
|
||||
When uWSGI serves requests with gevent, concurrent ``/api/schema/`` builds
|
||||
interleave on that state and raise:
|
||||
|
||||
AssertionError: Schema generation REQUIRES a view instance
|
||||
|
||||
Cold builds also frequently hang the gevent hub when introspection runs in a
|
||||
greenlet. Generation therefore runs in a real OS thread (gevent ThreadPool),
|
||||
with per-process single-flight coordination and a Django/Redis cache shared
|
||||
by all workers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import threading
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from rest_framework.response import Response
|
||||
|
||||
from drf_spectacular.views import SpectacularAPIView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
# Real OS threads; .get() waits without blocking the gevent hub.
|
||||
from gevent import monkey
|
||||
from gevent.threadpool import ThreadPool
|
||||
|
||||
_SCHEMA_POOL: Optional[ThreadPool] = ThreadPool(maxsize=1)
|
||||
_GEVENT_PATCHED = monkey.is_module_patched("threading")
|
||||
except ImportError: # pragma: no cover - non-gevent runtimes
|
||||
_SCHEMA_POOL = None
|
||||
_GEVENT_PATCHED = False
|
||||
|
||||
# Greenlet-safe after gevent monkey-patching.
|
||||
_guard = threading.Lock()
|
||||
_in_progress: Dict[str, threading.Event] = {}
|
||||
_flight_results: Dict[str, dict] = {}
|
||||
|
||||
_SCHEMA_CACHE_PREFIX = "openapi:schema:"
|
||||
_SCHEMA_CACHE_VER_KEY = f"{_SCHEMA_CACHE_PREFIX}cache_ver"
|
||||
_BUILD_TIMEOUT_SECONDS = 120
|
||||
|
||||
|
||||
def _run_schema_build(build: Callable[[], dict]) -> dict:
|
||||
"""Run schema introspection off the gevent hub when monkey-patched."""
|
||||
if _SCHEMA_POOL is not None and _GEVENT_PATCHED:
|
||||
return _SCHEMA_POOL.spawn(build).get(timeout=_BUILD_TIMEOUT_SECONDS)
|
||||
return build()
|
||||
|
||||
|
||||
def _cache_supports_delete_pattern() -> bool:
|
||||
return callable(getattr(cache, "delete_pattern", None))
|
||||
|
||||
|
||||
def clear_schema_cache() -> None:
|
||||
"""Invalidate cached schemas (tests / forced refresh after deploy)."""
|
||||
with _guard:
|
||||
_flight_results.clear()
|
||||
try:
|
||||
if _cache_supports_delete_pattern():
|
||||
cache.delete_pattern(f"{_SCHEMA_CACHE_PREFIX}*")
|
||||
return
|
||||
ver = cache.get(_SCHEMA_CACHE_VER_KEY) or 0
|
||||
cache.set(_SCHEMA_CACHE_VER_KEY, int(ver) + 1, timeout=None)
|
||||
except Exception:
|
||||
logger.warning("Failed to clear OpenAPI schema cache", exc_info=True)
|
||||
|
||||
|
||||
def _key_namespace() -> str:
|
||||
"""Stable on Redis (pattern delete); versioned on LocMem for test clears."""
|
||||
if _cache_supports_delete_pattern():
|
||||
return "shared"
|
||||
try:
|
||||
ver = cache.get(_SCHEMA_CACHE_VER_KEY)
|
||||
if ver is None:
|
||||
cache.add(_SCHEMA_CACHE_VER_KEY, 1, timeout=None)
|
||||
ver = cache.get(_SCHEMA_CACHE_VER_KEY) or 1
|
||||
return f"v{int(ver)}"
|
||||
except Exception:
|
||||
return "v1"
|
||||
|
||||
|
||||
class LockedSpectacularAPIView(SpectacularAPIView):
|
||||
"""SpectacularAPIView with single-flight generation and Django cache."""
|
||||
|
||||
def _resolve_version(self, request):
|
||||
return self.api_version or request.version or self._get_version_parameter(
|
||||
request
|
||||
)
|
||||
|
||||
def _cache_key(self, request) -> str:
|
||||
version = self._resolve_version(request)
|
||||
lang = request.GET.get("lang") if settings.USE_I18N else None
|
||||
urlconf = self.urlconf
|
||||
if urlconf is None:
|
||||
urlconf_part = "default"
|
||||
else:
|
||||
urlconf_part = getattr(urlconf, "__name__", None) or type(urlconf).__name__
|
||||
patterns_part = "custom" if self.patterns is not None else "default"
|
||||
return (
|
||||
f"{_SCHEMA_CACHE_PREFIX}{_key_namespace()}:"
|
||||
f"{version}:{lang}:{self.serve_public}:{urlconf_part}:{patterns_part}"
|
||||
)
|
||||
|
||||
def _cache_get(self, key: str) -> Optional[dict]:
|
||||
try:
|
||||
return cache.get(key)
|
||||
except Exception:
|
||||
logger.warning("OpenAPI schema cache get failed", exc_info=True)
|
||||
return None
|
||||
|
||||
def _cache_set(self, key: str, schema: dict) -> None:
|
||||
try:
|
||||
# Use CACHES['default'] TIMEOUT (3600s in settings).
|
||||
cache.set(key, schema)
|
||||
except Exception:
|
||||
logger.warning("OpenAPI schema cache set failed", exc_info=True)
|
||||
|
||||
def _build_schema(self, request, version: Optional[str]) -> dict:
|
||||
generator = self.generator_class(
|
||||
urlconf=self.urlconf,
|
||||
api_version=version,
|
||||
patterns=self.patterns,
|
||||
)
|
||||
return generator.get_schema(request=request, public=self.serve_public)
|
||||
|
||||
def _get_schema_response(self, request):
|
||||
version = self._resolve_version(request)
|
||||
cache_key = self._cache_key(request)
|
||||
|
||||
cached = self._cache_get(cache_key)
|
||||
if cached is not None:
|
||||
return self._schema_response(request, version, cached)
|
||||
|
||||
with _guard:
|
||||
cached = self._cache_get(cache_key)
|
||||
if cached is not None:
|
||||
return self._schema_response(request, version, cached)
|
||||
|
||||
event = _in_progress.get(cache_key)
|
||||
if event is None:
|
||||
event = threading.Event()
|
||||
_in_progress[cache_key] = event
|
||||
is_builder = True
|
||||
else:
|
||||
is_builder = False
|
||||
|
||||
if not is_builder:
|
||||
if not event.wait(timeout=_BUILD_TIMEOUT_SECONDS):
|
||||
return Response(
|
||||
{"detail": "Timed out waiting for OpenAPI schema generation."},
|
||||
status=503,
|
||||
)
|
||||
cached = self._cache_get(cache_key)
|
||||
if cached is None:
|
||||
with _guard:
|
||||
cached = _flight_results.get(cache_key)
|
||||
if cached is None:
|
||||
return Response(
|
||||
{"detail": "OpenAPI schema generation failed."},
|
||||
status=500,
|
||||
)
|
||||
return self._schema_response(request, version, cached)
|
||||
|
||||
try:
|
||||
schema = _run_schema_build(
|
||||
lambda: self._build_schema(request, version)
|
||||
)
|
||||
with _guard:
|
||||
_flight_results[cache_key] = schema
|
||||
self._cache_set(cache_key, schema)
|
||||
return self._schema_response(request, version, schema)
|
||||
except Exception:
|
||||
logger.exception("OpenAPI schema generation failed")
|
||||
return Response(
|
||||
{"detail": "OpenAPI schema generation failed."},
|
||||
status=500,
|
||||
)
|
||||
finally:
|
||||
with _guard:
|
||||
_in_progress.pop(cache_key, None)
|
||||
event.set()
|
||||
|
||||
def _schema_response(self, request, version, schema: dict) -> Response:
|
||||
# LocMem returns the same object; deepcopy keeps responses isolated.
|
||||
return Response(
|
||||
data=copy.deepcopy(schema),
|
||||
headers={
|
||||
"Content-Disposition": (
|
||||
f'inline; filename="{self._get_filename(request, version)}"'
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
from django.urls import path, include, re_path
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
|
||||
from drf_spectacular.views import SpectacularSwaggerView, SpectacularRedocView
|
||||
|
||||
from apps.api.schema_views import LockedSpectacularAPIView
|
||||
|
||||
app_name = 'api'
|
||||
|
||||
|
|
@ -22,9 +24,9 @@ urlpatterns = [
|
|||
|
||||
|
||||
|
||||
# OpenAPI Schema and Documentation (drf-spectacular)
|
||||
path('schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||
# OpenAPI schema (single-flight + Django cache; see apps.api.schema_views)
|
||||
path('schema/', LockedSpectacularAPIView.as_view(), name='schema'),
|
||||
re_path(r'^swagger/?$', SpectacularSwaggerView.as_view(url_name='api:schema'), name='swagger-ui'),
|
||||
path('redoc/', SpectacularRedocView.as_view(url_name='api:schema'), name='redoc'),
|
||||
path('swagger.json', SpectacularAPIView.as_view(), name='schema-json'),
|
||||
path('swagger.json', LockedSpectacularAPIView.as_view(), name='schema-json'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -44,13 +44,6 @@ class ResolveXcEpgPrevDaysTests(TestCase):
|
|||
self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 3)
|
||||
mock_compute.assert_not_called()
|
||||
|
||||
@patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14)
|
||||
@patch("core.models.CoreSettings.get_xmltv_prev_days_override", return_value=7)
|
||||
def test_epg_settings_override_prevents_auto_detect(self, _override, mock_compute):
|
||||
request = self.factory.get("/xmltv.php")
|
||||
self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 7)
|
||||
mock_compute.assert_not_called()
|
||||
|
||||
|
||||
class CatchupRollupActiveAccountTests(TestCase):
|
||||
"""Denormalized catch-up flags ignore disabled M3U accounts."""
|
||||
|
|
|
|||
|
|
@ -67,8 +67,7 @@ def resolve_xc_epg_prev_days(request, user, *, auto_detect_fallback=True):
|
|||
Resolution order:
|
||||
1. URL ``?prev_days=`` (explicit; 0 means no past programmes)
|
||||
2. ``user.custom_properties.epg_prev_days``
|
||||
3. ``CoreSettings.epg_settings.xmltv_prev_days_override`` when > 0
|
||||
4. Auto-detect (only when *auto_detect_fallback* is True)
|
||||
3. Auto-detect (only when *auto_detect_fallback* is True)
|
||||
"""
|
||||
user_custom = (user.custom_properties or {}) if user else {}
|
||||
url_prev = request.GET.get("prev_days")
|
||||
|
|
@ -85,14 +84,6 @@ def resolve_xc_epg_prev_days(request, user, *, auto_detect_fallback=True):
|
|||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
from core.models import CoreSettings
|
||||
|
||||
try:
|
||||
override = int(CoreSettings.get_xmltv_prev_days_override() or 0)
|
||||
except (TypeError, ValueError):
|
||||
override = 0
|
||||
if override > 0:
|
||||
return max(0, min(override, MAX_AUTO_PREV_DAYS))
|
||||
if auto_detect_fallback:
|
||||
return compute_provider_archive_days_capped()
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ urlpatterns = [
|
|||
api_views.CatchupSessionCreateAPIView.as_view(),
|
||||
name="catchup-session-create",
|
||||
),
|
||||
path(
|
||||
"sessions/<str:session_id>/position/",
|
||||
api_views.CatchupSessionPositionAPIView.as_view(),
|
||||
name="catchup-session-position",
|
||||
),
|
||||
path(
|
||||
"sessions/<str:session_id>/",
|
||||
api_views.CatchupSessionDestroyAPIView.as_view(),
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ from rest_framework.views import APIView
|
|||
from apps.accounts.permissions import IsStandardUser
|
||||
from apps.channels.models import Channel
|
||||
from apps.channels.utils import get_channel_catchup_streams
|
||||
from core.utils import RedisClient
|
||||
from dispatcharr.utils import network_access_allowed
|
||||
|
||||
from .helpers import parse_catchup_timestamp
|
||||
from .helpers import MAX_DURATION_MINUTES, parse_catchup_timestamp
|
||||
from .sessions import (
|
||||
HANDSHAKE_TTL_SECONDS,
|
||||
SESSION_IDLE_TTL_SECONDS,
|
||||
|
|
@ -19,7 +20,11 @@ from .sessions import (
|
|||
delete_catchup_session,
|
||||
user_owns_catchup_session,
|
||||
)
|
||||
from .views import _user_can_access_channel
|
||||
from .stats import update_catchup_session_position
|
||||
from .views import _trigger_timeshift_stats_update, _user_can_access_channel
|
||||
|
||||
# Programme length cap expressed in seconds for position reports.
|
||||
_MAX_POSITION_SECS = MAX_DURATION_MINUTES * 60
|
||||
|
||||
|
||||
class CatchupSessionCreateSerializer(serializers.Serializer):
|
||||
|
|
@ -32,6 +37,16 @@ class CatchupSessionCreateSerializer(serializers.Serializer):
|
|||
"ISO-8601 (2026-07-09T14:00:00Z), Unix epoch, or XC wall-clock shapes."
|
||||
),
|
||||
)
|
||||
duration = serializers.IntegerField(
|
||||
required=False,
|
||||
min_value=1,
|
||||
max_value=MAX_DURATION_MINUTES,
|
||||
help_text=(
|
||||
"Optional programme length in minutes. Preferred over EPG when "
|
||||
"supplied. A short buffer is added for provider archive lag. "
|
||||
"Omit to derive the length from EPG."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CatchupSessionResponseSerializer(serializers.Serializer):
|
||||
|
|
@ -42,6 +57,11 @@ class CatchupSessionResponseSerializer(serializers.Serializer):
|
|||
)
|
||||
channel_uuid = serializers.UUIDField()
|
||||
start = serializers.CharField()
|
||||
duration = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Programme length in minutes if supplied at creation, else null.",
|
||||
)
|
||||
|
||||
|
||||
class CatchupSessionCreateAPIView(APIView):
|
||||
|
|
@ -58,6 +78,11 @@ class CatchupSessionCreateAPIView(APIView):
|
|||
"**``start``** is the programme's broadcast start time in UTC "
|
||||
"(from EPG ``start_time``). It selects *which* archived show to "
|
||||
"fetch, not when the viewer pressed play.\n\n"
|
||||
"**``duration``** is optional programme length in minutes. Native "
|
||||
"clients should send it when their guide knows the programme "
|
||||
"length. Dispatcharr uses it before local EPG duration, adds a "
|
||||
"short provider-lag buffer, and falls back to EPG duration, then "
|
||||
"the default archive window when omitted.\n\n"
|
||||
f"The player should open ``playback_url`` within "
|
||||
f"**{HANDSHAKE_TTL_SECONDS} seconds** (see ``expires_at``). After the "
|
||||
"first byte request, the session stays valid with a "
|
||||
|
|
@ -120,7 +145,12 @@ class CatchupSessionCreateAPIView(APIView):
|
|||
)
|
||||
|
||||
try:
|
||||
payload = create_catchup_session(user=user, channel=channel, start=start)
|
||||
payload = create_catchup_session(
|
||||
user=user,
|
||||
channel=channel,
|
||||
start=start,
|
||||
duration=body.validated_data.get("duration"),
|
||||
)
|
||||
except RuntimeError:
|
||||
return Response(
|
||||
{"error": "Session service unavailable"},
|
||||
|
|
@ -166,3 +196,79 @@ class CatchupSessionDestroyAPIView(APIView):
|
|||
|
||||
delete_catchup_session(session_id)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class CatchupSessionPositionSerializer(serializers.Serializer):
|
||||
position_secs = serializers.FloatField(
|
||||
min_value=0,
|
||||
max_value=_MAX_POSITION_SECS,
|
||||
help_text=(
|
||||
"Current playhead within the programme, in seconds from programme start."
|
||||
),
|
||||
)
|
||||
paused = serializers.BooleanField(
|
||||
required=False,
|
||||
help_text=(
|
||||
"When true, admin stats freeze at ``position_secs`` (no wall-clock "
|
||||
"advance). When false, clear pause. Omit to leave pause state unchanged."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CatchupSessionPositionAPIView(APIView):
|
||||
"""Native clients report local playhead / pause for catch-up stats polish."""
|
||||
|
||||
permission_classes = [IsStandardUser]
|
||||
|
||||
@extend_schema(
|
||||
description=(
|
||||
"Update the reported playhead for an active catch-up session.\n\n"
|
||||
"Native apps can call this while paused or after local scrubbing so "
|
||||
"admin stats stay aligned with what the viewer sees. This does "
|
||||
"**not** seek the provider stream; HTTP ``Range`` / a new archive "
|
||||
"open still control bytes.\n\n"
|
||||
"Requires an active playback connection for ``session_id``. Also "
|
||||
"refreshes the session idle TTL."
|
||||
),
|
||||
request=CatchupSessionPositionSerializer,
|
||||
responses={
|
||||
204: None,
|
||||
400: inline_serializer(
|
||||
name="CatchupSessionPositionError",
|
||||
fields={"error": serializers.CharField()},
|
||||
),
|
||||
403: inline_serializer(
|
||||
name="CatchupSessionPositionForbidden",
|
||||
fields={"error": serializers.CharField()},
|
||||
),
|
||||
404: inline_serializer(
|
||||
name="CatchupSessionPositionNotFound",
|
||||
fields={"error": serializers.CharField()},
|
||||
),
|
||||
},
|
||||
tags=["catchup"],
|
||||
)
|
||||
def post(self, request, session_id):
|
||||
if not network_access_allowed(request, "STREAMS", request.user):
|
||||
return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
if not user_owns_catchup_session(session_id, request.user.id):
|
||||
return Response({"error": "Session not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
body = CatchupSessionPositionSerializer(data=request.data)
|
||||
body.is_valid(raise_exception=True)
|
||||
|
||||
updated = update_catchup_session_position(
|
||||
session_id,
|
||||
position_secs=body.validated_data["position_secs"],
|
||||
paused=body.validated_data.get("paused"),
|
||||
user_id=request.user.id,
|
||||
)
|
||||
if not updated:
|
||||
return Response(
|
||||
{"error": "No active playback for this session"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
_trigger_timeshift_stats_update(RedisClient.get_client())
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
"""URL builders and timestamp helpers for XC catch-up."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from collections import namedtuple
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import quote
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from apps.timeshift.redis_keys import TimeshiftRedisKeys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Credentials for the profile whose pool slot was reserved (not raw account fields).
|
||||
|
|
@ -15,10 +19,13 @@ TimeshiftCredentials = namedtuple(
|
|||
)
|
||||
|
||||
DEFAULT_DURATION_MINUTES = 120
|
||||
# Extra minutes added to client/EPG programme length when asking the provider.
|
||||
# IPTV archives commonly lag live by about 30 seconds to 2 minutes, so a bare
|
||||
# programme length tends to include the previous show's tail and clip the end.
|
||||
DURATION_BUFFER_MINUTES = 5
|
||||
MAX_DURATION_MINUTES = 480
|
||||
|
||||
# Wall-clock shapes seen from XC / iPlayTV / TiviMate clients. Compiled once.
|
||||
# Wall-clock shapes seen from XC catch-up clients. Compiled once.
|
||||
_CATCHUP_WALL_CLOCK_RE = re.compile(
|
||||
r"^"
|
||||
r"(?P<date>\d{4}-\d{2}-\d{2})"
|
||||
|
|
@ -38,7 +45,7 @@ def normalize_catchup_timestamp_input(timestamp_str):
|
|||
"""Map a client catch-up timestamp to an ISO-8601 string for ``fromisoformat``.
|
||||
|
||||
Supported inputs:
|
||||
- ``YYYY-MM-DD:HH-MM`` (iPlayTV/TiviMate colon-dash)
|
||||
- ``YYYY-MM-DD:HH-MM`` (XC colon-dash)
|
||||
- ``YYYY-MM-DD_HH-MM`` (XC underscore)
|
||||
- ``YYYY-MM-DD:HH:MM[:SS]`` (XC colon time in catch-up URLs)
|
||||
- ``YYYY-MM-DD HH:MM[:SS]`` (EPG / SQL datetime)
|
||||
|
|
@ -158,8 +165,9 @@ def get_programme_duration(channel, timestamp_str):
|
|||
timestamp_str: Programme start in UTC (same shape as the client URL).
|
||||
|
||||
Returns:
|
||||
Programme length plus a small buffer, capped at ``MAX_DURATION_MINUTES``,
|
||||
or ``DEFAULT_DURATION_MINUTES`` when EPG lookup fails.
|
||||
Programme length plus ``DURATION_BUFFER_MINUTES`` (provider archive lag),
|
||||
capped at ``MAX_DURATION_MINUTES``, or ``DEFAULT_DURATION_MINUTES`` when
|
||||
EPG lookup fails.
|
||||
"""
|
||||
try:
|
||||
dt = parse_catchup_timestamp(timestamp_str)
|
||||
|
|
@ -183,8 +191,54 @@ def get_programme_duration(channel, timestamp_str):
|
|||
return DEFAULT_DURATION_MINUTES
|
||||
|
||||
|
||||
def get_programme_info(channel, timestamp_str):
|
||||
"""Return EPG metadata for the programme airing at *timestamp_str*."""
|
||||
def client_duration_to_window(value):
|
||||
"""Convert a client-supplied programme length (minutes) to an archive window.
|
||||
|
||||
Args:
|
||||
value: Raw client hint (str/int). PATH XC duration segment, QUERY
|
||||
``duration=``, or the native session's stored ``duration``.
|
||||
|
||||
Returns:
|
||||
Minutes to request from the provider (client length +
|
||||
``DURATION_BUFFER_MINUTES``, capped), or ``None`` when the hint is
|
||||
missing or not a usable positive integer.
|
||||
|
||||
The buffer matches the EPG path: clients usually send exact guide length
|
||||
(for example ``.../60/.../123.ts`` for a 60-minute show), but provider
|
||||
archives lag live, so requesting that bare length clips the end.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
minutes = int(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if minutes <= 0:
|
||||
return None
|
||||
return min(minutes + DURATION_BUFFER_MINUTES, MAX_DURATION_MINUTES)
|
||||
|
||||
|
||||
def resolve_catchup_duration(channel, timestamp_str, client_hint=None):
|
||||
"""Pick the catch-up archive window in minutes.
|
||||
|
||||
Preference order: a sane client-supplied hint, then the EPG programme
|
||||
length, then ``DEFAULT_DURATION_MINUTES``.
|
||||
"""
|
||||
window = client_duration_to_window(client_hint)
|
||||
if window is not None:
|
||||
return window
|
||||
return get_programme_duration(channel, timestamp_str)
|
||||
|
||||
|
||||
def get_programme_info(channel, timestamp_str, position_secs=None):
|
||||
"""Return EPG metadata for the programme airing at *timestamp_str*.
|
||||
|
||||
When ``position_secs`` is set (playhead within the archive, relative to the
|
||||
programme that contains ``timestamp_str``), and that playhead is at or past
|
||||
the programme's end, resolve the guide entry at the playhead instead. That
|
||||
keeps catch-up stats cards on the show the viewer has actually reached when
|
||||
they keep watching into the provider buffer / next programme.
|
||||
"""
|
||||
try:
|
||||
dt = parse_catchup_timestamp(timestamp_str)
|
||||
if dt is None:
|
||||
|
|
@ -199,6 +253,19 @@ def get_programme_info(channel, timestamp_str):
|
|||
if not programme:
|
||||
return None
|
||||
|
||||
if position_secs is not None:
|
||||
try:
|
||||
offset = max(0.0, float(position_secs))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0.0
|
||||
playhead = programme.start_time + timedelta(seconds=offset)
|
||||
if playhead >= programme.end_time:
|
||||
advanced = channel.epg_data.programs.filter(
|
||||
start_time__lte=playhead, end_time__gt=playhead
|
||||
).first()
|
||||
if advanced is not None:
|
||||
programme = advanced
|
||||
|
||||
duration_seconds = (programme.end_time - programme.start_time).total_seconds()
|
||||
return {
|
||||
"title": programme.title,
|
||||
|
|
@ -216,10 +283,19 @@ def get_catchup_programmes_for_sessions(sessions):
|
|||
"""Resolve EPG metadata for catch-up stats cards (batch, on demand).
|
||||
|
||||
Each session dict needs ``session_id``, ``channel_uuid``, and
|
||||
``programme_start``. Called by the frontend when active sessions or seek
|
||||
position change, not on every stats poll.
|
||||
``programme_start``. Optional ``position_secs`` (playhead within the
|
||||
archive, relative to the programme containing ``programme_start``)
|
||||
advances the returned programme when past that show's end. When omitted,
|
||||
an estimate is taken from the session's Redis stats metadata when present.
|
||||
"""
|
||||
from apps.channels.models import Channel
|
||||
from apps.timeshift.stats import (
|
||||
_client_paused,
|
||||
_decode_hash,
|
||||
compute_playback_position_secs,
|
||||
find_stats_channel_for_session,
|
||||
)
|
||||
from core.utils import RedisClient
|
||||
|
||||
if not sessions:
|
||||
return []
|
||||
|
|
@ -238,12 +314,40 @@ def get_catchup_programmes_for_sessions(sessions):
|
|||
for ch in Channel.objects.filter(uuid__in=uuids).select_related("epg_data")
|
||||
}
|
||||
|
||||
redis_client = RedisClient.get_client()
|
||||
results = []
|
||||
for session in valid:
|
||||
channel_uuid = str(session["channel_uuid"])
|
||||
programme_start = session["programme_start"]
|
||||
channel = channels_by_uuid.get(channel_uuid)
|
||||
position_secs = session.get("position_secs")
|
||||
if position_secs is not None:
|
||||
try:
|
||||
position_secs = float(position_secs)
|
||||
except (TypeError, ValueError):
|
||||
position_secs = None
|
||||
|
||||
# Resolve the guide entry for the URL first so Redis playhead math has
|
||||
# an EPG start; then re-resolve with position to advance past the end.
|
||||
info = get_programme_info(channel, programme_start) if channel else None
|
||||
if position_secs is None and info is not None and redis_client is not None:
|
||||
position_secs = _position_secs_from_stats(
|
||||
redis_client,
|
||||
session_id=session["session_id"],
|
||||
programme_start=programme_start,
|
||||
epg_start_iso=info["start_time"],
|
||||
compute_playback_position_secs=compute_playback_position_secs,
|
||||
find_stats_channel_for_session=find_stats_channel_for_session,
|
||||
client_paused=_client_paused,
|
||||
decode_hash=_decode_hash,
|
||||
)
|
||||
if channel is not None and position_secs is not None:
|
||||
advanced = get_programme_info(
|
||||
channel, programme_start, position_secs=position_secs,
|
||||
)
|
||||
if advanced is not None:
|
||||
info = advanced
|
||||
|
||||
entry = {
|
||||
"session_id": session["session_id"],
|
||||
"channel_uuid": channel_uuid,
|
||||
|
|
@ -262,6 +366,46 @@ def get_catchup_programmes_for_sessions(sessions):
|
|||
return results
|
||||
|
||||
|
||||
def _position_secs_from_stats(
|
||||
redis_client,
|
||||
*,
|
||||
session_id,
|
||||
programme_start,
|
||||
epg_start_iso,
|
||||
compute_playback_position_secs,
|
||||
find_stats_channel_for_session,
|
||||
client_paused,
|
||||
decode_hash,
|
||||
):
|
||||
"""Best-effort uncapped playhead from timeshift stats Redis metadata."""
|
||||
try:
|
||||
stats_channel_id = find_stats_channel_for_session(redis_client, session_id)
|
||||
if not stats_channel_id:
|
||||
return None
|
||||
client_key = TimeshiftRedisKeys.client_metadata(stats_channel_id, session_id)
|
||||
client_data = decode_hash(redis_client.hgetall(client_key))
|
||||
if not client_data:
|
||||
return None
|
||||
playback_base_raw = client_data.get("playback_base_secs")
|
||||
playback_base_secs = None
|
||||
if playback_base_raw not in (None, ""):
|
||||
try:
|
||||
playback_base_secs = float(playback_base_raw)
|
||||
except (TypeError, ValueError):
|
||||
playback_base_secs = None
|
||||
return compute_playback_position_secs(
|
||||
programme_start,
|
||||
epg_start_iso,
|
||||
client_data.get("position_anchor_at"),
|
||||
time.time(),
|
||||
duration_secs=None,
|
||||
playback_base_secs=playback_base_secs,
|
||||
paused=client_paused(client_data.get("paused")),
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def build_timeshift_url_format_a(creds, stream_id, timestamp, duration_minutes):
|
||||
"""QUERY layout: ``/streaming/timeshift.php?username=...&start=...``"""
|
||||
return (
|
||||
|
|
@ -339,3 +483,51 @@ def format_timestamp_as_underscore(timestamp):
|
|||
def format_timestamp_as_sql_datetime(timestamp):
|
||||
"""Reshape to ``YYYY-MM-DD HH:MM:SS`` without timezone conversion."""
|
||||
return _reshape_timestamp(timestamp, "%Y-%m-%d %H:%M:%S", "SQL")
|
||||
|
||||
|
||||
def programme_age_days(timestamp_str, *, now=None):
|
||||
"""Archive depth in whole days needed to cover a catch-up start timestamp.
|
||||
|
||||
Returns:
|
||||
``None`` if the timestamp cannot be parsed, ``0`` if start is at/after
|
||||
*now*, otherwise ``ceil(elapsed / 86400)`` (at least 1).
|
||||
"""
|
||||
dt = parse_catchup_timestamp(timestamp_str)
|
||||
if dt is None:
|
||||
return None
|
||||
if now is None:
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
elif getattr(now, "tzinfo", None) is not None:
|
||||
now = now.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
elapsed = (now - dt).total_seconds()
|
||||
if elapsed <= 0:
|
||||
return 0
|
||||
return max(1, math.ceil(elapsed / 86400.0))
|
||||
|
||||
|
||||
def order_catchup_streams_for_timestamp(streams, timestamp_str, *, now=None):
|
||||
"""Prefer streams whose ``catchup_days`` cover the programme age.
|
||||
|
||||
Relative channel order is preserved within the preferred and fallback
|
||||
groups. Streams with unknown/zero ``catchup_days`` stay preferred so
|
||||
incomplete provider metadata does not skip a possible server. Unparseable
|
||||
timestamps leave the input order unchanged.
|
||||
"""
|
||||
age = programme_age_days(timestamp_str, now=now)
|
||||
if age is None:
|
||||
return list(streams)
|
||||
|
||||
preferred = []
|
||||
fallback = []
|
||||
for stream in streams:
|
||||
raw = getattr(stream, "catchup_days", None)
|
||||
try:
|
||||
days = int(raw) if raw is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
days = 0
|
||||
if days <= 0 or days >= age:
|
||||
preferred.append(stream)
|
||||
else:
|
||||
fallback.append(stream)
|
||||
return preferred + fallback
|
||||
|
|
|
|||
|
|
@ -38,8 +38,12 @@ def mint_catchup_session_id():
|
|||
return mint_session_id()
|
||||
|
||||
|
||||
def create_catchup_session(*, user, channel, start):
|
||||
"""Persist a new playback session and return metadata for the API response."""
|
||||
def create_catchup_session(*, user, channel, start, duration=None):
|
||||
"""Persist a new playback session and return metadata for the API response.
|
||||
|
||||
``duration`` is an optional programme length in minutes. When supplied it is
|
||||
preferred over EPG at playback time (see ``resolve_catchup_duration``).
|
||||
"""
|
||||
redis_client = RedisClient.get_client()
|
||||
if redis_client is None:
|
||||
raise RuntimeError("Redis unavailable")
|
||||
|
|
@ -47,16 +51,16 @@ def create_catchup_session(*, user, channel, start):
|
|||
session_id = mint_session_id()
|
||||
now = int(time.time())
|
||||
key = TimeshiftRedisKeys.api_session(session_id)
|
||||
redis_client.hset(
|
||||
key,
|
||||
mapping={
|
||||
"user_id": str(user.id),
|
||||
"channel_uuid": str(channel.uuid),
|
||||
"channel_id": str(channel.id),
|
||||
"start": str(start),
|
||||
"created_at": str(now),
|
||||
},
|
||||
)
|
||||
mapping = {
|
||||
"user_id": str(user.id),
|
||||
"channel_uuid": str(channel.uuid),
|
||||
"channel_id": str(channel.id),
|
||||
"start": str(start),
|
||||
"created_at": str(now),
|
||||
}
|
||||
if duration is not None:
|
||||
mapping["duration"] = str(duration)
|
||||
redis_client.hset(key, mapping=mapping)
|
||||
redis_client.expire(key, HANDSHAKE_TTL_SECONDS)
|
||||
|
||||
handshake_expires_at = now + HANDSHAKE_TTL_SECONDS
|
||||
|
|
@ -68,6 +72,7 @@ def create_catchup_session(*, user, channel, start):
|
|||
"expires_at": handshake_expires_at,
|
||||
"channel_uuid": str(channel.uuid),
|
||||
"start": str(start),
|
||||
"duration": duration,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -157,8 +162,9 @@ def resolve_catchup_playback(session_id, channel_uuid):
|
|||
"""Resolve user and programme start for a tokenless playback request.
|
||||
|
||||
Returns:
|
||||
``(user, start)`` on success, or ``None`` if the session is invalid,
|
||||
expired, or bound to a different channel.
|
||||
``(user, start, duration)`` on success, or ``None`` if the session is
|
||||
invalid, expired, or bound to a different channel. ``duration`` is the
|
||||
stored client programme length in minutes, or ``None`` when unset.
|
||||
"""
|
||||
record = get_catchup_session(session_id)
|
||||
if not record:
|
||||
|
|
@ -184,7 +190,7 @@ def resolve_catchup_playback(session_id, channel_uuid):
|
|||
if not start:
|
||||
return None
|
||||
|
||||
return user, str(start)
|
||||
return user, str(start), record.get("duration")
|
||||
|
||||
|
||||
def user_owns_catchup_session(session_id, user_id):
|
||||
|
|
|
|||
|
|
@ -13,8 +13,13 @@ from apps.m3u.models import M3UAccountProfile
|
|||
from apps.proxy.live_proxy.constants import ChannelMetadataField
|
||||
from apps.timeshift.redis_keys import TimeshiftRedisKeys, parse_stats_channel_id
|
||||
from apps.timeshift.helpers import parse_catchup_timestamp
|
||||
from core.utils import RedisClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Shared with views near-EOF classification (common ~1.88MB duration probes).
|
||||
EOF_PROBE_TAIL_BYTES = 2_097_152
|
||||
|
||||
_STREAM_STATS_TO_METADATA = {
|
||||
"video_codec": ChannelMetadataField.VIDEO_CODEC,
|
||||
"resolution": ChannelMetadataField.RESOLUTION,
|
||||
|
|
@ -165,6 +170,32 @@ def resolve_stats_playback_fields(
|
|||
existing_programme_start is not None
|
||||
and existing_programme_start != timestamp_utc
|
||||
)
|
||||
|
||||
# Near-EOF duration probes must not reanchor stats to end-of-file.
|
||||
if (
|
||||
range_start is not None
|
||||
and representation_length is not None
|
||||
and not programme_changed
|
||||
):
|
||||
try:
|
||||
start = int(range_start)
|
||||
total = int(representation_length)
|
||||
except (TypeError, ValueError):
|
||||
start = None
|
||||
total = None
|
||||
if start is not None and total is not None and total > 0:
|
||||
if start >= max(0, total - EOF_PROBE_TAIL_BYTES):
|
||||
try:
|
||||
keep_base = (
|
||||
float(existing_playback_base)
|
||||
if existing_playback_base is not None
|
||||
else None
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
keep_base = None
|
||||
keep_anchor = existing_position_anchor or now
|
||||
return keep_base, keep_anchor
|
||||
|
||||
byte_base = compute_playback_base_from_byte_range(
|
||||
range_start, representation_length, programme_duration_secs,
|
||||
)
|
||||
|
|
@ -192,6 +223,7 @@ def compute_playback_position_secs(
|
|||
current_time,
|
||||
duration_secs=None,
|
||||
playback_base_secs=None,
|
||||
paused=False,
|
||||
):
|
||||
"""Best-effort catch-up play position in seconds within the programme.
|
||||
|
||||
|
|
@ -203,9 +235,12 @@ def compute_playback_position_secs(
|
|||
Native players (VLC) often keep the programme URL fixed and seek via
|
||||
``Range: bytes=…``; in that case ``playback_base_secs`` carries the mapped
|
||||
position at the anchor instead of the URL timestamp offset.
|
||||
|
||||
When ``paused`` is true, wall-clock since the anchor is ignored so admin
|
||||
stats stay frozen at the last reported playhead.
|
||||
"""
|
||||
elapsed_since_anchor = 0.0
|
||||
if position_anchor_at:
|
||||
if not paused and position_anchor_at:
|
||||
try:
|
||||
elapsed_since_anchor = max(0.0, current_time - float(position_anchor_at))
|
||||
except (TypeError, ValueError):
|
||||
|
|
@ -258,6 +293,81 @@ def find_stats_channel_for_session(redis_client, session_id):
|
|||
return None
|
||||
|
||||
|
||||
def _client_paused(raw_value):
|
||||
if raw_value is None or raw_value == "":
|
||||
return False
|
||||
value = _decode_value(raw_value).strip().lower()
|
||||
return value in {"1", "true", "yes"}
|
||||
|
||||
|
||||
def update_catchup_session_position(
|
||||
session_id,
|
||||
*,
|
||||
position_secs,
|
||||
paused=None,
|
||||
user_id=None,
|
||||
redis_client=None,
|
||||
):
|
||||
"""Record a native client's playhead for catch-up stats.
|
||||
|
||||
Updates the active stats client hash for ``session_id`` and refreshes the
|
||||
API session idle TTL. Does not seek the provider stream.
|
||||
|
||||
Returns:
|
||||
``True`` when metadata was updated, ``False`` when there is no active
|
||||
playback stats entry (or Redis is unavailable).
|
||||
"""
|
||||
from apps.timeshift.sessions import touch_catchup_session
|
||||
|
||||
if redis_client is None:
|
||||
redis_client = RedisClient.get_client()
|
||||
if redis_client is None or not session_id:
|
||||
return False
|
||||
|
||||
stats_channel_id = find_stats_channel_for_session(redis_client, session_id)
|
||||
if not stats_channel_id:
|
||||
return False
|
||||
|
||||
client_key = TimeshiftRedisKeys.client_metadata(stats_channel_id, session_id)
|
||||
client_set_key = TimeshiftRedisKeys.clients(stats_channel_id)
|
||||
metadata_key = TimeshiftRedisKeys.channel_metadata(stats_channel_id)
|
||||
try:
|
||||
if not redis_client.exists(client_key):
|
||||
return False
|
||||
if user_id is not None:
|
||||
stored_user = redis_client.hget(client_key, "user_id")
|
||||
if stored_user is not None and str(_decode_value(stored_user)) != str(user_id):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
now = str(time.time())
|
||||
mapping = {
|
||||
"playback_base_secs": str(float(position_secs)),
|
||||
"position_anchor_at": now,
|
||||
"last_active": now,
|
||||
}
|
||||
# Must match apps.timeshift.views.CLIENT_TTL_SECONDS (stats / fingerprint).
|
||||
client_ttl_seconds = 60
|
||||
|
||||
try:
|
||||
pipe = redis_client.pipeline(transaction=False)
|
||||
pipe.hset(client_key, mapping=mapping)
|
||||
if paused is True:
|
||||
pipe.hset(client_key, "paused", "1")
|
||||
elif paused is False:
|
||||
pipe.hdel(client_key, "paused")
|
||||
pipe.expire(client_key, client_ttl_seconds)
|
||||
pipe.expire(client_set_key, client_ttl_seconds)
|
||||
pipe.expire(metadata_key, client_ttl_seconds)
|
||||
pipe.execute()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
touch_catchup_session(session_id, redis_client=redis_client)
|
||||
return True
|
||||
|
||||
|
||||
def build_timeshift_stats_data(redis_client):
|
||||
"""Build catch-up stats payload from Redis session metadata."""
|
||||
empty = {
|
||||
|
|
@ -339,6 +449,8 @@ def build_timeshift_stats_data(redis_client):
|
|||
except (TypeError, ValueError):
|
||||
playback_base_secs = None
|
||||
|
||||
paused = _client_paused(client_data.get("paused"))
|
||||
|
||||
connected_at = client_data.get("connected_at")
|
||||
duration = 0
|
||||
if connected_at:
|
||||
|
|
@ -358,6 +470,7 @@ def build_timeshift_stats_data(redis_client):
|
|||
"programme_start": programme_start,
|
||||
"position_anchor_at": client_data.get("position_anchor_at"),
|
||||
"playback_base_secs": playback_base_secs,
|
||||
"paused": paused,
|
||||
"m3u_profile_id": int(m3u_profile_id) if m3u_profile_id else None,
|
||||
"ip_address": client_data.get("ip_address", "Unknown"),
|
||||
"user_agent": client_data.get("user_agent", "unknown"),
|
||||
|
|
@ -437,6 +550,7 @@ def build_timeshift_stats_data(redis_client):
|
|||
"programme_start": conn["programme_start"],
|
||||
"position_anchor_at": position_anchor_at,
|
||||
"playback_base_secs": playback_base_secs,
|
||||
"paused": bool(conn.get("paused")),
|
||||
"resolution": conn.get("resolution"),
|
||||
"source_fps": conn.get("source_fps"),
|
||||
"video_codec": conn.get("video_codec"),
|
||||
|
|
|
|||
|
|
@ -15,13 +15,15 @@ from apps.timeshift.helpers import (
|
|||
format_timestamp_as_sql_datetime,
|
||||
format_timestamp_as_underscore,
|
||||
normalize_catchup_timestamp_input,
|
||||
order_catchup_streams_for_timestamp,
|
||||
parse_catchup_timestamp,
|
||||
programme_age_days,
|
||||
)
|
||||
|
||||
|
||||
def _make_creds():
|
||||
# The builders consume resolved per-profile credentials, never an account
|
||||
# object — get_transformed_credentials() produces these in the view.
|
||||
# object - get_transformed_credentials() produces these in the view.
|
||||
return TimeshiftCredentials("http://example.test", "user", "pass")
|
||||
|
||||
|
||||
|
|
@ -335,3 +337,116 @@ class GetProgrammeDurationTests(TestCase):
|
|||
from unittest.mock import MagicMock
|
||||
from apps.timeshift.helpers import get_programme_duration
|
||||
self.assertEqual(get_programme_duration(MagicMock(), "garbage"), 120)
|
||||
|
||||
|
||||
class ClientDurationTests(TestCase):
|
||||
"""Client-supplied programme length: sanitised, buffered for provider lag,
|
||||
capped, and preferred over EPG when usable."""
|
||||
|
||||
def test_valid_hint_gets_buffer(self):
|
||||
from apps.timeshift.helpers import client_duration_to_window
|
||||
self.assertEqual(client_duration_to_window(30), 35)
|
||||
self.assertEqual(client_duration_to_window("30"), 35)
|
||||
|
||||
def test_hint_capped_at_max(self):
|
||||
from apps.timeshift.helpers import client_duration_to_window
|
||||
self.assertEqual(client_duration_to_window(1000), 480)
|
||||
|
||||
def test_unusable_hint_returns_none(self):
|
||||
from apps.timeshift.helpers import client_duration_to_window
|
||||
for bad in (None, "", "abc", "0", "-5", 0, -10):
|
||||
self.assertIsNone(client_duration_to_window(bad))
|
||||
|
||||
def test_resolve_prefers_client_hint(self):
|
||||
from unittest.mock import MagicMock
|
||||
from apps.timeshift.helpers import resolve_catchup_duration
|
||||
# EPG would say 120 (no programme), but a valid hint wins.
|
||||
channel = MagicMock(epg_data=None)
|
||||
self.assertEqual(
|
||||
resolve_catchup_duration(channel, "2026-06-08:17-00", client_hint="30"),
|
||||
35,
|
||||
)
|
||||
|
||||
def test_resolve_falls_back_to_epg_when_hint_missing(self):
|
||||
from unittest.mock import MagicMock
|
||||
from apps.timeshift.helpers import resolve_catchup_duration
|
||||
channel = MagicMock(epg_data=None)
|
||||
self.assertEqual(
|
||||
resolve_catchup_duration(channel, "2026-06-08:17-00", client_hint=None),
|
||||
120,
|
||||
)
|
||||
|
||||
|
||||
class ProgrammeAgeAndStreamOrderTests(TestCase):
|
||||
"""Archive-age helpers used to prefer deep catch-up providers first."""
|
||||
|
||||
def test_programme_age_days_ceil(self):
|
||||
now = datetime(2026, 7, 16, 12, 0, 0)
|
||||
self.assertEqual(
|
||||
programme_age_days("2026-07-12:12-00", now=now),
|
||||
4,
|
||||
)
|
||||
self.assertEqual(
|
||||
programme_age_days("2026-07-15:12-00", now=now),
|
||||
1,
|
||||
)
|
||||
self.assertEqual(
|
||||
programme_age_days("2026-07-16:12-00", now=now),
|
||||
0,
|
||||
)
|
||||
|
||||
def test_programme_age_days_unparseable(self):
|
||||
self.assertIsNone(programme_age_days("garbage"))
|
||||
|
||||
def test_order_prefers_covering_streams_then_fallback(self):
|
||||
from types import SimpleNamespace
|
||||
|
||||
now = datetime(2026, 7, 16, 12, 0, 0)
|
||||
streams = [
|
||||
SimpleNamespace(catchup_days=2, name="p1"),
|
||||
SimpleNamespace(catchup_days=1, name="p2"),
|
||||
SimpleNamespace(catchup_days=5, name="p3"),
|
||||
]
|
||||
ordered = order_catchup_streams_for_timestamp(
|
||||
streams, "2026-07-12:12-00", now=now
|
||||
)
|
||||
self.assertEqual([s.name for s in ordered], ["p3", "p1", "p2"])
|
||||
|
||||
def test_order_keeps_channel_order_within_groups(self):
|
||||
from types import SimpleNamespace
|
||||
|
||||
now = datetime(2026, 7, 16, 12, 0, 0)
|
||||
streams = [
|
||||
SimpleNamespace(catchup_days=7, name="a"),
|
||||
SimpleNamespace(catchup_days=2, name="b"),
|
||||
SimpleNamespace(catchup_days=14, name="c"),
|
||||
SimpleNamespace(catchup_days=1, name="d"),
|
||||
]
|
||||
ordered = order_catchup_streams_for_timestamp(
|
||||
streams, "2026-07-12:12-00", now=now
|
||||
)
|
||||
self.assertEqual([s.name for s in ordered], ["a", "c", "b", "d"])
|
||||
|
||||
def test_unknown_catchup_days_stay_preferred(self):
|
||||
from types import SimpleNamespace
|
||||
|
||||
now = datetime(2026, 7, 16, 12, 0, 0)
|
||||
streams = [
|
||||
SimpleNamespace(catchup_days=2, name="short"),
|
||||
SimpleNamespace(catchup_days=0, name="unknown"),
|
||||
SimpleNamespace(catchup_days=5, name="deep"),
|
||||
]
|
||||
ordered = order_catchup_streams_for_timestamp(
|
||||
streams, "2026-07-12:12-00", now=now
|
||||
)
|
||||
self.assertEqual([s.name for s in ordered], ["unknown", "deep", "short"])
|
||||
|
||||
def test_unparseable_timestamp_preserves_order(self):
|
||||
from types import SimpleNamespace
|
||||
|
||||
streams = [
|
||||
SimpleNamespace(catchup_days=2, name="a"),
|
||||
SimpleNamespace(catchup_days=5, name="b"),
|
||||
]
|
||||
ordered = order_catchup_streams_for_timestamp(streams, "garbage")
|
||||
self.assertEqual([s.name for s in ordered], ["a", "b"])
|
||||
|
|
|
|||
|
|
@ -126,6 +126,38 @@ class CatchupSessionApiTests(TestCase):
|
|||
self.assertEqual(data["channel_uuid"], str(self.channel.uuid))
|
||||
self.assertEqual(data["start"], "2026-06-08T17:00:00Z")
|
||||
self.assertGreater(data["expires_at"], int(time.time()))
|
||||
self.assertIsNone(data["duration"])
|
||||
|
||||
@patch.object(sessions.RedisClient, "get_client")
|
||||
@patch("apps.timeshift.api_views.network_access_allowed", return_value=True)
|
||||
def test_post_accepts_duration(self, _net, redis_mock):
|
||||
redis_mock.return_value = self.redis
|
||||
response = self.client.post(
|
||||
self._create_url(),
|
||||
{
|
||||
"channel_uuid": str(self.channel.uuid),
|
||||
"start": "2026-06-08T17:00:00Z",
|
||||
"duration": 30,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.json()["duration"], 30)
|
||||
|
||||
@patch.object(sessions.RedisClient, "get_client")
|
||||
@patch("apps.timeshift.api_views.network_access_allowed", return_value=True)
|
||||
def test_post_rejects_out_of_range_duration(self, _net, redis_mock):
|
||||
redis_mock.return_value = self.redis
|
||||
response = self.client.post(
|
||||
self._create_url(),
|
||||
{
|
||||
"channel_uuid": str(self.channel.uuid),
|
||||
"start": "2026-06-08T17:00:00Z",
|
||||
"duration": 0,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@patch.object(sessions.RedisClient, "get_client")
|
||||
@patch("apps.timeshift.api_views.network_access_allowed", return_value=True)
|
||||
|
|
@ -174,6 +206,139 @@ class CatchupSessionApiTests(TestCase):
|
|||
self.assertEqual(deleted.status_code, 404)
|
||||
|
||||
|
||||
@override_settings(
|
||||
CACHES={
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
"LOCATION": "catchup-session-position-tests",
|
||||
}
|
||||
},
|
||||
REST_FRAMEWORK={
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||
"apps.accounts.authentication.ApiKeyAuthentication",
|
||||
],
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"apps.accounts.permissions.IsAdmin",
|
||||
],
|
||||
},
|
||||
)
|
||||
class CatchupSessionPositionApiTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = User.objects.create(
|
||||
username="catchup-position-user",
|
||||
user_level=User.UserLevel.STANDARD,
|
||||
)
|
||||
cls.account = M3UAccount.objects.create(
|
||||
name="catchup-position-acct",
|
||||
server_url="http://example.test",
|
||||
account_type="XC",
|
||||
is_active=True,
|
||||
)
|
||||
cls.channel = Channel.objects.create(
|
||||
name="Catchup Position Channel",
|
||||
is_catchup=True,
|
||||
catchup_days=7,
|
||||
)
|
||||
cls.stream = Stream.objects.create(
|
||||
name="catchup-position-stream",
|
||||
url="http://example.test/live",
|
||||
m3u_account=cls.account,
|
||||
is_catchup=True,
|
||||
catchup_days=7,
|
||||
custom_properties={"stream_id": "111"},
|
||||
)
|
||||
ChannelStream.objects.create(
|
||||
channel=cls.channel, stream=cls.stream, order=0,
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
from apps.timeshift.tests.test_views import _FakeRedis
|
||||
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
self.redis = _FakeRedis()
|
||||
|
||||
def _seed_active_playback(self, session_id):
|
||||
stats_channel_id = f"{self.channel.id}_{session_id}"
|
||||
self.redis.hset(
|
||||
TimeshiftRedisKeys.channel_metadata(stats_channel_id),
|
||||
mapping={"state": "active"},
|
||||
)
|
||||
self.redis.sadd(TimeshiftRedisKeys.clients(stats_channel_id), session_id)
|
||||
self.redis.hset(
|
||||
TimeshiftRedisKeys.client_metadata(stats_channel_id, session_id),
|
||||
mapping={
|
||||
"user_id": str(self.user.id),
|
||||
"username": self.user.username,
|
||||
"programme_start": "2026-06-08T17:00:00Z",
|
||||
"position_anchor_at": "1000.0",
|
||||
},
|
||||
)
|
||||
return stats_channel_id
|
||||
|
||||
@patch("apps.timeshift.api_views._trigger_timeshift_stats_update")
|
||||
@patch("apps.timeshift.api_views.RedisClient.get_client")
|
||||
@patch("apps.timeshift.stats.RedisClient.get_client")
|
||||
@patch("apps.timeshift.sessions.RedisClient.get_client")
|
||||
@patch("apps.timeshift.api_views.network_access_allowed", return_value=True)
|
||||
def test_position_updates_active_session(
|
||||
self, _net, sessions_redis, stats_redis, api_redis, trigger_mock,
|
||||
):
|
||||
sessions_redis.return_value = self.redis
|
||||
stats_redis.return_value = self.redis
|
||||
api_redis.return_value = self.redis
|
||||
created = self.client.post(
|
||||
"/api/catchup/sessions/",
|
||||
{
|
||||
"channel_uuid": str(self.channel.uuid),
|
||||
"start": "2026-06-08T17:00:00Z",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
session_id = created.json()["session_id"]
|
||||
stats_channel_id = self._seed_active_playback(session_id)
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/catchup/sessions/{session_id}/position/",
|
||||
{"position_secs": 842, "paused": True},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
client_key = TimeshiftRedisKeys.client_metadata(stats_channel_id, session_id)
|
||||
data = self.redis.hgetall(client_key)
|
||||
self.assertEqual(data["playback_base_secs"], "842.0")
|
||||
self.assertEqual(data["paused"], "1")
|
||||
trigger_mock.assert_called_once()
|
||||
|
||||
@patch("apps.timeshift.api_views.RedisClient.get_client")
|
||||
@patch("apps.timeshift.stats.RedisClient.get_client")
|
||||
@patch("apps.timeshift.sessions.RedisClient.get_client")
|
||||
@patch("apps.timeshift.api_views.network_access_allowed", return_value=True)
|
||||
def test_position_without_playback_returns_404(
|
||||
self, _net, sessions_redis, stats_redis, api_redis,
|
||||
):
|
||||
sessions_redis.return_value = self.redis
|
||||
stats_redis.return_value = self.redis
|
||||
api_redis.return_value = self.redis
|
||||
created = self.client.post(
|
||||
"/api/catchup/sessions/",
|
||||
{
|
||||
"channel_uuid": str(self.channel.uuid),
|
||||
"start": "2026-06-08T17:00:00Z",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
session_id = created.json()["session_id"]
|
||||
response = self.client.post(
|
||||
f"/api/catchup/sessions/{session_id}/position/",
|
||||
{"position_secs": 10},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class CatchupSessionResolveTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
|
@ -235,6 +400,32 @@ class CatchupSessionResolveTests(TestCase):
|
|||
sessions.resolve_catchup_playback(session_id, other_uuid),
|
||||
)
|
||||
|
||||
@patch.object(sessions.RedisClient, "get_client")
|
||||
def test_create_and_resolve_round_trips_duration(self, redis_mock):
|
||||
redis_mock.return_value = self.redis
|
||||
payload = sessions.create_catchup_session(
|
||||
user=self.user, channel=self.channel, start="2026-06-08T17:00:00Z",
|
||||
duration=30,
|
||||
)
|
||||
self.assertEqual(payload["duration"], 30)
|
||||
resolved = sessions.resolve_catchup_playback(
|
||||
payload["session_id"], self.channel.uuid,
|
||||
)
|
||||
self.assertIsNotNone(resolved)
|
||||
self.assertEqual(resolved[2], "30")
|
||||
|
||||
@patch.object(sessions.RedisClient, "get_client")
|
||||
def test_create_without_duration_resolves_none(self, redis_mock):
|
||||
redis_mock.return_value = self.redis
|
||||
payload = sessions.create_catchup_session(
|
||||
user=self.user, channel=self.channel, start="2026-06-08T17:00:00Z",
|
||||
)
|
||||
self.assertIsNone(payload["duration"])
|
||||
resolved = sessions.resolve_catchup_playback(
|
||||
payload["session_id"], self.channel.uuid,
|
||||
)
|
||||
self.assertIsNone(resolved[2])
|
||||
|
||||
|
||||
class CatchupProxySessionAuthTests(TestCase):
|
||||
"""Playback via API session without JWT."""
|
||||
|
|
@ -252,7 +443,7 @@ class CatchupProxySessionAuthTests(TestCase):
|
|||
self, channel_cls, _access, serve, _net, resolve_mock,
|
||||
):
|
||||
user = MagicMock(id=42, is_authenticated=False)
|
||||
resolve_mock.return_value = (user, "2026-06-08T17:00:00Z")
|
||||
resolve_mock.return_value = (user, "2026-06-08T17:00:00Z", None)
|
||||
channel_cls.objects.get.return_value = MagicMock(
|
||||
id=8, uuid=self.channel_uuid,
|
||||
)
|
||||
|
|
@ -277,7 +468,7 @@ class CatchupProxySessionAuthTests(TestCase):
|
|||
@patch.object(views, "resolve_catchup_playback")
|
||||
@patch.object(views, "network_access_allowed", return_value=True)
|
||||
def test_mismatched_jwt_and_session_returns_403(self, _net, resolve_mock):
|
||||
resolve_mock.return_value = (MagicMock(id=1), "2026-06-08T17:00:00Z")
|
||||
resolve_mock.return_value = (MagicMock(id=1), "2026-06-08T17:00:00Z", None)
|
||||
request = self.factory.get(
|
||||
f"/proxy/catchup/{self.channel_uuid}?session_id=test",
|
||||
)
|
||||
|
|
@ -312,7 +503,7 @@ class CatchupProxySessionAuthTests(TestCase):
|
|||
patch.object(views, "_serve_catchup", return_value=HttpResponse("ok")) as serve:
|
||||
channel_cls.objects.get.return_value = MagicMock(id=8)
|
||||
response = views.timeshift_proxy(
|
||||
request, "u", "p", "8", "2026-06-08:17-00", "8.ts",
|
||||
request, "u", "p", "40", "2026-06-08:17-00", "8.ts",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
serve.assert_called_once()
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from apps.timeshift.stats import (
|
|||
resolve_stats_playback_fields,
|
||||
seed_stream_stats_metadata,
|
||||
stream_stats_to_metadata_fields,
|
||||
update_catchup_session_position,
|
||||
)
|
||||
from apps.timeshift.tests.test_views import _FakeRedis
|
||||
|
||||
|
|
@ -55,6 +56,43 @@ class GetProgrammeInfoTests(TestCase):
|
|||
self.assertEqual(info["sub_title"], "Local Edition")
|
||||
self.assertEqual(info["duration_secs"], 45 * 60)
|
||||
|
||||
def test_get_programme_info_advances_past_end(self):
|
||||
from datetime import datetime, timedelta, timezone as dt_timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
start = datetime(2026, 6, 8, 17, 0, tzinfo=dt_timezone.utc)
|
||||
first = MagicMock(
|
||||
title="Show A",
|
||||
sub_title="",
|
||||
description="",
|
||||
start_time=start,
|
||||
end_time=start + timedelta(minutes=30),
|
||||
)
|
||||
second = MagicMock(
|
||||
title="Show B",
|
||||
sub_title="",
|
||||
description="",
|
||||
start_time=start + timedelta(minutes=30),
|
||||
end_time=start + timedelta(minutes=60),
|
||||
)
|
||||
channel = MagicMock()
|
||||
|
||||
def _filter(**kwargs):
|
||||
qs = MagicMock()
|
||||
dt = kwargs["start_time__lte"]
|
||||
chosen = None
|
||||
for prog in (first, second):
|
||||
if prog.start_time <= dt and prog.end_time > dt:
|
||||
chosen = prog
|
||||
break
|
||||
qs.first.return_value = chosen
|
||||
return qs
|
||||
|
||||
channel.epg_data.programs.filter.side_effect = _filter
|
||||
info = get_programme_info(channel, "2026-06-08:17-00", position_secs=31 * 60)
|
||||
self.assertEqual(info["title"], "Show B")
|
||||
self.assertEqual(info["duration_secs"], 30 * 60)
|
||||
|
||||
|
||||
class ComputePlaybackPositionTests(TestCase):
|
||||
EPG_START = "2026-07-10T14:00:00+00:00"
|
||||
|
|
@ -108,6 +146,18 @@ class ComputePlaybackPositionTests(TestCase):
|
|||
)
|
||||
self.assertAlmostEqual(pos, 5 * 60)
|
||||
|
||||
def test_paused_freezes_wall_clock_advance(self):
|
||||
pos = compute_playback_position_secs(
|
||||
"2026-07-10:14-00",
|
||||
self.EPG_START,
|
||||
position_anchor_at=1000.0,
|
||||
current_time=1300.0,
|
||||
duration_secs=3600,
|
||||
playback_base_secs=900.0,
|
||||
paused=True,
|
||||
)
|
||||
self.assertAlmostEqual(pos, 900.0)
|
||||
|
||||
|
||||
class ByteRangePlaybackTests(TestCase):
|
||||
def test_compute_playback_base_from_byte_range(self):
|
||||
|
|
@ -144,6 +194,22 @@ class ByteRangePlaybackTests(TestCase):
|
|||
self.assertIsNone(base)
|
||||
self.assertEqual(anchor, "2000.0")
|
||||
|
||||
def test_resolve_near_eof_probe_keeps_existing_position(self):
|
||||
# Clients probe ~1.88MB from EOF for duration; must not flash to end.
|
||||
total = 8_783_238_116
|
||||
base, anchor = resolve_stats_playback_fields(
|
||||
timestamp_utc="2026-07-14:14-59",
|
||||
existing_programme_start="2026-07-14:14-59",
|
||||
existing_position_anchor="1000.0",
|
||||
existing_playback_base="2100.0",
|
||||
range_start=total - 1_880_000,
|
||||
representation_length=total,
|
||||
programme_duration_secs=3600,
|
||||
now="2000.0",
|
||||
)
|
||||
self.assertAlmostEqual(base, 2100.0)
|
||||
self.assertEqual(anchor, "1000.0")
|
||||
|
||||
|
||||
class TimeshiftStreamStatsTests(TestCase):
|
||||
def test_stream_stats_to_metadata_fields(self):
|
||||
|
|
@ -248,12 +314,45 @@ class BuildTimeshiftStatsDataTests(TestCase):
|
|||
self.assertNotIn("playback_position_secs", session)
|
||||
self.assertEqual(session["channel_name"], "Catch-up Stats Channel")
|
||||
self.assertEqual(session["resolution"], "1920x1080")
|
||||
self.assertFalse(session["paused"])
|
||||
self.assertEqual(session["connections"][0]["ip_address"], "10.0.0.5")
|
||||
|
||||
def test_find_stats_channel_for_session(self):
|
||||
found = find_stats_channel_for_session(self.redis, self.session_id)
|
||||
self.assertEqual(found, self.stats_channel_id)
|
||||
|
||||
def test_update_catchup_session_position_sets_base_and_pause(self):
|
||||
updated = update_catchup_session_position(
|
||||
self.session_id,
|
||||
position_secs=842.5,
|
||||
paused=True,
|
||||
user_id=1,
|
||||
redis_client=self.redis,
|
||||
)
|
||||
self.assertTrue(updated)
|
||||
client_key = RedisKeys.client_metadata(self.stats_channel_id, self.session_id)
|
||||
data = self.redis.hgetall(client_key)
|
||||
self.assertEqual(data["playback_base_secs"], "842.5")
|
||||
self.assertEqual(data["paused"], "1")
|
||||
self.assertIsNotNone(data.get("position_anchor_at"))
|
||||
|
||||
@patch("apps.timeshift.stats.Channel")
|
||||
def test_build_includes_paused_flag(self, mock_channel_model):
|
||||
channel = MagicMock()
|
||||
channel.id = self.channel_id
|
||||
channel.name = "Catch-up Stats Channel"
|
||||
channel.uuid = "00000000-0000-0000-0000-000000000042"
|
||||
channel.logo_id = None
|
||||
channel.logo = None
|
||||
mock_channel_model.objects.select_related.return_value.filter.return_value = [
|
||||
channel,
|
||||
]
|
||||
client_key = RedisKeys.client_metadata(self.stats_channel_id, self.session_id)
|
||||
self.redis.hset(client_key, mapping={"paused": "1", "playback_base_secs": "100"})
|
||||
session = build_timeshift_stats_data(self.redis)["timeshift_sessions"][0]
|
||||
self.assertTrue(session["paused"])
|
||||
self.assertEqual(session["playback_base_secs"], 100.0)
|
||||
|
||||
@patch("apps.timeshift.stats.Channel")
|
||||
def test_skips_clients_missing_required_metadata(self, mock_channel_model):
|
||||
mock_channel_model.objects.select_related.return_value.filter.return_value = []
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -280,18 +280,8 @@ class CoreSettings(models.Model):
|
|||
"epg_match_ignore_prefixes": [],
|
||||
"epg_match_ignore_suffixes": [],
|
||||
"epg_match_ignore_custom": [],
|
||||
# XC catch-up: forced XMLTV lookback (0 = auto-detect, capped at 30).
|
||||
"xmltv_prev_days_override": 0,
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def get_xmltv_prev_days_override(cls):
|
||||
"""Global XC XMLTV prev_days default (0 = auto-detect from provider archives)."""
|
||||
try:
|
||||
return int(cls.get_epg_settings().get("xmltv_prev_days_override", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def _safe_string_list(cls, value):
|
||||
"""Return a list of strings, filtering out non-list or non-string values."""
|
||||
|
|
|
|||
|
|
@ -710,7 +710,7 @@ def validate_flexible_url(value):
|
|||
# Matches: http://hostname, https://hostname/, http://hostname:port/path/to/file.xml, rtp://192.168.2.1, rtsp://192.168.178.1, udp://239.0.0.1:1234
|
||||
# Also matches FQDNs for rtsp/rtp/udp protocols: rtsp://FQDN/path?query=value
|
||||
# Also supports authentication: rtsp://user:pass@hostname/path
|
||||
non_fqdn_pattern = r'^(rts?p|https?|udp)://([a-zA-Z0-9_\-\.]+:[^\s@]+@)?([a-zA-Z0-9]([a-zA-Z0-9\-\.]{0,61}[a-zA-Z0-9])?|[0-9.]+)?(\:[0-9]+)?(/[^\s]*)?$'
|
||||
non_fqdn_pattern = r'^(rts?p|https?|udp)://([a-zA-Z0-9_\-\.]+:[^\s@]+@)?([a-zA-Z0-9]([a-zA-Z0-9_\-\.]{0,61}[a-zA-Z0-9])?|[0-9.]+)?(\:[0-9]+)?(/[^\s]*)?$'
|
||||
non_fqdn_match = re.match(non_fqdn_pattern, value)
|
||||
|
||||
if non_fqdn_match:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from .routing import websocket_urlpatterns
|
|||
from apps.output.views import xc_player_api, xc_panel_api, xc_get, xc_xmltv
|
||||
from apps.proxy.live_proxy.views import stream_xc
|
||||
from apps.proxy.vod_proxy.views import stream_xc_movie, stream_xc_episode
|
||||
from apps.timeshift.views import timeshift_proxy
|
||||
from apps.timeshift.views import timeshift_proxy, timeshift_proxy_query
|
||||
|
||||
urlpatterns = [
|
||||
# API Routes
|
||||
|
|
@ -43,10 +43,15 @@ urlpatterns = [
|
|||
name="xc_stream_endpoint",
|
||||
),
|
||||
path(
|
||||
"timeshift/<str:username>/<str:password>/<str:stream_id>/<str:timestamp>/<str:duration>",
|
||||
"timeshift/<str:username>/<str:password>/<str:duration>/<str:timestamp>/<str:channel_id>",
|
||||
timeshift_proxy,
|
||||
name="timeshift_proxy",
|
||||
),
|
||||
path(
|
||||
"streaming/timeshift.php",
|
||||
timeshift_proxy_query,
|
||||
name="timeshift_proxy_query",
|
||||
),
|
||||
# XC VOD endpoints
|
||||
path(
|
||||
"movie/<str:username>/<str:password>/<str:stream_id>.<str:extension>",
|
||||
|
|
|
|||
|
|
@ -66,7 +66,9 @@ ignore-write-errors = true
|
|||
disable-write-exception = true
|
||||
|
||||
# Debugging settings
|
||||
py-autoreload = 1
|
||||
# Reload API workers: touch /app/.uwsgi-reload
|
||||
; py-autoreload = 1
|
||||
touch-workers-reload = /app/.uwsgi-reload
|
||||
honour-stdin = true
|
||||
|
||||
# Environment variables
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ const ProgramPreview = ({
|
|||
label = 'Now Playing:',
|
||||
timelineMode = 'live',
|
||||
playbackElapsedSeconds = 0,
|
||||
accentColor = 'green.5',
|
||||
accentIconColor = '#22c55e',
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
|
|
@ -119,8 +121,8 @@ const ProgramPreview = ({
|
|||
return (
|
||||
<>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Radio size="14" style={{ color: '#22c55e', flexShrink: 0 }} />
|
||||
<Text size="xs" fw={500} c="green.5" style={{ flexShrink: 0 }}>
|
||||
<Radio size="14" style={{ color: accentIconColor, flexShrink: 0 }} />
|
||||
<Text size="xs" fw={500} c={accentColor} style={{ flexShrink: 0 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Tooltip label={program.title}>
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ const TimeshiftConnectionCard = ({
|
|||
null;
|
||||
|
||||
// Play position from URL timestamp + EPG window + stream-open anchor.
|
||||
const playbackBaseRef = useRef({ base: null, receivedAtMs: 0 });
|
||||
const playbackBaseRef = useRef({ base: null, receivedAtMs: 0, paused: false });
|
||||
useEffect(() => {
|
||||
const computed = computeCatchupPlaybackSeconds({
|
||||
programmeStart: timeshiftSession.programme_start,
|
||||
|
|
@ -172,27 +172,35 @@ const TimeshiftConnectionCard = ({
|
|||
programDurationSecs: programmePreview?.duration_secs,
|
||||
positionAnchorAt: timeshiftSession.position_anchor_at,
|
||||
playbackBaseSecs: timeshiftSession.playback_base_secs,
|
||||
paused: Boolean(timeshiftSession.paused),
|
||||
nowMs: Date.now(),
|
||||
});
|
||||
if (computed != null) {
|
||||
playbackBaseRef.current = {
|
||||
base: computed,
|
||||
receivedAtMs: Date.now(),
|
||||
paused: Boolean(timeshiftSession.paused),
|
||||
};
|
||||
}
|
||||
}, [
|
||||
timeshiftSession.programme_start,
|
||||
timeshiftSession.position_anchor_at,
|
||||
timeshiftSession.playback_base_secs,
|
||||
timeshiftSession.paused,
|
||||
programmePreview?.start_time,
|
||||
programmePreview?.duration_secs,
|
||||
]);
|
||||
|
||||
const { base: playbackBase, receivedAtMs: playbackReceivedAtMs } =
|
||||
playbackBaseRef.current;
|
||||
const {
|
||||
base: playbackBase,
|
||||
receivedAtMs: playbackReceivedAtMs,
|
||||
paused: playbackPaused,
|
||||
} = playbackBaseRef.current;
|
||||
const playbackElapsedSeconds =
|
||||
playbackBase != null
|
||||
? playbackBase + (Date.now() - playbackReceivedAtMs) / 1000
|
||||
? playbackPaused
|
||||
? playbackBase
|
||||
: playbackBase + (Date.now() - playbackReceivedAtMs) / 1000
|
||||
: getConnectionDurationSeconds(connection);
|
||||
|
||||
const getConnectionStartTime = useCallback(
|
||||
|
|
@ -291,7 +299,9 @@ const TimeshiftConnectionCard = ({
|
|||
<ProgramPreview
|
||||
program={programmePreview}
|
||||
timelineMode="catchup"
|
||||
label="Watching:"
|
||||
label={timeshiftSession.paused ? 'Paused:' : 'Watching:'}
|
||||
accentColor={timeshiftSession.paused ? 'yellow.5' : 'green.5'}
|
||||
accentIconColor={timeshiftSession.paused ? '#eab308' : '#22c55e'}
|
||||
playbackElapsedSeconds={playbackElapsedSeconds}
|
||||
/>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Modal,
|
||||
TextInput,
|
||||
|
|
@ -13,27 +10,14 @@ import {
|
|||
Stack,
|
||||
Checkbox,
|
||||
} from '@mantine/core';
|
||||
|
||||
const BUILT_IN_COMMANDS = [
|
||||
{ value: 'ffmpeg', label: 'FFmpeg' },
|
||||
{ value: '__custom__', label: 'Custom…' },
|
||||
];
|
||||
|
||||
const COMMAND_EXAMPLES = {
|
||||
ffmpeg:
|
||||
'-i pipe:0 -c:v libx264 -b:v 2000k -vf scale=-2:720 -c:a copy -f mpegts pipe:1',
|
||||
};
|
||||
|
||||
const toCommandSelection = (command) =>
|
||||
BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__')
|
||||
? command
|
||||
: '__custom__';
|
||||
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
command: Yup.string().required('Command is required'),
|
||||
parameters: Yup.string(),
|
||||
});
|
||||
import {
|
||||
addOutputProfile,
|
||||
BUILT_IN_COMMANDS,
|
||||
COMMAND_EXAMPLES,
|
||||
getResolver,
|
||||
toCommandSelection,
|
||||
updateOutputProfile,
|
||||
} from '../../utils/forms/OutputProfileUtils';
|
||||
|
||||
const OutputProfile = ({ profile = null, isOpen, onClose }) => {
|
||||
const [commandSelection, setCommandSelection] = useState('ffmpeg');
|
||||
|
|
@ -57,7 +41,7 @@ const OutputProfile = ({ profile = null, isOpen, onClose }) => {
|
|||
watch,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resolver: yupResolver(schema),
|
||||
resolver: getResolver(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -67,9 +51,9 @@ const OutputProfile = ({ profile = null, isOpen, onClose }) => {
|
|||
|
||||
const onSubmit = async (values) => {
|
||||
if (profile?.id) {
|
||||
await API.updateOutputProfile({ id: profile.id, ...values });
|
||||
await updateOutputProfile({ id: profile.id, ...values });
|
||||
} else {
|
||||
await API.addOutputProfile(values);
|
||||
await addOutputProfile(values);
|
||||
}
|
||||
reset();
|
||||
onClose();
|
||||
|
|
|
|||
|
|
@ -1,20 +1,13 @@
|
|||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import { Button, Flex, Modal, TextInput } from '@mantine/core';
|
||||
import {
|
||||
getResolver,
|
||||
updateServerGroup,
|
||||
addServerGroup,
|
||||
} from '../../utils/forms/ServerGroupUtils';
|
||||
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
});
|
||||
|
||||
const ServerGroupForm = ({
|
||||
serverGroup = null,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSaved,
|
||||
}) => {
|
||||
const ServerGroupForm = ({ serverGroup = null, isOpen, onClose, onSaved }) => {
|
||||
const defaultValues = useMemo(
|
||||
() => ({
|
||||
name: serverGroup?.name || '',
|
||||
|
|
@ -29,16 +22,13 @@ const ServerGroupForm = ({
|
|||
reset,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resolver: yupResolver(schema),
|
||||
resolver: getResolver(),
|
||||
});
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
let response;
|
||||
if (serverGroup?.id) {
|
||||
response = await API.updateServerGroup({ id: serverGroup.id, ...values });
|
||||
} else {
|
||||
response = await API.addServerGroup(values);
|
||||
}
|
||||
const response = serverGroup?.id
|
||||
? await updateServerGroup({ id: serverGroup.id, ...values })
|
||||
: await addServerGroup(values);
|
||||
|
||||
if (response) {
|
||||
onSaved?.(response);
|
||||
|
|
|
|||
534
frontend/src/components/forms/__tests__/OutputProfile.test.jsx
Normal file
534
frontend/src/components/forms/__tests__/OutputProfile.test.jsx
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Module-level form state ────────────────────────────────────────────────────
|
||||
const __form = { values: {}, resetSpy: null };
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/forms/OutputProfileUtils', () => ({
|
||||
BUILT_IN_COMMANDS: [
|
||||
{ value: 'ffmpeg', label: 'FFmpeg' },
|
||||
{ value: '__custom__', label: 'Custom…' },
|
||||
],
|
||||
COMMAND_EXAMPLES: {
|
||||
ffmpeg: '-i pipe:0 -c:v libx264 -f mpegts pipe:1',
|
||||
},
|
||||
addOutputProfile: vi.fn(),
|
||||
updateOutputProfile: vi.fn(),
|
||||
getResolver: vi.fn(() => undefined),
|
||||
toCommandSelection: vi.fn((cmd) =>
|
||||
cmd === 'ffmpeg' ? 'ffmpeg' : '__custom__'
|
||||
),
|
||||
}));
|
||||
|
||||
// ── react-hook-form ────────────────────────────────────────────────────────────
|
||||
vi.mock('react-hook-form', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
useForm: vi.fn(({ defaultValues } = {}) => {
|
||||
const [formValues, setFormValues] = React.useState(() => {
|
||||
const vals = defaultValues || {};
|
||||
Object.assign(__form.values, vals);
|
||||
return vals;
|
||||
});
|
||||
|
||||
const updateField = (name, value) => {
|
||||
__form.values[name] = value;
|
||||
setFormValues((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const register = (name) => ({
|
||||
name,
|
||||
value: __form.values[name] ?? '',
|
||||
onChange: (e) => updateField(name, e.target.value),
|
||||
onBlur: () => {},
|
||||
});
|
||||
|
||||
const setValue = (name, value) => updateField(name, value);
|
||||
const watch = (name) => formValues[name];
|
||||
|
||||
const handleSubmit = (onSubmit) => (e) => {
|
||||
e?.preventDefault?.();
|
||||
return onSubmit({ ...__form.values });
|
||||
};
|
||||
|
||||
const resetImpl = React.useCallback((newValues) => {
|
||||
const vals = newValues || defaultValues || {};
|
||||
Object.assign(__form.values, vals);
|
||||
setFormValues({ ...vals });
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const resetRef = React.useRef(null);
|
||||
if (!resetRef.current) {
|
||||
resetRef.current = vi.fn((...args) => resetImpl(...args));
|
||||
__form.resetSpy = resetRef.current;
|
||||
}
|
||||
|
||||
return {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors: {}, isSubmitting: false },
|
||||
reset: resetRef.current,
|
||||
setValue,
|
||||
watch,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Button: ({ children, type, disabled }) => (
|
||||
<button type={type} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Checkbox: ({ label, checked, onChange }) => (
|
||||
<div>
|
||||
<label htmlFor="checkbox-is-active">{label}</label>
|
||||
<input
|
||||
id="checkbox-is-active"
|
||||
data-testid="checkbox-is-active"
|
||||
type="checkbox"
|
||||
checked={checked ?? false}
|
||||
onChange={(e) =>
|
||||
onChange({ currentTarget: { checked: e.target.checked } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
Modal: ({ children, opened, onClose, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
Select: ({ label, value, onChange, data, disabled }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<select
|
||||
data-testid={`select-${label?.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
value={value ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
{(data ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Textarea: ({
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
description,
|
||||
disabled,
|
||||
...rest
|
||||
}) => (
|
||||
<div>
|
||||
<label htmlFor={name}>{label}</label>
|
||||
{description && (
|
||||
<div data-testid={`desc-${label?.replace(/\s+/g, '-').toLowerCase()}`}>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
id={name}
|
||||
name={name}
|
||||
data-testid={`textarea-${label?.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
value={value ?? ''}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
TextInput: ({ label, name, value, onChange, error, disabled, ...rest }) => (
|
||||
<div>
|
||||
<label htmlFor={name}>{label}</label>
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
data-testid={`input-${label?.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
/>
|
||||
{error && <span data-testid={`error-${label}`}>{error}</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import OutputProfile from '../OutputProfile';
|
||||
import * as OutputProfileUtils from '../../../utils/forms/OutputProfileUtils';
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
const makeProfile = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: 'HD Transcode',
|
||||
command: 'ffmpeg',
|
||||
parameters: '-i pipe:0 -c:v copy -f mpegts pipe:1',
|
||||
is_active: true,
|
||||
locked: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
profile: null,
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('OutputProfile', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__form.values = {};
|
||||
__form.resetSpy = null;
|
||||
vi.mocked(OutputProfileUtils.addOutputProfile).mockResolvedValue(undefined);
|
||||
vi.mocked(OutputProfileUtils.updateOutputProfile).mockResolvedValue(
|
||||
undefined
|
||||
);
|
||||
vi.mocked(OutputProfileUtils.getResolver).mockReturnValue(undefined);
|
||||
vi.mocked(OutputProfileUtils.toCommandSelection).mockImplementation(
|
||||
(cmd) => (cmd === 'ffmpeg' ? 'ffmpeg' : '__custom__')
|
||||
);
|
||||
});
|
||||
|
||||
// ── Visibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when isOpen is true', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the modal when isOpen is false', () => {
|
||||
render(<OutputProfile {...defaultProps({ isOpen: false })} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Output Profile" as the modal title', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(
|
||||
'Output Profile'
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onClose when the modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<OutputProfile {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form fields ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('form fields', () => {
|
||||
it('renders the Name input', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Command select', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('select-command')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Parameters textarea', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('textarea-parameters')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Is Active checkbox', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('checkbox-is-active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Save button', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates the Command select with built-in options', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByText('FFmpeg')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Custom…').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not show Custom Command input when a built-in is selected', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(
|
||||
screen.queryByTestId('input-custom-command')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Default values ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('default values', () => {
|
||||
it('name field is empty for a new profile', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-name')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('command select defaults to ffmpeg', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('select-command')).toHaveValue('ffmpeg');
|
||||
});
|
||||
|
||||
it('is_active checkbox is checked by default', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
|
||||
});
|
||||
|
||||
it('parameters field is empty for a new profile', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('textarea-parameters')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Profile pre-fill ───────────────────────────────────────────────────────
|
||||
|
||||
describe('profile pre-fill', () => {
|
||||
it('pre-fills the name from the profile', () => {
|
||||
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(screen.getByTestId('input-name')).toHaveValue('HD Transcode');
|
||||
});
|
||||
|
||||
it('pre-fills the parameters from the profile', () => {
|
||||
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(screen.getByTestId('textarea-parameters')).toHaveValue(
|
||||
'-i pipe:0 -c:v copy -f mpegts pipe:1'
|
||||
);
|
||||
});
|
||||
|
||||
it('pre-selects the command from the profile', () => {
|
||||
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(screen.getByTestId('select-command')).toHaveValue('ffmpeg');
|
||||
});
|
||||
|
||||
it('is_active checkbox is unchecked when profile has is_active: false', () => {
|
||||
render(
|
||||
<OutputProfile
|
||||
{...defaultProps({ profile: makeProfile({ is_active: false }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('checkbox-is-active')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('shows Custom Command input when profile has a custom command', () => {
|
||||
vi.mocked(OutputProfileUtils.toCommandSelection).mockReturnValue(
|
||||
'__custom__'
|
||||
);
|
||||
render(
|
||||
<OutputProfile
|
||||
{...defaultProps({
|
||||
profile: makeProfile({ command: '/usr/bin/mycmd' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Locked profile ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('locked profile', () => {
|
||||
it('disables the Name input when profile is locked', () => {
|
||||
render(
|
||||
<OutputProfile
|
||||
{...defaultProps({ profile: makeProfile({ locked: true }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('input-name')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables the Command select when profile is locked', () => {
|
||||
render(
|
||||
<OutputProfile
|
||||
{...defaultProps({ profile: makeProfile({ locked: true }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('select-command')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables the Parameters textarea when profile is locked', () => {
|
||||
render(
|
||||
<OutputProfile
|
||||
{...defaultProps({ profile: makeProfile({ locked: true }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('textarea-parameters')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not disable inputs when profile is not locked', () => {
|
||||
render(
|
||||
<OutputProfile
|
||||
{...defaultProps({ profile: makeProfile({ locked: false }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('input-name')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('select-command')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('textarea-parameters')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Command selection ──────────────────────────────────────────────────────
|
||||
|
||||
describe('command selection', () => {
|
||||
it('shows Custom Command input when Custom… is selected', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: '__custom__' },
|
||||
});
|
||||
expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Custom Command input when switching back to a built-in', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: '__custom__' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: 'ffmpeg' },
|
||||
});
|
||||
expect(
|
||||
screen.queryByTestId('input-custom-command')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets command form value when switching to a built-in', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: 'ffmpeg' },
|
||||
});
|
||||
expect(__form.values.command).toBe('ffmpeg');
|
||||
});
|
||||
|
||||
it('clears command form value when Custom… is selected', () => {
|
||||
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: '__custom__' },
|
||||
});
|
||||
expect(__form.values.command).toBe('');
|
||||
});
|
||||
|
||||
it('shows parameters example in the description for ffmpeg', () => {
|
||||
const { container } = render(
|
||||
<OutputProfile {...defaultProps({ profile: makeProfile() })} />
|
||||
);
|
||||
expect(container.textContent).toMatch(/-i pipe:0/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Is Active checkbox ─────────────────────────────────────────────────────
|
||||
|
||||
describe('Is Active checkbox', () => {
|
||||
it('toggles is_active to false when unchecked', () => {
|
||||
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
fireEvent.click(screen.getByTestId('checkbox-is-active'));
|
||||
expect(__form.values.is_active).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles is_active to true when checked', () => {
|
||||
render(
|
||||
<OutputProfile
|
||||
{...defaultProps({ profile: makeProfile({ is_active: false }) })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('checkbox-is-active'));
|
||||
expect(__form.values.is_active).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form submission ────────────────────────────────────────────────────────
|
||||
|
||||
describe('form submission', () => {
|
||||
it('calls addOutputProfile when submitting a new profile', async () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('input-name'), {
|
||||
target: { value: 'New Profile' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(OutputProfileUtils.addOutputProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'New Profile', command: 'ffmpeg' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call updateOutputProfile for a new profile', async () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(OutputProfileUtils.addOutputProfile).toHaveBeenCalled();
|
||||
});
|
||||
expect(OutputProfileUtils.updateOutputProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls updateOutputProfile when submitting an existing profile', async () => {
|
||||
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(OutputProfileUtils.updateOutputProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 1, name: 'HD Transcode' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call addOutputProfile for an existing profile', async () => {
|
||||
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(OutputProfileUtils.updateOutputProfile).toHaveBeenCalled();
|
||||
});
|
||||
expect(OutputProfileUtils.addOutputProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose after successful submission', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<OutputProfile {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls reset after successful submission', async () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(__form.resetSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes is_active value to addOutputProfile', async () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('checkbox-is-active')); // uncheck → false
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(OutputProfileUtils.addOutputProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ is_active: false })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
363
frontend/src/components/forms/__tests__/ServerGroup.test.jsx
Normal file
363
frontend/src/components/forms/__tests__/ServerGroup.test.jsx
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Module-level form state ────────────────────────────────────────────────────
|
||||
const __form = { values: {}, resetSpy: null };
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/forms/ServerGroupUtils', () => ({
|
||||
addServerGroup: vi.fn(),
|
||||
updateServerGroup: vi.fn(),
|
||||
getResolver: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
// ── react-hook-form ────────────────────────────────────────────────────────────
|
||||
vi.mock('react-hook-form', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
useForm: vi.fn(({ defaultValues } = {}) => {
|
||||
const [_formValues, setFormValues] = React.useState(() => {
|
||||
const vals = defaultValues || {};
|
||||
Object.assign(__form.values, vals);
|
||||
return vals;
|
||||
});
|
||||
|
||||
const updateField = (name, value) => {
|
||||
__form.values[name] = value;
|
||||
setFormValues((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const register = (name) => ({
|
||||
name,
|
||||
value: __form.values[name] ?? '',
|
||||
onChange: (e) => updateField(name, e.target.value),
|
||||
onBlur: () => {},
|
||||
});
|
||||
|
||||
const handleSubmit = (onSubmit) => (e) => {
|
||||
e?.preventDefault?.();
|
||||
return onSubmit({ ...__form.values });
|
||||
};
|
||||
|
||||
const resetImpl = React.useCallback((newValues) => {
|
||||
const vals = newValues || defaultValues || {};
|
||||
Object.assign(__form.values, vals);
|
||||
setFormValues({ ...vals });
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const resetRef = React.useRef(null);
|
||||
if (!resetRef.current) {
|
||||
resetRef.current = vi.fn((...args) => resetImpl(...args));
|
||||
__form.resetSpy = resetRef.current;
|
||||
}
|
||||
|
||||
return {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors: {}, isSubmitting: false },
|
||||
reset: resetRef.current,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Button: ({ children, type, disabled }) => (
|
||||
<button type={type} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
Modal: ({ children, opened, onClose, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
TextInput: ({
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
description,
|
||||
...rest
|
||||
}) => (
|
||||
<div>
|
||||
<label htmlFor={name}>{label}</label>
|
||||
{description && <div>{description}</div>}
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
data-testid={`input-${label?.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
{error && <span data-testid={`error-${label}`}>{error}</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import ServerGroupForm from '../ServerGroup';
|
||||
import * as ServerGroupUtils from '../../../utils/forms/ServerGroupUtils';
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
const makeServerGroup = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: 'US East',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
serverGroup: null,
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
onSaved: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ServerGroupForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__form.values = {};
|
||||
__form.resetSpy = null;
|
||||
vi.mocked(ServerGroupUtils.addServerGroup).mockResolvedValue({
|
||||
id: 2,
|
||||
name: 'New Group',
|
||||
});
|
||||
vi.mocked(ServerGroupUtils.updateServerGroup).mockResolvedValue({
|
||||
id: 1,
|
||||
name: 'Updated',
|
||||
});
|
||||
vi.mocked(ServerGroupUtils.getResolver).mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
// ── Visibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the form when isOpen is true', () => {
|
||||
render(<ServerGroupForm {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when isOpen is false', () => {
|
||||
render(<ServerGroupForm {...defaultProps({ isOpen: false })} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Server Group" as the modal title', () => {
|
||||
render(<ServerGroupForm {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(
|
||||
'Server Group'
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onClose when the modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ServerGroupForm {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form fields ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('form fields', () => {
|
||||
it('renders the Name input', () => {
|
||||
render(<ServerGroupForm {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Submit button', () => {
|
||||
render(<ServerGroupForm {...defaultProps()} />);
|
||||
expect(screen.getByText('Submit')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Default values ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('default values', () => {
|
||||
it('name input is empty when no serverGroup is provided', () => {
|
||||
render(<ServerGroupForm {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-name')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('pre-fills the name from the serverGroup prop', () => {
|
||||
render(
|
||||
<ServerGroupForm
|
||||
{...defaultProps({
|
||||
serverGroup: makeServerGroup({ name: 'EU West' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('input-name')).toHaveValue('EU West');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Create (no id) ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('create (no serverGroup.id)', () => {
|
||||
it('calls addServerGroup with the form values', async () => {
|
||||
render(<ServerGroupForm {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('input-name'), {
|
||||
target: { value: 'New Group' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(ServerGroupUtils.addServerGroup).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'New Group' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call updateServerGroup when creating', async () => {
|
||||
render(<ServerGroupForm {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(ServerGroupUtils.addServerGroup).toHaveBeenCalled();
|
||||
});
|
||||
expect(ServerGroupUtils.updateServerGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSaved with the API response when response is truthy', async () => {
|
||||
const onSaved = vi.fn();
|
||||
const response = { id: 5, name: 'New Group' };
|
||||
vi.mocked(ServerGroupUtils.addServerGroup).mockResolvedValue(response);
|
||||
render(<ServerGroupForm {...defaultProps({ onSaved })} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(onSaved).toHaveBeenCalledWith(response);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call onSaved when addServerGroup returns null', async () => {
|
||||
vi.mocked(ServerGroupUtils.addServerGroup).mockResolvedValue(null);
|
||||
const onSaved = vi.fn();
|
||||
render(<ServerGroupForm {...defaultProps({ onSaved })} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(ServerGroupUtils.addServerGroup).toHaveBeenCalled();
|
||||
});
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose after submission regardless of response', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ServerGroupForm {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls reset after submission', async () => {
|
||||
render(<ServerGroupForm {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(__form.resetSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Update (with id) ───────────────────────────────────────────────────────
|
||||
|
||||
describe('update (serverGroup with id)', () => {
|
||||
it('calls updateServerGroup with id and form values', async () => {
|
||||
render(
|
||||
<ServerGroupForm
|
||||
{...defaultProps({
|
||||
serverGroup: makeServerGroup({ id: 7, name: 'Old Name' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('input-name'), {
|
||||
target: { value: 'New Name' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(ServerGroupUtils.updateServerGroup).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 7, name: 'New Name' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call addServerGroup when updating', async () => {
|
||||
render(
|
||||
<ServerGroupForm
|
||||
{...defaultProps({ serverGroup: makeServerGroup() })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(ServerGroupUtils.updateServerGroup).toHaveBeenCalled();
|
||||
});
|
||||
expect(ServerGroupUtils.addServerGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSaved with the updated response', async () => {
|
||||
const updated = { id: 1, name: 'Updated Name' };
|
||||
vi.mocked(ServerGroupUtils.updateServerGroup).mockResolvedValue(updated);
|
||||
const onSaved = vi.fn();
|
||||
render(
|
||||
<ServerGroupForm
|
||||
{...defaultProps({ serverGroup: makeServerGroup(), onSaved })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(onSaved).toHaveBeenCalledWith(updated);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call onSaved when updateServerGroup returns null', async () => {
|
||||
vi.mocked(ServerGroupUtils.updateServerGroup).mockResolvedValue(null);
|
||||
const onSaved = vi.fn();
|
||||
render(
|
||||
<ServerGroupForm
|
||||
{...defaultProps({ serverGroup: makeServerGroup(), onSaved })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(ServerGroupUtils.updateServerGroup).toHaveBeenCalled();
|
||||
});
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose after update regardless of response', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ServerGroupForm
|
||||
{...defaultProps({ serverGroup: makeServerGroup(), onClose })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── onSaved optional ───────────────────────────────────────────────────────
|
||||
|
||||
describe('onSaved optional', () => {
|
||||
it('does not throw when onSaved is not provided and response is truthy', async () => {
|
||||
render(<ServerGroupForm isOpen={true} onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await expect(
|
||||
waitFor(() =>
|
||||
expect(ServerGroupUtils.addServerGroup).toHaveBeenCalled()
|
||||
)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import useSettingsStore from '../../../store/settings.jsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { Alert, Button, Flex, NumberInput, Stack, Text } from '@mantine/core';
|
||||
import { EPG_SETTINGS_OPTIONS } from '../../../constants.js';
|
||||
import {
|
||||
getChangedSettings,
|
||||
parseSettings,
|
||||
saveChangedSettings,
|
||||
} from '../../../utils/pages/SettingsUtils.js';
|
||||
import { getEpgSettingsFormInitialValues } from '../../../utils/forms/settings/EpgSettingsFormUtils.js';
|
||||
|
||||
const EpgSettingsForm = React.memo(({ active }) => {
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
mode: 'controlled',
|
||||
initialValues: getEpgSettingsFormInitialValues(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) setSaved(false);
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
const parsed = parseSettings(settings);
|
||||
form.setFieldValue(
|
||||
'xmltv_prev_days_override',
|
||||
parsed.xmltv_prev_days_override ?? 0
|
||||
);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const onSubmit = async () => {
|
||||
setSaved(false);
|
||||
const changedSettings = getChangedSettings(form.getValues(), settings);
|
||||
try {
|
||||
await saveChangedSettings(settings, changedSettings);
|
||||
setSaved(true);
|
||||
} catch (error) {
|
||||
console.error('Error saving EPG settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const prevDaysConfig = EPG_SETTINGS_OPTIONS.xmltv_prev_days_override;
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
{saved && (
|
||||
<Alert variant="light" color="green" title="Saved Successfully" />
|
||||
)}
|
||||
<NumberInput
|
||||
label={prevDaysConfig.label}
|
||||
description={prevDaysConfig.description}
|
||||
min={0}
|
||||
max={30}
|
||||
{...form.getInputProps('xmltv_prev_days_override')}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
Per-user defaults and URL parameters still override this global value.
|
||||
EPG channel matching options are configured from the Channels page.
|
||||
</Text>
|
||||
<Flex justify="flex-end">
|
||||
<Button type="submit">Save</Button>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
export default EpgSettingsForm;
|
||||
|
|
@ -1,417 +0,0 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import EpgSettingsForm from '../EpgSettingsForm';
|
||||
|
||||
// ── Store mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── @mantine/form mock ────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/form', () => ({ useForm: vi.fn() }));
|
||||
|
||||
// ── @mantine/core mock ────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Alert: ({ title, color, variant }) => (
|
||||
<div data-testid="alert" data-color={color} data-variant={variant}>
|
||||
{title}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, type, disabled }) => (
|
||||
<button type={type} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Flex: ({ children, justify }) => <div data-justify={justify}>{children}</div>,
|
||||
NumberInput: ({ label, description, min, max, value, onChange }) => (
|
||||
<div>
|
||||
{label && <label data-testid="number-input-label">{label}</label>}
|
||||
{description && (
|
||||
<span data-testid="number-input-description">{description}</span>
|
||||
)}
|
||||
<input
|
||||
data-testid="number-input"
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value ?? 0}
|
||||
onChange={(e) => onChange?.(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
Stack: ({ children, gap }) => <div data-gap={gap}>{children}</div>,
|
||||
Text: ({ children, size, c }) => (
|
||||
<span data-testid="text" data-size={size} data-color={c}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── SettingsUtils mock ────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../utils/pages/SettingsUtils.js', () => ({
|
||||
getChangedSettings: vi.fn(),
|
||||
parseSettings: vi.fn(),
|
||||
saveChangedSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── EpgSettingsFormUtils mock ──────────────────────────────────────────────────
|
||||
vi.mock('../../../../utils/forms/settings/EpgSettingsFormUtils.js', () => ({
|
||||
getEpgSettingsFormInitialValues: vi.fn(() => ({
|
||||
xmltv_prev_days_override: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Imports after mocks
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
import useSettingsStore from '../../../../store/settings.jsx';
|
||||
import { useForm } from '@mantine/form';
|
||||
import * as SettingsUtils from '../../../../utils/pages/SettingsUtils.js';
|
||||
import { getEpgSettingsFormInitialValues } from '../../../../utils/forms/settings/EpgSettingsFormUtils.js';
|
||||
import { EPG_SETTINGS_OPTIONS } from '../../../../constants.js';
|
||||
|
||||
// ── Form mock factory ─────────────────────────────────────────────────────────
|
||||
|
||||
let mockForm;
|
||||
|
||||
const createMockForm = (initialValues = { xmltv_prev_days_override: 0 }) => {
|
||||
const state = { ...initialValues };
|
||||
return {
|
||||
values: state,
|
||||
setFieldValue: vi.fn((field, value) => {
|
||||
state[field] = value;
|
||||
}),
|
||||
getInputProps: vi.fn((field) => ({
|
||||
value: state[field],
|
||||
onChange: vi.fn((val) => {
|
||||
state[field] = val;
|
||||
}),
|
||||
})),
|
||||
getValues: vi.fn(() => ({ ...state })),
|
||||
onSubmit: vi.fn((handler) => (e) => {
|
||||
e?.preventDefault?.();
|
||||
return handler();
|
||||
}),
|
||||
submitting: false,
|
||||
};
|
||||
};
|
||||
|
||||
// ── Settings factories ────────────────────────────────────────────────────────
|
||||
|
||||
const makeSettings = (epgValue = {}) => ({
|
||||
epg_settings: { id: 1, value: epgValue },
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('EpgSettingsForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockForm = createMockForm();
|
||||
vi.mocked(useForm).mockReturnValue(mockForm);
|
||||
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ settings: null })
|
||||
);
|
||||
|
||||
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({
|
||||
xmltv_prev_days_override: 0,
|
||||
});
|
||||
vi.mocked(SettingsUtils.getChangedSettings).mockReturnValue({});
|
||||
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders without crashing', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(screen.getByTestId('number-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the NumberInput label from EPG_SETTINGS_OPTIONS', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(
|
||||
screen.getByText(EPG_SETTINGS_OPTIONS.xmltv_prev_days_override.label)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the NumberInput description from EPG_SETTINGS_OPTIONS', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
EPG_SETTINGS_OPTIONS.xmltv_prev_days_override.description
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the disclaimer text about per-user defaults', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Per-user defaults and URL parameters still override this global value/i
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the EPG channel matching hint', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
/EPG channel matching options are configured from the Channels page/i
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a Save button of type="submit"', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
const btn = screen.getByText('Save');
|
||||
expect(btn).toBeInTheDocument();
|
||||
expect(btn).toHaveAttribute('type', 'submit');
|
||||
});
|
||||
|
||||
it('does not show the "Saved Successfully" alert initially', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('NumberInput has min=0', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(screen.getByTestId('number-input')).toHaveAttribute('min', '0');
|
||||
});
|
||||
|
||||
it('NumberInput has max=30', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(screen.getByTestId('number-input')).toHaveAttribute('max', '30');
|
||||
});
|
||||
|
||||
it('NumberInput starts with value 0 from initial form values', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(screen.getByTestId('number-input')).toHaveValue(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Initialization ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('initialization', () => {
|
||||
it('calls getEpgSettingsFormInitialValues to seed useForm', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(getEpgSettingsFormInitialValues).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes initial values from getEpgSettingsFormInitialValues to useForm', () => {
|
||||
vi.mocked(getEpgSettingsFormInitialValues).mockReturnValue({
|
||||
xmltv_prev_days_override: 5,
|
||||
});
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(vi.mocked(useForm)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialValues: { xmltv_prev_days_override: 5 },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('calls useForm with mode="controlled"', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(vi.mocked(useForm)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: 'controlled' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Settings effect ────────────────────────────────────────────────────────
|
||||
|
||||
describe('settings effect', () => {
|
||||
it('calls parseSettings when settings are provided', () => {
|
||||
const settings = makeSettings({ xmltv_prev_days_override: 7 });
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ settings })
|
||||
);
|
||||
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({
|
||||
xmltv_prev_days_override: 7,
|
||||
});
|
||||
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
|
||||
expect(SettingsUtils.parseSettings).toHaveBeenCalledWith(settings);
|
||||
});
|
||||
|
||||
it('calls setFieldValue with parsed xmltv_prev_days_override', () => {
|
||||
const settings = makeSettings({ xmltv_prev_days_override: 14 });
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ settings })
|
||||
);
|
||||
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({
|
||||
xmltv_prev_days_override: 14,
|
||||
});
|
||||
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
|
||||
expect(mockForm.setFieldValue).toHaveBeenCalledWith(
|
||||
'xmltv_prev_days_override',
|
||||
14
|
||||
);
|
||||
});
|
||||
|
||||
it('calls setFieldValue with 0 when parsed value is undefined', () => {
|
||||
const settings = makeSettings({});
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ settings })
|
||||
);
|
||||
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({});
|
||||
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
|
||||
expect(mockForm.setFieldValue).toHaveBeenCalledWith(
|
||||
'xmltv_prev_days_override',
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call parseSettings when settings is null', () => {
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ settings: null })
|
||||
);
|
||||
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
|
||||
expect(SettingsUtils.parseSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── active prop effect ─────────────────────────────────────────────────────
|
||||
|
||||
describe('active prop effect', () => {
|
||||
it('resets the saved alert when active changes to false', async () => {
|
||||
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
|
||||
const { rerender } = render(<EpgSettingsForm active={true} />);
|
||||
|
||||
// Trigger a successful save to get saved=true
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Switching active to false should dismiss the alert
|
||||
rerender(<EpgSettingsForm active={false} />);
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show alert when active starts as false', () => {
|
||||
render(<EpgSettingsForm active={false} />);
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Submit — success ───────────────────────────────────────────────────────
|
||||
|
||||
describe('submit — success', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('calls getChangedSettings with form values and settings on submit', async () => {
|
||||
const settings = makeSettings({ xmltv_prev_days_override: 3 });
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ settings })
|
||||
);
|
||||
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
// Set the value after render so the settings useEffect (which resets the
|
||||
// field to the parseSettings result) has already run and won't overwrite it.
|
||||
mockForm.values.xmltv_prev_days_override = 5;
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(SettingsUtils.getChangedSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ xmltv_prev_days_override: 5 }),
|
||||
settings
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls saveChangedSettings with settings and changedSettings on submit', async () => {
|
||||
const settings = makeSettings({ xmltv_prev_days_override: 3 });
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ settings })
|
||||
);
|
||||
const changed = { xmltv_prev_days_override: 7 };
|
||||
vi.mocked(SettingsUtils.getChangedSettings).mockReturnValue(changed);
|
||||
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(SettingsUtils.saveChangedSettings).toHaveBeenCalledWith(
|
||||
settings,
|
||||
changed
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Saved Successfully" alert after a successful save', async () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Saved Successfully')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears saved state before re-submitting', async () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
|
||||
// First save
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Second save — saved resets to false then true again
|
||||
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Submit — error ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('submit — error', () => {
|
||||
it('does not show alert when saveChangedSettings throws', async () => {
|
||||
vi.mocked(SettingsUtils.saveChangedSettings).mockRejectedValue(
|
||||
new Error('network error')
|
||||
);
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(SettingsUtils.saveChangedSettings).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not throw when saveChangedSettings rejects', async () => {
|
||||
vi.mocked(SettingsUtils.saveChangedSettings).mockRejectedValue(
|
||||
new Error('fail')
|
||||
);
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
|
||||
await expect(
|
||||
waitFor(() => fireEvent.click(screen.getByText('Save')))
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getInputProps wiring ───────────────────────────────────────────────────
|
||||
|
||||
describe('getInputProps wiring', () => {
|
||||
it('calls form.getInputProps with xmltv_prev_days_override', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(mockForm.getInputProps).toHaveBeenCalledWith(
|
||||
'xmltv_prev_days_override'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
Stack,
|
||||
Text,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
NumberInput,
|
||||
Checkbox,
|
||||
Group,
|
||||
|
|
@ -101,11 +102,7 @@ const CreateChannelModal = ({
|
|||
|
||||
<Divider label="Channel Number" labelPosition="left" />
|
||||
|
||||
<Radio.Group
|
||||
value={mode}
|
||||
onChange={onModeChange}
|
||||
label={numberingLabel}
|
||||
>
|
||||
<RadioGroup value={mode} onChange={onModeChange} label={numberingLabel}>
|
||||
<Stack mt="xs" spacing="xs">
|
||||
<Radio
|
||||
value="provider"
|
||||
|
|
@ -148,7 +145,7 @@ const CreateChannelModal = ({
|
|||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
</RadioGroup>
|
||||
|
||||
{mode === customModeValue && (
|
||||
<NumberInput
|
||||
|
|
|
|||
567
frontend/src/components/modals/ManageReposModal.jsx
Normal file
567
frontend/src/components/modals/ManageReposModal.jsx
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { KeyRound, Plus, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import ConfirmationDialog from '../ConfirmationDialog.jsx';
|
||||
import { usePluginStore } from '../../store/plugins.jsx';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
import {
|
||||
getPluginRepoSettings,
|
||||
previewPluginRepo,
|
||||
updatePluginRepoSettings,
|
||||
} from '../../utils/pages/PluginsUtils.js';
|
||||
|
||||
export default function ManageReposModal({ opened, onClose }) {
|
||||
const repos = usePluginStore((s) => s.repos);
|
||||
const reposLoading = usePluginStore((s) => s.reposLoading);
|
||||
const fetchAvailablePlugins = usePluginStore((s) => s.fetchAvailablePlugins);
|
||||
const refreshRepo = usePluginStore((s) => s.refreshRepo);
|
||||
const addRepo = usePluginStore((s) => s.addRepo);
|
||||
const removeRepo = usePluginStore((s) => s.removeRepo);
|
||||
const updateRepo = usePluginStore((s) => s.updateRepo);
|
||||
|
||||
const [refreshInterval, setRefreshInterval] = useState(6);
|
||||
const [savingInterval, setSavingInterval] = useState(false);
|
||||
const saveIntervalTimer = useRef(null);
|
||||
|
||||
const [editingKeyRepoId, setEditingKeyRepoId] = useState(null);
|
||||
const [editKeyValue, setEditKeyValue] = useState('');
|
||||
const [savingKey, setSavingKey] = useState(false);
|
||||
|
||||
const [showAddRepo, setShowAddRepo] = useState(false);
|
||||
const [newRepoUrl, setNewRepoUrl] = useState('');
|
||||
const [newRepoPublicKey, setNewRepoPublicKey] = useState('');
|
||||
const [addingRepo, setAddingRepo] = useState(false);
|
||||
const [gpgKeyFocused, setGpgKeyFocused] = useState(false);
|
||||
const [repoPreview, setRepoPreview] = useState(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const previewTimer = useRef(null);
|
||||
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
|
||||
|
||||
const loadRepoSettings = useCallback(async () => {
|
||||
const data = await getPluginRepoSettings();
|
||||
if (data) setRefreshInterval(data.refresh_interval_hours ?? 6);
|
||||
}, []);
|
||||
|
||||
const handleSaveInterval = useCallback((val) => {
|
||||
const hours = val ?? 0;
|
||||
setRefreshInterval(hours);
|
||||
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
|
||||
saveIntervalTimer.current = setTimeout(async () => {
|
||||
setSavingInterval(true);
|
||||
try {
|
||||
await updatePluginRepoSettings({ refresh_interval_hours: hours });
|
||||
} catch {
|
||||
// Error notification handled by API layer
|
||||
} finally {
|
||||
setSavingInterval(false);
|
||||
}
|
||||
}, 800);
|
||||
}, []);
|
||||
|
||||
// Debounced manifest preview
|
||||
const fetchPreview = useCallback((url, publicKey) => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
if (!url.trim() || !url.match(/^https?:\/\/.+/i)) {
|
||||
setRepoPreview(null);
|
||||
setPreviewLoading(false);
|
||||
return;
|
||||
}
|
||||
setPreviewLoading(true);
|
||||
previewTimer.current = setTimeout(async () => {
|
||||
const result = await previewPluginRepo(url.trim(), publicKey?.trim());
|
||||
setRepoPreview(result);
|
||||
setPreviewLoading(false);
|
||||
}, 600);
|
||||
}, []);
|
||||
|
||||
const handleAddRepo = useCallback(async () => {
|
||||
if (!newRepoUrl.trim()) return;
|
||||
setAddingRepo(true);
|
||||
try {
|
||||
await addRepo({
|
||||
url: newRepoUrl.trim(),
|
||||
public_key: newRepoPublicKey.trim(),
|
||||
});
|
||||
setNewRepoUrl('');
|
||||
setNewRepoPublicKey('');
|
||||
setRepoPreview(null);
|
||||
setShowAddRepo(false);
|
||||
await fetchAvailablePlugins();
|
||||
showNotification({
|
||||
title: 'Added',
|
||||
message: 'Plugin repo added',
|
||||
color: 'green',
|
||||
});
|
||||
} catch {
|
||||
// Error notification handled by API layer
|
||||
} finally {
|
||||
setAddingRepo(false);
|
||||
}
|
||||
}, [newRepoUrl, newRepoPublicKey, addRepo, fetchAvailablePlugins]);
|
||||
|
||||
const handleDeleteRepo = useCallback(
|
||||
async (id) => {
|
||||
await removeRepo(id);
|
||||
setDeleteConfirmId(null);
|
||||
await fetchAvailablePlugins();
|
||||
showNotification({
|
||||
title: 'Removed',
|
||||
message: 'Plugin repo removed',
|
||||
color: 'green',
|
||||
});
|
||||
},
|
||||
[removeRepo, fetchAvailablePlugins]
|
||||
);
|
||||
|
||||
const handleEditKey = useCallback((repo) => {
|
||||
setEditingKeyRepoId(repo.id);
|
||||
setEditKeyValue(repo.public_key || '');
|
||||
}, []);
|
||||
|
||||
const handleSaveKey = useCallback(async () => {
|
||||
if (editingKeyRepoId == null) return;
|
||||
setSavingKey(true);
|
||||
try {
|
||||
await updateRepo(editingKeyRepoId, { public_key: editKeyValue });
|
||||
await refreshRepo(editingKeyRepoId);
|
||||
await fetchAvailablePlugins();
|
||||
showNotification({
|
||||
title: 'Updated',
|
||||
message: 'Public key updated',
|
||||
color: 'green',
|
||||
});
|
||||
setEditingKeyRepoId(null);
|
||||
setEditKeyValue('');
|
||||
} catch {
|
||||
showNotification({
|
||||
title: 'Error',
|
||||
message: 'Failed to update key',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setSavingKey(false);
|
||||
}
|
||||
}, [
|
||||
editingKeyRepoId,
|
||||
editKeyValue,
|
||||
updateRepo,
|
||||
refreshRepo,
|
||||
fetchAvailablePlugins,
|
||||
]);
|
||||
|
||||
// Load settings when modal opens
|
||||
useEffect(() => {
|
||||
if (opened) loadRepoSettings();
|
||||
}, [opened, loadRepoSettings]);
|
||||
|
||||
// Cleanup any pending timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Group justify="space-between" align="flex-start" w="100%">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text fw={600}>Plugin Repositories</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
Add third-party plugin repositories or manage existing ones.
|
||||
Manifests are fetched automatically at the configured interval.
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'left', flexShrink: 0 }}>
|
||||
<Text size="sm" fw={500} mb={2}>
|
||||
Refresh Interval
|
||||
</Text>
|
||||
<NumberInput
|
||||
value={refreshInterval}
|
||||
onChange={handleSaveInterval}
|
||||
min={0}
|
||||
max={168}
|
||||
size="xs"
|
||||
disabled={savingInterval}
|
||||
w={115}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
Hours, 0 to disable
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
}
|
||||
centered
|
||||
size="lg"
|
||||
styles={{
|
||||
title: { width: '100%' },
|
||||
header: { alignItems: 'flex-start' },
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{reposLoading && repos.length === 0 && <Loader size="sm" />}
|
||||
|
||||
{repos.map((repo) => (
|
||||
<React.Fragment key={repo.id}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--mantine-color-dark-6)',
|
||||
}}
|
||||
>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={500} size="sm" lineClamp={1}>
|
||||
{repo.name}
|
||||
</Text>
|
||||
{repo.is_official && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="filled"
|
||||
style={{ backgroundColor: '#14917E' }}
|
||||
>
|
||||
Official Repo
|
||||
</Badge>
|
||||
)}
|
||||
{repo.signature_verified === true && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<ShieldCheck size={10} />}
|
||||
>
|
||||
Verified Signature
|
||||
</Badge>
|
||||
)}
|
||||
{repo.signature_verified === false && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<ShieldAlert size={10} />}
|
||||
>
|
||||
Invalid Signature
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{repo.registry_url ? (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
<a
|
||||
href={repo.registry_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--mantine-color-blue-4)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{repo.registry_url}
|
||||
</a>
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{repo.url}
|
||||
</Text>
|
||||
{repo.last_fetched && (
|
||||
<Text
|
||||
size="xs"
|
||||
c={
|
||||
repo.last_fetch_status &&
|
||||
repo.last_fetch_status !== '200'
|
||||
? 'red'
|
||||
: 'dimmed'
|
||||
}
|
||||
>
|
||||
Last fetched:{' '}
|
||||
{new Date(repo.last_fetched).toLocaleString()}
|
||||
{repo.last_fetch_status &&
|
||||
repo.last_fetch_status !== '200'
|
||||
? ` · ${repo.last_fetch_status}`
|
||||
: repo.plugin_count != null
|
||||
? ` · ${repo.plugin_count} plugin${repo.plugin_count !== 1 ? 's' : ''} available`
|
||||
: ''}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{!repo.is_official && (
|
||||
<Stack gap={4} align="center">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
title="Edit public key"
|
||||
onClick={() => handleEditKey(repo)}
|
||||
>
|
||||
<KeyRound size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
title="Remove repo"
|
||||
onClick={() => setDeleteConfirmId(repo.id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Stack>
|
||||
)}
|
||||
</Group>
|
||||
{editingKeyRepoId === repo.id && (
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Textarea
|
||||
placeholder={
|
||||
'-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nOptional: Paste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
|
||||
}
|
||||
value={editKeyValue}
|
||||
onChange={(e) => setEditKeyValue(e.currentTarget.value)}
|
||||
size="xs"
|
||||
minRows={3}
|
||||
autosize
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={handleSaveKey}
|
||||
loading={savingKey}
|
||||
>
|
||||
Save Key
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setEditingKeyRepoId(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{!showAddRepo ? (
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Plus size={16} />}
|
||||
size="sm"
|
||||
onClick={() => setShowAddRepo(true)}
|
||||
>
|
||||
Add Repository
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Text fw={500} size="sm" mt="sm">
|
||||
Add Repository
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
minHeight: 90,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'var(--mantine-color-dark-6)',
|
||||
border:
|
||||
repoPreview && !previewLoading && !repoPreview.valid
|
||||
? '1px solid var(--mantine-color-red-7)'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
{previewLoading ? (
|
||||
<Group gap="xs" align="center">
|
||||
<Loader size={14} />
|
||||
<Text size="xs" c="dimmed">
|
||||
Checking manifest...
|
||||
</Text>
|
||||
</Group>
|
||||
) : repoPreview ? (
|
||||
repoPreview.valid ? (
|
||||
<Box>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={500} size="sm">
|
||||
{repoPreview.registry_name}
|
||||
</Text>
|
||||
{repoPreview.signature_verified === true && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<ShieldCheck size={10} />}
|
||||
>
|
||||
Verified Signature
|
||||
</Badge>
|
||||
)}
|
||||
{repoPreview.signature_verified === false && (
|
||||
<>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<ShieldCheck size={10} />}
|
||||
>
|
||||
Signed Manifest
|
||||
</Badge>
|
||||
<Text
|
||||
size="xs"
|
||||
c="var(--mantine-color-yellow-6)"
|
||||
fs="italic"
|
||||
>
|
||||
Public key required for verification
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{repoPreview.signature_verified == null && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
No Signature
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{repoPreview.registry_url ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
<a
|
||||
href={repoPreview.registry_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--mantine-color-blue-4)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{repoPreview.registry_url}
|
||||
</a>
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{newRepoUrl.trim()}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{repoPreview.plugin_count} plugin
|
||||
{repoPreview.plugin_count !== 1 ? 's' : ''} available
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Text size="xs" c="red">
|
||||
{repoPreview.errors?.join(' ') || 'Invalid manifest'}
|
||||
</Text>
|
||||
)
|
||||
) : (
|
||||
<Text size="xs" c="yellow">
|
||||
Third-party repositories are not reviewed by the Dispatcharr
|
||||
team.
|
||||
<br />
|
||||
Adding sources and installing plugins is done at your own
|
||||
risk.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<TextInput
|
||||
placeholder="Repository Manifest URL (ending in .json)"
|
||||
value={newRepoUrl}
|
||||
onChange={(e) => {
|
||||
setNewRepoUrl(e.currentTarget.value);
|
||||
fetchPreview(e.currentTarget.value, newRepoPublicKey);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
<Textarea
|
||||
placeholder={
|
||||
gpgKeyFocused
|
||||
? '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nPaste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
|
||||
: 'Optional: Paste public GPG key here'
|
||||
}
|
||||
value={newRepoPublicKey}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setNewRepoPublicKey(value);
|
||||
fetchPreview(newRepoUrl, value);
|
||||
}}
|
||||
size="sm"
|
||||
minRows={gpgKeyFocused || newRepoPublicKey ? 4 : 1}
|
||||
maxRows={8}
|
||||
autosize
|
||||
onFocus={() => setGpgKeyFocused(true)}
|
||||
onBlur={() => {
|
||||
if (!newRepoPublicKey) setGpgKeyFocused(false);
|
||||
}}
|
||||
styles={
|
||||
repoPreview?.valid &&
|
||||
repoPreview?.signature_verified === false &&
|
||||
!newRepoPublicKey.trim()
|
||||
? {
|
||||
input: { borderColor: 'var(--mantine-color-yellow-6)' },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowAddRepo(false);
|
||||
setNewRepoUrl('');
|
||||
setNewRepoPublicKey('');
|
||||
setRepoPreview(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddRepo}
|
||||
loading={addingRepo}
|
||||
disabled={!newRepoUrl.trim()}
|
||||
leftSection={<Plus size={16} />}
|
||||
size="sm"
|
||||
>
|
||||
Add Repo
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={deleteConfirmId != null}
|
||||
onClose={() => setDeleteConfirmId(null)}
|
||||
onConfirm={() => handleDeleteRepo(deleteConfirmId)}
|
||||
title="Remove Repository"
|
||||
message={
|
||||
<>
|
||||
<Text size="sm">
|
||||
Are you sure you want to remove this repository?
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
Plugins installed from this repo will remain installed but become
|
||||
unmanaged.
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
confirmLabel="Remove"
|
||||
size="sm"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,10 +13,18 @@ import {
|
|||
} from '@mantine/core';
|
||||
import { Copy, SquareMinus, SquarePen } from 'lucide-react';
|
||||
import API from '../../api';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { showNotification } from '../../utils/notificationUtils';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import { USER_LEVELS } from '../../constants';
|
||||
|
||||
const updateChannelProfile = (values) => {
|
||||
return API.updateChannelProfile(values);
|
||||
}
|
||||
|
||||
const duplicateChannelProfile = (profileId, newName) => {
|
||||
return API.duplicateChannelProfile(profileId, newName);
|
||||
}
|
||||
|
||||
const ProfileModal = ({ opened, onClose, mode, profile }) => {
|
||||
const [profileNameInput, setProfileNameInput] = useState('');
|
||||
const setSelectedProfileId = useChannelsStore((s) => s.setSelectedProfileId);
|
||||
|
|
@ -40,7 +48,7 @@ const ProfileModal = ({ opened, onClose, mode, profile }) => {
|
|||
if (!mode || !profile) return;
|
||||
|
||||
if (!trimmedName) {
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: 'Profile name is required',
|
||||
color: 'red.5',
|
||||
});
|
||||
|
|
@ -53,13 +61,13 @@ const ProfileModal = ({ opened, onClose, mode, profile }) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const updatedProfile = await API.updateChannelProfile({
|
||||
const updatedProfile = await updateChannelProfile({
|
||||
id: profile.id,
|
||||
name: trimmedName,
|
||||
});
|
||||
|
||||
if (updatedProfile) {
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: 'Profile renamed',
|
||||
message: `${profile.name} → ${trimmedName}`,
|
||||
color: 'green.5',
|
||||
|
|
@ -69,13 +77,13 @@ const ProfileModal = ({ opened, onClose, mode, profile }) => {
|
|||
}
|
||||
|
||||
if (mode === 'duplicate') {
|
||||
const duplicatedProfile = await API.duplicateChannelProfile(
|
||||
const duplicatedProfile = await duplicateChannelProfile(
|
||||
profile.id,
|
||||
trimmedName
|
||||
);
|
||||
|
||||
if (duplicatedProfile) {
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: 'Profile duplicated',
|
||||
message: `${profile.name} copied to ${duplicatedProfile.name}`,
|
||||
color: 'green.5',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,539 @@
|
|||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => {
|
||||
const React = require('react');
|
||||
|
||||
// Named declarations so reference equality checks (child.type === Stack) work
|
||||
function Stack({ children }) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
function RadioItem({ value, label, description, _onChange }) {
|
||||
return (
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
data-testid={`radio-${value}`}
|
||||
value={value}
|
||||
onChange={() => _onChange?.(value)}
|
||||
aria-label={label}
|
||||
/>
|
||||
{label}
|
||||
{description && (
|
||||
<span data-testid={`radio-desc-${value}`}>{description}</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function RadioGroup({ children, onChange, label }) {
|
||||
// Inject _onChange into Radio children, handling the Stack wrapper
|
||||
const inject = (child) => {
|
||||
if (!React.isValidElement(child)) return child;
|
||||
if (child.type === RadioItem) {
|
||||
return React.cloneElement(child, { _onChange: onChange });
|
||||
}
|
||||
if (child.type === Stack && child.props.children) {
|
||||
return React.cloneElement(child, {
|
||||
children: React.Children.map(child.props.children, inject),
|
||||
});
|
||||
}
|
||||
return child;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
{React.Children.map(children, inject)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
Button: ({ children, onClick, variant }) => (
|
||||
<button onClick={onClick} data-variant={variant}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Checkbox: ({ label, checked, onChange }) => (
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="remember-checkbox"
|
||||
checked={checked ?? false}
|
||||
onChange={(e) =>
|
||||
onChange({ currentTarget: { checked: e.target.checked } })
|
||||
}
|
||||
/>
|
||||
{label && <label>{label}</label>}
|
||||
</div>
|
||||
),
|
||||
Divider: ({ label }) => <hr aria-label={label} />,
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Modal: ({ children, opened, onClose, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
MultiSelect: ({ label, data, value, onChange }) => {
|
||||
const flat = (data ?? []).flatMap((g) =>
|
||||
g.items ? g.items : [{ value: g.value, label: g.label }]
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
{flat.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
data-testid={`profile-option-${opt.value}`}
|
||||
onClick={() => onChange([...(value ?? []), opt.value])}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
NumberInput: ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
placeholder,
|
||||
description,
|
||||
}) => (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
<input
|
||||
type="number"
|
||||
data-testid="number-input"
|
||||
value={value ?? ''}
|
||||
min={min}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
/>
|
||||
{description && <span>{description}</span>}
|
||||
</div>
|
||||
),
|
||||
Radio: RadioItem,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
Text: ({ children, c }) => <span data-color={c}>{children}</span>,
|
||||
};
|
||||
});
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import CreateChannelModal from '../CreateChannelModal';
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
const makeProfiles = () => [
|
||||
{ id: '0', name: 'All Profiles' }, // should be filtered out
|
||||
{ id: '1', name: 'Profile One' },
|
||||
{ id: '2', name: 'Profile Two' },
|
||||
];
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
opened: true,
|
||||
onClose: vi.fn(),
|
||||
mode: 'provider',
|
||||
onModeChange: vi.fn(),
|
||||
numberValue: '',
|
||||
onNumberValueChange: vi.fn(),
|
||||
rememberChoice: false,
|
||||
onRememberChoiceChange: vi.fn(),
|
||||
onConfirm: vi.fn(),
|
||||
isBulk: false,
|
||||
streamCount: 1,
|
||||
streamName: 'My Stream',
|
||||
selectedProfileIds: [],
|
||||
onProfileIdsChange: vi.fn(),
|
||||
channelProfiles: makeProfiles(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('CreateChannelModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Visibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when opened is true', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the modal when opened is false', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ opened: false })} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when the close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<CreateChannelModal {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Title and labels ───────────────────────────────────────────────────────
|
||||
|
||||
describe('title and labels', () => {
|
||||
it('shows "Create Channel" title for single mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(
|
||||
'Create Channel'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows "Create Channels Options" title for bulk mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(
|
||||
'Create Channels Options'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows "Create Channel" confirm button for single mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Create Channel' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Create Channels" confirm button for bulk mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
|
||||
expect(screen.getByText('Create Channels')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Number Assignment" numbering label for single mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Number Assignment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Numbering Mode" numbering label for bulk mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
|
||||
expect(screen.getByText('Numbering Mode')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Description text ───────────────────────────────────────────────────────
|
||||
|
||||
describe('description text', () => {
|
||||
it('shows the streamName in the description for single mode', () => {
|
||||
render(
|
||||
<CreateChannelModal {...defaultProps({ streamName: 'ESPN HD' })} />
|
||||
);
|
||||
expect(screen.getByText(/ESPN HD/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the streamCount in the description for bulk mode', () => {
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ isBulk: true, streamCount: 5 })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/5 channels/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Radio options ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('radio options', () => {
|
||||
it('renders all four radio options', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('radio-provider')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('radio-auto')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('radio-highest')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('radio-specific')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Use Provider Number" label for single mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByLabelText('Use Provider Number')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Use Provider Numbers" label for bulk mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
|
||||
expect(screen.getByLabelText('Use Provider Numbers')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Auto-Assign Next Available" label for single mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(
|
||||
screen.getByLabelText('Auto-Assign Next Available')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Auto-Assign Sequential" label for bulk mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
|
||||
expect(
|
||||
screen.getByLabelText('Auto-Assign Sequential')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Use Specific Number" label for single mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByLabelText('Use Specific Number')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Start from Custom Number" label for bulk mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
|
||||
expect(
|
||||
screen.getByLabelText('Start from Custom Number')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onModeChange when a radio option is selected', () => {
|
||||
const onModeChange = vi.fn();
|
||||
render(<CreateChannelModal {...defaultProps({ onModeChange })} />);
|
||||
fireEvent.click(screen.getByLabelText('Auto-Assign Next Available'));
|
||||
expect(onModeChange).toHaveBeenCalledWith('auto');
|
||||
});
|
||||
});
|
||||
|
||||
// ── NumberInput visibility ─────────────────────────────────────────────────
|
||||
|
||||
describe('NumberInput visibility', () => {
|
||||
it('does not show NumberInput when mode is "provider"', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ mode: 'provider' })} />);
|
||||
expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show NumberInput when mode is "auto"', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ mode: 'auto' })} />);
|
||||
expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show NumberInput when mode is "highest"', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ mode: 'highest' })} />);
|
||||
expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows NumberInput when mode is "specific" in single mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ mode: 'specific' })} />);
|
||||
expect(screen.getByTestId('number-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows NumberInput when mode is "custom" in bulk mode', () => {
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ isBulk: true, mode: 'custom' })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('number-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show NumberInput when mode is "specific" but isBulk is true', () => {
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ isBulk: true, mode: 'specific' })}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onNumberValueChange when NumberInput value changes', () => {
|
||||
const onNumberValueChange = vi.fn();
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({
|
||||
mode: 'specific',
|
||||
numberValue: 5,
|
||||
onNumberValueChange,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('number-input'), {
|
||||
target: { value: '10' },
|
||||
});
|
||||
expect(onNumberValueChange).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('shows "Channel Number" label in single mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ mode: 'specific' })} />);
|
||||
expect(screen.getByText('Channel Number')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Starting Channel Number" label in bulk mode', () => {
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ isBulk: true, mode: 'custom' })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Starting Channel Number')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Remember choice checkbox ───────────────────────────────────────────────
|
||||
|
||||
describe('remember choice checkbox', () => {
|
||||
it('renders unchecked when rememberChoice is false', () => {
|
||||
render(
|
||||
<CreateChannelModal {...defaultProps({ rememberChoice: false })} />
|
||||
);
|
||||
expect(screen.getByTestId('remember-checkbox')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('renders checked when rememberChoice is true', () => {
|
||||
render(
|
||||
<CreateChannelModal {...defaultProps({ rememberChoice: true })} />
|
||||
);
|
||||
expect(screen.getByTestId('remember-checkbox')).toBeChecked();
|
||||
});
|
||||
|
||||
it('calls onRememberChoiceChange with true when checked', () => {
|
||||
const onRememberChoiceChange = vi.fn();
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ rememberChoice: false, onRememberChoiceChange })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('remember-checkbox'));
|
||||
expect(onRememberChoiceChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('calls onRememberChoiceChange with false when unchecked', () => {
|
||||
const onRememberChoiceChange = vi.fn();
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ rememberChoice: true, onRememberChoiceChange })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('remember-checkbox'));
|
||||
expect(onRememberChoiceChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Action buttons ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('action buttons', () => {
|
||||
it('calls onConfirm when the confirm button is clicked', () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(<CreateChannelModal {...defaultProps({ onConfirm })} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create Channel' }));
|
||||
expect(onConfirm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose when the Cancel button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<CreateChannelModal {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Channel profiles ───────────────────────────────────────────────────────
|
||||
|
||||
describe('channel profiles', () => {
|
||||
it('renders the "All Profiles" special option', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('profile-option-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the "No Profiles" special option', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('profile-option-none')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders channel profile options (excluding id "0")', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('profile-option-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('profile-option-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the profile with id "0"', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.queryByTestId('profile-option-0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "All Profiles" and "No Profiles" labels', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByText('All Profiles')).toBeInTheDocument();
|
||||
expect(screen.getByText('No Profiles')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleProfileChange logic ──────────────────────────────────────────────
|
||||
|
||||
describe('handleProfileChange', () => {
|
||||
it('selects only "all" when "All Profiles" is clicked', () => {
|
||||
const onProfileIdsChange = vi.fn();
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ selectedProfileIds: [], onProfileIdsChange })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('profile-option-all'));
|
||||
expect(onProfileIdsChange).toHaveBeenCalledWith(['all']);
|
||||
});
|
||||
|
||||
it('selects only "none" when "No Profiles" is clicked', () => {
|
||||
const onProfileIdsChange = vi.fn();
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ selectedProfileIds: [], onProfileIdsChange })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('profile-option-none'));
|
||||
expect(onProfileIdsChange).toHaveBeenCalledWith(['none']);
|
||||
});
|
||||
|
||||
it('removes "all" when a specific profile is added while "all" is selected', () => {
|
||||
const onProfileIdsChange = vi.fn();
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ selectedProfileIds: ['all'], onProfileIdsChange })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('profile-option-1'));
|
||||
expect(onProfileIdsChange).toHaveBeenCalledWith(['1']);
|
||||
});
|
||||
|
||||
it('removes "none" when a specific profile is added while "none" is selected', () => {
|
||||
const onProfileIdsChange = vi.fn();
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({
|
||||
selectedProfileIds: ['none'],
|
||||
onProfileIdsChange,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('profile-option-2'));
|
||||
expect(onProfileIdsChange).toHaveBeenCalledWith(['2']);
|
||||
});
|
||||
|
||||
it('allows selecting multiple specific profiles', () => {
|
||||
const onProfileIdsChange = vi.fn();
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ selectedProfileIds: ['1'], onProfileIdsChange })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('profile-option-2'));
|
||||
expect(onProfileIdsChange).toHaveBeenCalledWith(['1', '2']);
|
||||
});
|
||||
|
||||
it('replaces existing "all" selection when "none" is clicked last', () => {
|
||||
const onProfileIdsChange = vi.fn();
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ selectedProfileIds: ['all'], onProfileIdsChange })}
|
||||
/>
|
||||
);
|
||||
// Mock passes ['all', 'none'] to onChange, making lastSelected = 'none'
|
||||
fireEvent.click(screen.getByTestId('profile-option-none'));
|
||||
expect(onProfileIdsChange).toHaveBeenCalledWith(['none']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,597 @@
|
|||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
act,
|
||||
} from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Store mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/plugins.jsx', () => ({
|
||||
usePluginStore: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/notificationUtils.js', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/pages/PluginsUtils.js', () => ({
|
||||
getPluginRepoSettings: vi.fn(),
|
||||
previewPluginRepo: vi.fn(),
|
||||
updatePluginRepoSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── ConfirmationDialog mock ────────────────────────────────────────────────────
|
||||
vi.mock('../../ConfirmationDialog.jsx', () => ({
|
||||
default: ({ opened, onClose, onConfirm, title, confirmLabel }) =>
|
||||
opened ? (
|
||||
<div data-testid="confirmation-dialog">
|
||||
<div data-testid="dialog-title">{title}</div>
|
||||
<button data-testid="dialog-confirm" onClick={onConfirm}>
|
||||
{confirmLabel || 'Confirm'}
|
||||
</button>
|
||||
<button data-testid="dialog-close" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
KeyRound: () => <svg data-testid="icon-key-round" />,
|
||||
Plus: () => <svg data-testid="icon-plus" />,
|
||||
ShieldAlert: () => <svg data-testid="icon-shield-alert" />,
|
||||
ShieldCheck: () => <svg data-testid="icon-shield-check" />,
|
||||
Trash2: () => <svg data-testid="icon-trash-2" />,
|
||||
}));
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
ActionIcon: ({ children, onClick, title, color, variant }) => (
|
||||
<button
|
||||
data-testid="action-icon"
|
||||
title={title}
|
||||
data-color={color}
|
||||
data-variant={variant}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Badge: ({ children, color, variant, leftSection }) => (
|
||||
<span data-testid="badge" data-color={color} data-variant={variant}>
|
||||
{leftSection}
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Box: ({ children }) => <div>{children}</div>,
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
loading,
|
||||
disabled,
|
||||
variant,
|
||||
color,
|
||||
leftSection,
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
data-loading={loading}
|
||||
data-variant={variant}
|
||||
data-color={color}
|
||||
>
|
||||
{leftSection}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Loader: ({ size }) => <div data-testid="loader" data-size={size} />,
|
||||
Modal: ({ children, opened, onClose, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-header">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
NumberInput: ({ value, onChange, min, max, disabled }) => (
|
||||
<input
|
||||
type="number"
|
||||
data-testid="refresh-interval-input"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
min={min}
|
||||
max={max}
|
||||
disabled={disabled}
|
||||
/>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Text: ({ children, fw, size, c }) => (
|
||||
<span data-fw={fw} data-size={size} data-color={c}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Textarea: ({ value, onChange, placeholder, onFocus, onBlur }) => (
|
||||
<textarea
|
||||
data-testid="textarea"
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
),
|
||||
TextInput: ({ value, onChange, placeholder }) => (
|
||||
<input
|
||||
data-testid="repo-url-input"
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import ManageReposModal from '../ManageReposModal';
|
||||
import { usePluginStore } from '../../../store/plugins.jsx';
|
||||
import { showNotification } from '../../../utils/notificationUtils.js';
|
||||
import {
|
||||
getPluginRepoSettings,
|
||||
previewPluginRepo,
|
||||
updatePluginRepoSettings,
|
||||
} from '../../../utils/pages/PluginsUtils.js';
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
const makeRepo = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: 'Main Repo',
|
||||
url: 'https://example.com/manifest.json',
|
||||
is_official: false,
|
||||
signature_verified: null,
|
||||
registry_url: null,
|
||||
last_fetched: null,
|
||||
last_fetch_status: null,
|
||||
plugin_count: null,
|
||||
public_key: '',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
let mockFetchAvailablePlugins;
|
||||
let mockRefreshRepo;
|
||||
let mockAddRepo;
|
||||
let mockRemoveRepo;
|
||||
let mockUpdateRepo;
|
||||
|
||||
const setupStore = ({ repos = [], reposLoading = false } = {}) => {
|
||||
mockFetchAvailablePlugins = vi.fn().mockResolvedValue(undefined);
|
||||
mockRefreshRepo = vi.fn().mockResolvedValue(undefined);
|
||||
mockAddRepo = vi.fn().mockResolvedValue(undefined);
|
||||
mockRemoveRepo = vi.fn().mockResolvedValue(undefined);
|
||||
mockUpdateRepo = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(usePluginStore).mockImplementation((sel) =>
|
||||
sel({
|
||||
repos,
|
||||
reposLoading,
|
||||
fetchAvailablePlugins: mockFetchAvailablePlugins,
|
||||
refreshRepo: mockRefreshRepo,
|
||||
addRepo: mockAddRepo,
|
||||
removeRepo: mockRemoveRepo,
|
||||
updateRepo: mockUpdateRepo,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
opened: true,
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ManageReposModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupStore();
|
||||
vi.mocked(getPluginRepoSettings).mockResolvedValue({
|
||||
refresh_interval_hours: 6,
|
||||
});
|
||||
vi.mocked(updatePluginRepoSettings).mockResolvedValue(undefined);
|
||||
vi.mocked(previewPluginRepo).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
// ── Visibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when opened is true', () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the modal when opened is false', () => {
|
||||
render(<ManageReposModal {...defaultProps({ opened: false })} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when the close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ManageReposModal {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Settings load ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('settings load', () => {
|
||||
it('calls getPluginRepoSettings when opened', async () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
await waitFor(() => {
|
||||
expect(getPluginRepoSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call getPluginRepoSettings when closed', async () => {
|
||||
render(<ManageReposModal {...defaultProps({ opened: false })} />);
|
||||
expect(getPluginRepoSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads the refresh interval from settings', async () => {
|
||||
vi.mocked(getPluginRepoSettings).mockResolvedValue({
|
||||
refresh_interval_hours: 24,
|
||||
});
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('refresh-interval-input')).toHaveValue(24);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Repos list ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('repos list', () => {
|
||||
it('renders a row for each repo', () => {
|
||||
setupStore({
|
||||
repos: [
|
||||
makeRepo({ id: 1, name: 'Repo A' }),
|
||||
makeRepo({ id: 2, name: 'Repo B' }),
|
||||
],
|
||||
});
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Repo A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Repo B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loader when loading and no repos are present', () => {
|
||||
setupStore({ reposLoading: true, repos: [] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('loader')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show loader when repos are present', () => {
|
||||
setupStore({ reposLoading: true, repos: [makeRepo()] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.queryByTestId('loader')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Official Repo" badge for official repos', () => {
|
||||
setupStore({ repos: [makeRepo({ is_official: true })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Official Repo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Verified Signature" badge when signature_verified is true', () => {
|
||||
setupStore({ repos: [makeRepo({ signature_verified: true })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Verified Signature')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Invalid Signature" badge when signature_verified is false', () => {
|
||||
setupStore({ repos: [makeRepo({ signature_verified: false })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Invalid Signature')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows edit and delete buttons only for non-official repos', () => {
|
||||
setupStore({ repos: [makeRepo({ is_official: false })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.getByTitle('Edit public key')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Remove repo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show edit and delete buttons for official repos', () => {
|
||||
setupStore({ repos: [makeRepo({ is_official: true })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.queryByTitle('Edit public key')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Remove repo')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Refresh interval ───────────────────────────────────────────────────────
|
||||
|
||||
describe('refresh interval', () => {
|
||||
it('calls updatePluginRepoSettings (debounced) when interval changes', async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync(); // flush loadRepoSettings
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId('refresh-interval-input'), {
|
||||
target: { value: '12' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(900);
|
||||
});
|
||||
|
||||
expect(updatePluginRepoSettings).toHaveBeenCalledWith({
|
||||
refresh_interval_hours: 12,
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not call updatePluginRepoSettings before the debounce delay', async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId('refresh-interval-input'), {
|
||||
target: { value: '12' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
});
|
||||
expect(updatePluginRepoSettings).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit public key ────────────────────────────────────────────────────────
|
||||
|
||||
describe('edit public key', () => {
|
||||
it('shows key editor when Edit button is clicked', () => {
|
||||
setupStore({ repos: [makeRepo({ id: 1 })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Edit public key'));
|
||||
expect(screen.getByTestId('textarea')).toBeInTheDocument();
|
||||
expect(screen.getByText('Save Key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-fills key editor with existing public key', () => {
|
||||
setupStore({ repos: [makeRepo({ id: 1, public_key: 'existing-key' })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Edit public key'));
|
||||
expect(screen.getByTestId('textarea')).toHaveValue('existing-key');
|
||||
});
|
||||
|
||||
it('hides key editor when Cancel is clicked', () => {
|
||||
setupStore({ repos: [makeRepo({ id: 1 })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Edit public key'));
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(screen.queryByText('Save Key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls updateRepo, refreshRepo, and fetchAvailablePlugins on Save Key', async () => {
|
||||
setupStore({ repos: [makeRepo({ id: 7 })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Edit public key'));
|
||||
fireEvent.change(screen.getByTestId('textarea'), {
|
||||
target: { value: 'new-key' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save Key'));
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateRepo).toHaveBeenCalledWith(7, {
|
||||
public_key: 'new-key',
|
||||
});
|
||||
expect(mockRefreshRepo).toHaveBeenCalledWith(7);
|
||||
expect(mockFetchAvailablePlugins).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Updated" notification after successful key save', async () => {
|
||||
setupStore({ repos: [makeRepo({ id: 1 })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Edit public key'));
|
||||
fireEvent.click(screen.getByText('Save Key'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Updated', color: 'green' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error notification when key save fails', async () => {
|
||||
mockUpdateRepo = vi.fn().mockRejectedValue(new Error('Network error'));
|
||||
vi.mocked(usePluginStore).mockImplementation((sel) =>
|
||||
sel({
|
||||
repos: [makeRepo({ id: 1 })],
|
||||
reposLoading: false,
|
||||
fetchAvailablePlugins: mockFetchAvailablePlugins,
|
||||
refreshRepo: mockRefreshRepo,
|
||||
addRepo: mockAddRepo,
|
||||
removeRepo: mockRemoveRepo,
|
||||
updateRepo: mockUpdateRepo,
|
||||
})
|
||||
);
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Edit public key'));
|
||||
fireEvent.click(screen.getByText('Save Key'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Error', color: 'red' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes key editor after successful save', async () => {
|
||||
setupStore({ repos: [makeRepo({ id: 1 })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Edit public key'));
|
||||
fireEvent.click(screen.getByText('Save Key'));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Save Key')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete repo ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('delete repo', () => {
|
||||
it('opens ConfirmationDialog when delete button is clicked', () => {
|
||||
setupStore({ repos: [makeRepo({ id: 1 })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Remove repo'));
|
||||
expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dialog-title')).toHaveTextContent(
|
||||
'Remove Repository'
|
||||
);
|
||||
});
|
||||
|
||||
it('closes ConfirmationDialog when cancelled', () => {
|
||||
setupStore({ repos: [makeRepo({ id: 1 })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Remove repo'));
|
||||
fireEvent.click(screen.getByTestId('dialog-close'));
|
||||
expect(
|
||||
screen.queryByTestId('confirmation-dialog')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls removeRepo and fetchAvailablePlugins when confirmed', async () => {
|
||||
setupStore({ repos: [makeRepo({ id: 5 })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Remove repo'));
|
||||
fireEvent.click(screen.getByTestId('dialog-confirm'));
|
||||
await waitFor(() => {
|
||||
expect(mockRemoveRepo).toHaveBeenCalledWith(5);
|
||||
expect(mockFetchAvailablePlugins).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Removed" notification after delete', async () => {
|
||||
setupStore({ repos: [makeRepo({ id: 1 })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Remove repo'));
|
||||
fireEvent.click(screen.getByTestId('dialog-confirm'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Removed', color: 'green' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Add repository ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('add repository', () => {
|
||||
it('shows "Add Repository" button initially', () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Add Repository')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the add form when "Add Repository" is clicked', () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add Repository'));
|
||||
expect(screen.getByTestId('repo-url-input')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add Repo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the form and resets when Cancel is clicked', () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add Repository'));
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(screen.queryByTestId('repo-url-input')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Add Repository')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('"Add Repo" button is disabled when URL is empty', () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add Repository'));
|
||||
expect(screen.getByText('Add Repo')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('"Add Repo" button is enabled when URL is entered', () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add Repository'));
|
||||
fireEvent.change(screen.getByTestId('repo-url-input'), {
|
||||
target: { value: 'https://example.com/manifest.json' },
|
||||
});
|
||||
expect(screen.getByText('Add Repo')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls addRepo and fetchAvailablePlugins when submitted', async () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add Repository'));
|
||||
fireEvent.change(screen.getByTestId('repo-url-input'), {
|
||||
target: { value: 'https://example.com/manifest.json' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Add Repo'));
|
||||
await waitFor(() => {
|
||||
expect(mockAddRepo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: 'https://example.com/manifest.json' })
|
||||
);
|
||||
expect(mockFetchAvailablePlugins).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Added" notification after successful add', async () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add Repository'));
|
||||
fireEvent.change(screen.getByTestId('repo-url-input'), {
|
||||
target: { value: 'https://example.com/manifest.json' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Add Repo'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Added', color: 'green' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes the form after a successful add', async () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add Repository'));
|
||||
fireEvent.change(screen.getByTestId('repo-url-input'), {
|
||||
target: { value: 'https://example.com/manifest.json' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Add Repo'));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('repo-url-input')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers previewPluginRepo (debounced) when a valid URL is entered', async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Add Repository'));
|
||||
fireEvent.change(screen.getByTestId('repo-url-input'), {
|
||||
target: { value: 'https://example.com/manifest.json' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
});
|
||||
expect(previewPluginRepo).toHaveBeenCalledWith(
|
||||
'https://example.com/manifest.json',
|
||||
''
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
743
frontend/src/components/modals/__tests__/ProfileModal.test.jsx
Normal file
743
frontend/src/components/modals/__tests__/ProfileModal.test.jsx
Normal file
|
|
@ -0,0 +1,743 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── API mock ───────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../api', () => ({
|
||||
default: {
|
||||
updateChannelProfile: vi.fn(),
|
||||
duplicateChannelProfile: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Notification mock ──────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/notificationUtils', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Store mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/channels', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Constants mock ─────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../constants', () => ({
|
||||
USER_LEVELS: { STREAMER: 0, STANDARD: 1, ADMIN: 10 },
|
||||
}));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
Copy: () => <svg data-testid="icon-copy" />,
|
||||
SquareMinus: () => <svg data-testid="icon-square-minus" />,
|
||||
SquarePen: () => <svg data-testid="icon-square-pen" />,
|
||||
}));
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
ActionIcon: ({ children, onClick, disabled }) => (
|
||||
<button data-testid="action-icon" onClick={onClick} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Alert: ({ children, title, color }) => (
|
||||
<div data-testid="alert" data-color={color}>
|
||||
{title && <div data-testid="alert-title">{title}</div>}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Box: ({ children }) => <div>{children}</div>,
|
||||
Button: ({ children, onClick, variant, size }) => (
|
||||
<button onClick={onClick} data-variant={variant} data-size={size}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Modal: ({ children, opened, onClose, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Text: ({ children, size }) => <span data-size={size}>{children}</span>,
|
||||
TextInput: ({ label, value, onChange, placeholder }) => (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
<input
|
||||
data-testid="profile-name-input"
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import ProfileModal, { renderProfileOption } from '../ProfileModal';
|
||||
import API from '../../../api';
|
||||
import useChannelsStore from '../../../store/channels';
|
||||
import { showNotification } from '../../../utils/notificationUtils';
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
const makeProfile = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: 'My Profile',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
opened: true,
|
||||
onClose: vi.fn(),
|
||||
mode: 'edit',
|
||||
profile: makeProfile(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ProfileModal', () => {
|
||||
let mockSetSelectedProfileId;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockSetSelectedProfileId = vi.fn();
|
||||
vi.mocked(useChannelsStore).mockImplementation((sel) =>
|
||||
sel({ setSelectedProfileId: mockSetSelectedProfileId })
|
||||
);
|
||||
vi.mocked(API.updateChannelProfile).mockResolvedValue({
|
||||
id: 1,
|
||||
name: 'Updated',
|
||||
});
|
||||
vi.mocked(API.duplicateChannelProfile).mockResolvedValue({
|
||||
id: 2,
|
||||
name: 'My Profile Copy',
|
||||
});
|
||||
});
|
||||
|
||||
// ── Visibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when opened is true', () => {
|
||||
render(<ProfileModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the modal when opened is false', () => {
|
||||
render(<ProfileModal {...defaultProps({ opened: false })} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Title ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('title', () => {
|
||||
it('shows "Rename Profile: {name}" title in edit mode', () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
mode: 'edit',
|
||||
profile: makeProfile({ name: 'Work Profile' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(
|
||||
'Rename Profile: Work Profile'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows "Duplicate Profile: {name}" title in duplicate mode', () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
mode: 'duplicate',
|
||||
profile: makeProfile({ name: 'Work Profile' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(
|
||||
'Duplicate Profile: Work Profile'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Warning alert ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('warning alert', () => {
|
||||
it('shows the warning alert in edit mode', () => {
|
||||
render(<ProfileModal {...defaultProps({ mode: 'edit' })} />);
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the warning alert in duplicate mode', () => {
|
||||
render(<ProfileModal {...defaultProps({ mode: 'duplicate' })} />);
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Initial name value ─────────────────────────────────────────────────────
|
||||
|
||||
describe('initial name value', () => {
|
||||
it('pre-fills the input with the profile name in edit mode', () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
mode: 'edit',
|
||||
profile: makeProfile({ name: 'My Profile' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('profile-name-input')).toHaveValue(
|
||||
'My Profile'
|
||||
);
|
||||
});
|
||||
|
||||
it('pre-fills the input with "{name} Copy" in duplicate mode', () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
mode: 'duplicate',
|
||||
profile: makeProfile({ name: 'My Profile' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('profile-name-input')).toHaveValue(
|
||||
'My Profile Copy'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Button labels ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('button labels', () => {
|
||||
it('shows "Save" button in edit mode', () => {
|
||||
render(<ProfileModal {...defaultProps({ mode: 'edit' })} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Duplicate" button in duplicate mode', () => {
|
||||
render(<ProfileModal {...defaultProps({ mode: 'duplicate' })} />);
|
||||
expect(screen.getByText('Duplicate')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cancel / close ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('cancel / close', () => {
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ProfileModal {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose when the modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ProfileModal {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Submit validation ──────────────────────────────────────────────────────
|
||||
|
||||
describe('submit validation', () => {
|
||||
it('shows notification when name is empty', () => {
|
||||
render(<ProfileModal {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('profile-name-input'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Profile name is required',
|
||||
color: 'red.5',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('shows notification when name is only whitespace', () => {
|
||||
render(<ProfileModal {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('profile-name-input'), {
|
||||
target: { value: ' ' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Profile name is required' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call API when name is empty', () => {
|
||||
render(<ProfileModal {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('profile-name-input'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
expect(API.updateChannelProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns early without notification or API call when profile is undefined', () => {
|
||||
render(<ProfileModal {...defaultProps({ profile: undefined })} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
expect(API.updateChannelProfile).not.toHaveBeenCalled();
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit mode submission ───────────────────────────────────────────────────
|
||||
|
||||
describe('edit mode submission', () => {
|
||||
it('closes without calling API when name is unchanged', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
onClose,
|
||||
mode: 'edit',
|
||||
profile: makeProfile({ name: 'My Profile' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
expect(API.updateChannelProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls updateChannelProfile with id and new name when name changes', async () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
mode: 'edit',
|
||||
profile: makeProfile({ id: 7, name: 'Old Name' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('profile-name-input'), {
|
||||
target: { value: 'New Name' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(API.updateChannelProfile).toHaveBeenCalledWith({
|
||||
id: 7,
|
||||
name: 'New Name',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('trims whitespace from the name before submitting', async () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
mode: 'edit',
|
||||
profile: makeProfile({ id: 1, name: 'Old' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('profile-name-input'), {
|
||||
target: { value: ' Trimmed Name ' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(API.updateChannelProfile).toHaveBeenCalledWith({
|
||||
id: 1,
|
||||
name: 'Trimmed Name',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Profile renamed" notification on success', async () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
mode: 'edit',
|
||||
profile: makeProfile({ name: 'Old Name' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('profile-name-input'), {
|
||||
target: { value: 'New Name' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Profile renamed',
|
||||
color: 'green.5',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after a successful rename', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
onClose,
|
||||
mode: 'edit',
|
||||
profile: makeProfile({ name: 'Old' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('profile-name-input'), {
|
||||
target: { value: 'New' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not notify or close when updateChannelProfile returns null', async () => {
|
||||
vi.mocked(API.updateChannelProfile).mockResolvedValue(null);
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
onClose,
|
||||
mode: 'edit',
|
||||
profile: makeProfile({ name: 'Old' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('profile-name-input'), {
|
||||
target: { value: 'New' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(API.updateChannelProfile).toHaveBeenCalled();
|
||||
});
|
||||
expect(showNotification).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Profile renamed' })
|
||||
);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Duplicate mode submission ──────────────────────────────────────────────
|
||||
|
||||
describe('duplicate mode submission', () => {
|
||||
it('calls duplicateChannelProfile with profile id and default copy name', async () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
mode: 'duplicate',
|
||||
profile: makeProfile({ id: 5, name: 'My Profile' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Duplicate'));
|
||||
await waitFor(() => {
|
||||
expect(API.duplicateChannelProfile).toHaveBeenCalledWith(
|
||||
5,
|
||||
'My Profile Copy'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the custom name entered by the user', async () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
mode: 'duplicate',
|
||||
profile: makeProfile({ id: 3, name: 'Base' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('profile-name-input'), {
|
||||
target: { value: 'Custom Copy Name' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Duplicate'));
|
||||
await waitFor(() => {
|
||||
expect(API.duplicateChannelProfile).toHaveBeenCalledWith(
|
||||
3,
|
||||
'Custom Copy Name'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Profile duplicated" notification on success', async () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({ mode: 'duplicate', profile: makeProfile() })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Duplicate'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Profile duplicated',
|
||||
color: 'green.5',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls setSelectedProfileId with the string id of the duplicated profile', async () => {
|
||||
vi.mocked(API.duplicateChannelProfile).mockResolvedValue({
|
||||
id: 99,
|
||||
name: 'Copy',
|
||||
});
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({ mode: 'duplicate', profile: makeProfile() })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Duplicate'));
|
||||
await waitFor(() => {
|
||||
expect(mockSetSelectedProfileId).toHaveBeenCalledWith('99');
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after a successful duplication', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
onClose,
|
||||
mode: 'duplicate',
|
||||
profile: makeProfile(),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Duplicate'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call setSelectedProfileId or close when duplicateChannelProfile returns null', async () => {
|
||||
vi.mocked(API.duplicateChannelProfile).mockResolvedValue(null);
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
onClose,
|
||||
mode: 'duplicate',
|
||||
profile: makeProfile(),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Duplicate'));
|
||||
await waitFor(() => {
|
||||
expect(API.duplicateChannelProfile).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockSetSelectedProfileId).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('renderProfileOption', () => {
|
||||
const mockTheme = {
|
||||
tailwind: {
|
||||
yellow: { 3: '#f59e0b' },
|
||||
green: { 5: '#22c55e' },
|
||||
red: { 6: '#dc2626' },
|
||||
},
|
||||
};
|
||||
|
||||
const adminUser = { user_level: 10 };
|
||||
const nonAdminUser = { user_level: 1 };
|
||||
|
||||
const makeRenderArgs = (overrides = {}) => ({
|
||||
theme: mockTheme,
|
||||
profiles: [],
|
||||
onEditProfile: vi.fn(),
|
||||
onDeleteProfile: vi.fn(),
|
||||
authUser: adminUser,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const renderOption = (optionValue, optionLabel, args) => {
|
||||
const renderFn = renderProfileOption(
|
||||
args.theme,
|
||||
args.profiles,
|
||||
args.onEditProfile,
|
||||
args.onDeleteProfile,
|
||||
args.authUser
|
||||
);
|
||||
const { container } = render(
|
||||
renderFn({ option: { value: optionValue, label: optionLabel } })
|
||||
);
|
||||
return container;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the option label', () => {
|
||||
renderOption('1', 'Profile One', makeRenderArgs());
|
||||
expect(screen.getByText('Profile One')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows action icons when option value is not "0"', () => {
|
||||
renderOption('1', 'Profile One', makeRenderArgs());
|
||||
expect(screen.getByTestId('icon-square-pen')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('icon-copy')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('icon-square-minus')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show action icons when option value is "0"', () => {
|
||||
renderOption('0', 'All Profiles', makeRenderArgs());
|
||||
expect(screen.queryByTestId('icon-square-pen')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('icon-copy')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('icon-square-minus')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rename action ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('rename action', () => {
|
||||
it('calls onEditProfile with "edit" and option value when rename is clicked', () => {
|
||||
const args = makeRenderArgs();
|
||||
renderOption('3', 'Profile Three', args);
|
||||
fireEvent.click(screen.getByTestId('icon-square-pen').closest('button'));
|
||||
expect(args.onEditProfile).toHaveBeenCalledWith('edit', '3');
|
||||
});
|
||||
|
||||
it('rename button is enabled for admin user', () => {
|
||||
renderOption('1', 'Profile One', makeRenderArgs({ authUser: adminUser }));
|
||||
expect(
|
||||
screen.getByTestId('icon-square-pen').closest('button')
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('rename button is disabled for non-admin user', () => {
|
||||
renderOption(
|
||||
'1',
|
||||
'Profile One',
|
||||
makeRenderArgs({ authUser: nonAdminUser })
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('icon-square-pen').closest('button')
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Duplicate action ───────────────────────────────────────────────────────
|
||||
|
||||
describe('duplicate action', () => {
|
||||
it('calls onEditProfile with "duplicate" and option value when duplicate is clicked', () => {
|
||||
const args = makeRenderArgs();
|
||||
renderOption('5', 'Profile Five', args);
|
||||
fireEvent.click(screen.getByTestId('icon-copy').closest('button'));
|
||||
expect(args.onEditProfile).toHaveBeenCalledWith('duplicate', '5');
|
||||
});
|
||||
|
||||
it('duplicate button is enabled for admin user', () => {
|
||||
renderOption('1', 'Profile One', makeRenderArgs({ authUser: adminUser }));
|
||||
expect(
|
||||
screen.getByTestId('icon-copy').closest('button')
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('duplicate button is disabled for non-admin user', () => {
|
||||
renderOption(
|
||||
'1',
|
||||
'Profile One',
|
||||
makeRenderArgs({ authUser: nonAdminUser })
|
||||
);
|
||||
expect(screen.getByTestId('icon-copy').closest('button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete action ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('delete action', () => {
|
||||
it('calls onDeleteProfile with option value when delete is clicked', () => {
|
||||
const args = makeRenderArgs();
|
||||
renderOption('7', 'Profile Seven', args);
|
||||
fireEvent.click(
|
||||
screen.getByTestId('icon-square-minus').closest('button')
|
||||
);
|
||||
expect(args.onDeleteProfile).toHaveBeenCalledWith('7');
|
||||
});
|
||||
|
||||
it('delete button is disabled for non-admin user', () => {
|
||||
renderOption(
|
||||
'1',
|
||||
'Profile One',
|
||||
makeRenderArgs({ authUser: nonAdminUser })
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('icon-square-minus').closest('button')
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Event propagation ──────────────────────────────────────────────────────
|
||||
|
||||
describe('event propagation', () => {
|
||||
it('does not propagate click event when rename is clicked', () => {
|
||||
const args = makeRenderArgs();
|
||||
const parentClick = vi.fn();
|
||||
const renderFn = renderProfileOption(
|
||||
mockTheme,
|
||||
[],
|
||||
args.onEditProfile,
|
||||
args.onDeleteProfile,
|
||||
adminUser
|
||||
);
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
{renderFn({ option: { value: '1', label: 'Profile' } })}
|
||||
</div>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('icon-square-pen').closest('button'));
|
||||
expect(parentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not propagate click event when duplicate is clicked', () => {
|
||||
const args = makeRenderArgs();
|
||||
const parentClick = vi.fn();
|
||||
const renderFn = renderProfileOption(
|
||||
mockTheme,
|
||||
[],
|
||||
args.onEditProfile,
|
||||
args.onDeleteProfile,
|
||||
adminUser
|
||||
);
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
{renderFn({ option: { value: '1', label: 'Profile' } })}
|
||||
</div>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('icon-copy').closest('button'));
|
||||
expect(parentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not propagate click event when delete is clicked', () => {
|
||||
const args = makeRenderArgs();
|
||||
const parentClick = vi.fn();
|
||||
const renderFn = renderProfileOption(
|
||||
mockTheme,
|
||||
[],
|
||||
args.onEditProfile,
|
||||
args.onDeleteProfile,
|
||||
adminUser
|
||||
);
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
{renderFn({ option: { value: '1', label: 'Profile' } })}
|
||||
</div>
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByTestId('icon-square-minus').closest('button')
|
||||
);
|
||||
expect(parentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -71,14 +71,6 @@ export const PROXY_SETTINGS_OPTIONS = {
|
|||
},
|
||||
};
|
||||
|
||||
export const EPG_SETTINGS_OPTIONS = {
|
||||
xmltv_prev_days_override: {
|
||||
label: 'XMLTV prev_days Override (catch-up)',
|
||||
description:
|
||||
'Days of past programmes in the XC EPG output. 0 = auto-detect from the providers’ tv_archive_duration (capped at 30).',
|
||||
},
|
||||
};
|
||||
|
||||
export const USER_LIMITS_OPTIONS = {
|
||||
terminate_on_limit_exceeded: {
|
||||
label: 'Terminate on Limit Exceeded',
|
||||
|
|
|
|||
325
frontend/src/hooks/__tests__/useEpgPreview.test.jsx
Normal file
325
frontend/src/hooks/__tests__/useEpgPreview.test.jsx
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// ── API mock ───────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../api', () => ({
|
||||
default: {
|
||||
getCurrentProgramForEpg: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import API from '../../api';
|
||||
import { useEpgPreview } from '../useEpgPreview';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useEpgPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Invalid / empty epgDataId ──────────────────────────────────────────────
|
||||
|
||||
describe('invalid epgDataId', () => {
|
||||
it.each([null, undefined, '', '0'])(
|
||||
'returns defaults and skips the API call when epgDataId is %s',
|
||||
async (id) => {
|
||||
const { result } = renderHook(() => useEpgPreview(id));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.currentProgram).toBe(null);
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
expect(result.current.hasFetchedProgram).toBe(false);
|
||||
expect(API.getCurrentProgramForEpg).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// ── Initial state ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('initial state with a valid epgDataId', () => {
|
||||
it('sets isLoadingProgram true and hasFetchedProgram false immediately', () => {
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue(null);
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
expect(result.current.isLoadingProgram).toBe(true);
|
||||
expect(result.current.hasFetchedProgram).toBe(false);
|
||||
expect(result.current.currentProgram).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Successful fetch ───────────────────────────────────────────────────────
|
||||
|
||||
describe('successful fetch', () => {
|
||||
it('calls the API with the provided epgDataId', async () => {
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({ id: 1 });
|
||||
renderHook(() => useEpgPreview('epg-42'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(API.getCurrentProgramForEpg).toHaveBeenCalledWith('epg-42');
|
||||
});
|
||||
|
||||
it('sets currentProgram to the returned program', async () => {
|
||||
const program = { id: 7, title: 'News at Six', channel: 'BBC' };
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue(program);
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.currentProgram).toEqual(program);
|
||||
});
|
||||
|
||||
it('sets isLoadingProgram to false after a successful fetch', async () => {
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({ id: 1 });
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
});
|
||||
|
||||
it('sets hasFetchedProgram to true after a successful fetch', async () => {
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({ id: 1 });
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.hasFetchedProgram).toBe(true);
|
||||
});
|
||||
|
||||
it('sets currentProgram to null and completes when API returns null', async () => {
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue(null);
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.currentProgram).toBe(null);
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
expect(result.current.hasFetchedProgram).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Parsing retry ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('parsing retry', () => {
|
||||
it('retries after a delay when program.parsing is true', async () => {
|
||||
const parsingProgram = { id: 1, parsing: true };
|
||||
const readyProgram = { id: 1, title: 'News', parsing: false };
|
||||
|
||||
vi.mocked(API.getCurrentProgramForEpg)
|
||||
.mockResolvedValueOnce(parsingProgram)
|
||||
.mockResolvedValueOnce(readyProgram);
|
||||
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(API.getCurrentProgramForEpg).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.currentProgram).toEqual(readyProgram);
|
||||
expect(result.current.hasFetchedProgram).toBe(true);
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
});
|
||||
|
||||
it('does not set a still-parsing program as currentProgram', async () => {
|
||||
const parsingProgram = { id: 1, parsing: true };
|
||||
const readyProgram = { id: 1, title: 'Ready', parsing: false };
|
||||
|
||||
vi.mocked(API.getCurrentProgramForEpg)
|
||||
.mockResolvedValueOnce(parsingProgram)
|
||||
.mockResolvedValueOnce(readyProgram);
|
||||
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.currentProgram).toEqual(readyProgram);
|
||||
expect(result.current.currentProgram?.parsing).toBeFalsy();
|
||||
});
|
||||
|
||||
it('resolves to null after all retries are exhausted with parsing: true', async () => {
|
||||
// Always returns parsing: true — the hook will exhaust retries/deadline
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({
|
||||
id: 1,
|
||||
parsing: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.currentProgram).toBe(null);
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
expect(result.current.hasFetchedProgram).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error handling ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('error handling', () => {
|
||||
it('retries after an API error and sets currentProgram on the next success', async () => {
|
||||
const program = { id: 1, title: 'News' };
|
||||
vi.mocked(API.getCurrentProgramForEpg)
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockResolvedValueOnce(program);
|
||||
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(API.getCurrentProgramForEpg).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.currentProgram).toEqual(program);
|
||||
expect(result.current.hasFetchedProgram).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves to null after all retries are exhausted by persistent errors', async () => {
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockRejectedValue(
|
||||
new Error('Persistent error')
|
||||
);
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.currentProgram).toBe(null);
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
expect(result.current.hasFetchedProgram).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── epgDataId changes ──────────────────────────────────────────────────────
|
||||
|
||||
describe('epgDataId changes', () => {
|
||||
it('resets to defaults when epgDataId changes to null', async () => {
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({
|
||||
id: 1,
|
||||
title: 'News',
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(({ id }) => useEpgPreview(id), {
|
||||
initialProps: { id: 'epg-1' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.currentProgram).not.toBe(null);
|
||||
|
||||
rerender({ id: null });
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.currentProgram).toBe(null);
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
expect(result.current.hasFetchedProgram).toBe(false);
|
||||
});
|
||||
|
||||
it('resets to defaults when epgDataId changes to "0"', async () => {
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({
|
||||
id: 1,
|
||||
title: 'News',
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(({ id }) => useEpgPreview(id), {
|
||||
initialProps: { id: 'epg-1' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
rerender({ id: '0' });
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.currentProgram).toBe(null);
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
expect(result.current.hasFetchedProgram).toBe(false);
|
||||
});
|
||||
|
||||
it('fetches with the new epgDataId when it changes to another valid value', async () => {
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({ id: 1 });
|
||||
|
||||
const { rerender } = renderHook(({ id }) => useEpgPreview(id), {
|
||||
initialProps: { id: 'epg-1' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
rerender({ id: 'epg-2' });
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(API.getCurrentProgramForEpg).toHaveBeenCalledWith('epg-1');
|
||||
expect(API.getCurrentProgramForEpg).toHaveBeenCalledWith('epg-2');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cleanup on unmount ─────────────────────────────────────────────────────
|
||||
|
||||
describe('cleanup on unmount', () => {
|
||||
it('does not update currentProgram after unmount', async () => {
|
||||
let resolveProgram;
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveProgram = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const { result, unmount } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
// Unmount before the API call resolves
|
||||
unmount();
|
||||
|
||||
// Resolve the pending request
|
||||
await act(async () => {
|
||||
resolveProgram({ id: 1, title: 'Late News' });
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
// currentProgram should remain null — the cancelled flag prevented the update
|
||||
expect(result.current.currentProgram).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import API from '../api';
|
||||
|
||||
const getCurrentProgramForEpg = (epgId) => {
|
||||
return API.getCurrentProgramForEpg(epgId);
|
||||
};
|
||||
|
||||
export const useEpgPreview = (epgDataId) => {
|
||||
const [currentProgram, setCurrentProgram] = useState(null);
|
||||
const [isLoadingProgram, setIsLoadingProgram] = useState(false);
|
||||
|
|
@ -28,7 +32,7 @@ export const useEpgPreview = (epgDataId) => {
|
|||
if (cancelled || Date.now() - startTime > deadlineMs) break;
|
||||
|
||||
try {
|
||||
const program = await API.getCurrentProgramForEpg(epgDataId);
|
||||
const program = await getCurrentProgramForEpg(epgDataId);
|
||||
if (cancelled) return;
|
||||
|
||||
if (program && program.parsing && attempt < maxRetries) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ import { SquarePlus, Webhook, FileCode, Logs } from 'lucide-react';
|
|||
import ConnectionForm from '../components/forms/Connection';
|
||||
import { SUBSCRIPTION_EVENTS } from '../constants';
|
||||
|
||||
const deleteConnectIntegration = (id) => {
|
||||
return API.deleteConnectIntegration(id);
|
||||
};
|
||||
|
||||
const updateConnectIntegration = (id, values) => {
|
||||
return API.updateConnectIntegration(id, values);
|
||||
};
|
||||
|
||||
export default function ConnectPage() {
|
||||
const { integrations, isLoading, fetchIntegrations } = useConnectStore();
|
||||
const theme = useMantineTheme();
|
||||
|
|
@ -40,7 +48,7 @@ export default function ConnectPage() {
|
|||
|
||||
const deleteConnection = async (id) => {
|
||||
console.log('Deleting connection', id);
|
||||
await API.deleteConnectIntegration(id);
|
||||
await deleteConnectIntegration(id);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -99,15 +107,14 @@ function IntegrationRow({ integration, editConnection, deleteConnection }) {
|
|||
|
||||
const toggleIntegration = async () => {
|
||||
try {
|
||||
await API.updateConnectIntegration(integration.id, {
|
||||
await updateConnectIntegration(integration.id, {
|
||||
...integration,
|
||||
enabled: !enabled,
|
||||
});
|
||||
setEnabled(!enabled);
|
||||
} catch (error) {
|
||||
console.error('Failed to update integration', error);
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ import { SUBSCRIPTION_EVENTS } from '../constants';
|
|||
import { CustomTable, useTable } from '../components/tables/CustomTable';
|
||||
import { copyToClipboard } from '../utils';
|
||||
|
||||
const getConnectLogs = (params) => {
|
||||
return API.getConnectLogs(params);
|
||||
};
|
||||
|
||||
export default function ConnectLogsPage() {
|
||||
const { integrations, fetchIntegrations } = useConnectStore();
|
||||
|
||||
|
|
@ -51,7 +55,7 @@ export default function ConnectLogsPage() {
|
|||
if (filters.type) params.type = filters.type;
|
||||
if (filters.integration) params.integration = filters.integration;
|
||||
|
||||
const data = await API.getConnectLogs(params);
|
||||
const data = await getConnectLogs(params);
|
||||
const results = Array.isArray(data) ? data : data?.results || [];
|
||||
setLogs(results);
|
||||
setCount(data?.count || results.length || 0);
|
||||
|
|
|
|||
|
|
@ -7,66 +7,34 @@ import {
|
|||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NativeSelect,
|
||||
NumberInput,
|
||||
Pagination,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import API from '../api.js';
|
||||
import {
|
||||
RefreshCcw,
|
||||
Trash2,
|
||||
Plus,
|
||||
Search,
|
||||
KeyRound,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
import { Package, RefreshCcw, Search } from 'lucide-react';
|
||||
import { usePluginStore } from '../store/plugins.jsx';
|
||||
import useSettingsStore from '../store/settings.jsx';
|
||||
import AvailablePluginCard from '../components/cards/AvailablePluginCard.jsx';
|
||||
import ManageReposModal from '../components/modals/ManageReposModal.jsx';
|
||||
import { showNotification } from '../utils/notificationUtils.js';
|
||||
import { reloadPlugins } from '../utils/pages/PluginsUtils.js';
|
||||
import { compareVersions } from '../utils/components/pluginUtils.js';
|
||||
|
||||
export default function PluginBrowsePage() {
|
||||
const repos = usePluginStore((s) => s.repos);
|
||||
const reposLoading = usePluginStore((s) => s.reposLoading);
|
||||
const availablePlugins = usePluginStore((s) => s.availablePlugins);
|
||||
const availableLoading = usePluginStore((s) => s.availableLoading);
|
||||
const fetchRepos = usePluginStore((s) => s.fetchRepos);
|
||||
const fetchAvailablePlugins = usePluginStore((s) => s.fetchAvailablePlugins);
|
||||
const refreshRepo = usePluginStore((s) => s.refreshRepo);
|
||||
const addRepo = usePluginStore((s) => s.addRepo);
|
||||
const removeRepo = usePluginStore((s) => s.removeRepo);
|
||||
const updateRepo = usePluginStore((s) => s.updateRepo);
|
||||
|
||||
const appVersion = useSettingsStore((s) => s.version?.version || '');
|
||||
|
||||
const [repoModalOpen, setRepoModalOpen] = useState(false);
|
||||
const [newRepoUrl, setNewRepoUrl] = useState('');
|
||||
const [newRepoPublicKey, setNewRepoPublicKey] = useState('');
|
||||
const [addingRepo, setAddingRepo] = useState(false);
|
||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||
const [editingKeyRepoId, setEditingKeyRepoId] = useState(null);
|
||||
const [editKeyValue, setEditKeyValue] = useState('');
|
||||
const [savingKey, setSavingKey] = useState(false);
|
||||
const [showAddRepo, setShowAddRepo] = useState(false);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
|
||||
const [gpgKeyFocused, setGpgKeyFocused] = useState(false);
|
||||
const [repoPreview, setRepoPreview] = useState(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const previewTimer = useRef(null);
|
||||
const [refreshInterval, setRefreshInterval] = useState(6);
|
||||
const [savingInterval, setSavingInterval] = useState(false);
|
||||
const saveIntervalTimer = useRef(null);
|
||||
|
||||
const recentlyInstalledSlugs = useRef(new Set());
|
||||
const recentlyUpdatedSlugs = useRef(new Set());
|
||||
|
|
@ -121,130 +89,6 @@ export default function PluginBrowsePage() {
|
|||
}
|
||||
}, [refreshRepo, fetchAvailablePlugins]);
|
||||
|
||||
const handleAddRepo = useCallback(async () => {
|
||||
if (!newRepoUrl.trim()) return;
|
||||
setAddingRepo(true);
|
||||
try {
|
||||
await addRepo({
|
||||
url: newRepoUrl.trim(),
|
||||
public_key: newRepoPublicKey.trim(),
|
||||
});
|
||||
setNewRepoUrl('');
|
||||
setNewRepoPublicKey('');
|
||||
setRepoPreview(null);
|
||||
setShowAddRepo(false);
|
||||
await fetchAvailablePlugins();
|
||||
showNotification({
|
||||
title: 'Added',
|
||||
message: 'Plugin repo added',
|
||||
color: 'green',
|
||||
});
|
||||
} catch {
|
||||
// Error notification handled by API layer
|
||||
} finally {
|
||||
setAddingRepo(false);
|
||||
}
|
||||
}, [newRepoUrl, newRepoPublicKey, addRepo, fetchAvailablePlugins]);
|
||||
|
||||
const handleDeleteRepo = useCallback(
|
||||
async (id) => {
|
||||
await removeRepo(id);
|
||||
setDeleteConfirmId(null);
|
||||
await fetchAvailablePlugins();
|
||||
showNotification({
|
||||
title: 'Removed',
|
||||
message: 'Plugin repo removed',
|
||||
color: 'green',
|
||||
});
|
||||
},
|
||||
[removeRepo, fetchAvailablePlugins]
|
||||
);
|
||||
|
||||
const handleEditKey = useCallback((repo) => {
|
||||
setEditingKeyRepoId(repo.id);
|
||||
setEditKeyValue(repo.public_key || '');
|
||||
}, []);
|
||||
|
||||
const handleSaveKey = useCallback(async () => {
|
||||
if (editingKeyRepoId == null) return;
|
||||
setSavingKey(true);
|
||||
try {
|
||||
await updateRepo(editingKeyRepoId, { public_key: editKeyValue });
|
||||
await refreshRepo(editingKeyRepoId);
|
||||
await fetchAvailablePlugins();
|
||||
showNotification({
|
||||
title: 'Updated',
|
||||
message: 'Public key updated',
|
||||
color: 'green',
|
||||
});
|
||||
setEditingKeyRepoId(null);
|
||||
setEditKeyValue('');
|
||||
} catch {
|
||||
showNotification({
|
||||
title: 'Error',
|
||||
message: 'Failed to update key',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setSavingKey(false);
|
||||
}
|
||||
}, [
|
||||
editingKeyRepoId,
|
||||
editKeyValue,
|
||||
updateRepo,
|
||||
refreshRepo,
|
||||
fetchAvailablePlugins,
|
||||
]);
|
||||
|
||||
const loadRepoSettings = useCallback(async () => {
|
||||
const data = await API.getPluginRepoSettings();
|
||||
if (data) setRefreshInterval(data.refresh_interval_hours ?? 6);
|
||||
}, []);
|
||||
|
||||
const handleSaveInterval = useCallback((val) => {
|
||||
const hours = val ?? 0;
|
||||
setRefreshInterval(hours);
|
||||
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
|
||||
saveIntervalTimer.current = setTimeout(async () => {
|
||||
setSavingInterval(true);
|
||||
try {
|
||||
await API.updatePluginRepoSettings({ refresh_interval_hours: hours });
|
||||
} catch {
|
||||
// Error notification handled by API layer
|
||||
} finally {
|
||||
setSavingInterval(false);
|
||||
}
|
||||
}, 800);
|
||||
}, []);
|
||||
|
||||
// Debounced manifest preview
|
||||
const fetchPreview = useCallback((url, publicKey) => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
if (!url.trim() || !url.match(/^https?:\/\/.+/i)) {
|
||||
setRepoPreview(null);
|
||||
setPreviewLoading(false);
|
||||
return;
|
||||
}
|
||||
setPreviewLoading(true);
|
||||
previewTimer.current = setTimeout(async () => {
|
||||
const result = await API.previewPluginRepo(url.trim(), publicKey?.trim());
|
||||
setRepoPreview(result);
|
||||
setPreviewLoading(false);
|
||||
}, 600);
|
||||
}, []);
|
||||
|
||||
// Cleanup any pending timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
|
||||
};
|
||||
}, []);
|
||||
// Load settings when modal opens
|
||||
useEffect(() => {
|
||||
if (repoModalOpen) loadRepoSettings();
|
||||
}, [repoModalOpen, loadRepoSettings]);
|
||||
|
||||
const loading = availableLoading && availablePlugins.length === 0;
|
||||
|
||||
// Build repo filter options from available plugins
|
||||
|
|
@ -367,153 +211,152 @@ export default function PluginBrowsePage() {
|
|||
}}
|
||||
>
|
||||
<Box p={16} pb={60} style={{ flex: 1 }}>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={700} size="lg">
|
||||
Find Plugins
|
||||
</Text>
|
||||
{availablePlugins.length > 0 && (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{availablePlugins.length} Plugins Available
|
||||
</Badge>
|
||||
)}
|
||||
{repos.length > 1 && (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{repos.length} Repos
|
||||
</Badge>
|
||||
)}
|
||||
<Group justify="space-between" mb="md">
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={700} size="lg">
|
||||
Find Plugins
|
||||
</Text>
|
||||
{availablePlugins.length > 0 && (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{availablePlugins.length} Plugins Available
|
||||
</Badge>
|
||||
)}
|
||||
{repos.length > 1 && (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{repos.length} Repos
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
component="a"
|
||||
href="https://github.com/Dispatcharr/Plugins?tab=contributing-ov-file"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
leftSection={<Package size={14} />}
|
||||
>
|
||||
Publish Your Plugin
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => setRepoModalOpen(true)}
|
||||
>
|
||||
Manage Repos
|
||||
</Button>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
onClick={handleRefreshAll}
|
||||
title="Refresh all repos"
|
||||
loading={refreshingAll}
|
||||
>
|
||||
<RefreshCcw size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
<Group>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
component="a"
|
||||
href="https://github.com/Dispatcharr/Plugins?tab=contributing-ov-file"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
leftSection={<Package size={14} />}
|
||||
>
|
||||
Publish Your Plugin
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => setRepoModalOpen(true)}
|
||||
>
|
||||
Manage Repos
|
||||
</Button>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
onClick={handleRefreshAll}
|
||||
title="Refresh all repos"
|
||||
loading={refreshingAll}
|
||||
>
|
||||
<RefreshCcw size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{loading && <Loader />}
|
||||
{loading && <Loader />}
|
||||
|
||||
{!loading && (
|
||||
<Group gap="sm" mb="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search plugins..."
|
||||
leftSection={<Search size={14} />}
|
||||
size="xs"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
style={{ flex: 1, minWidth: 180, maxWidth: 300 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
allowDeselect={false}
|
||||
value={sortBy}
|
||||
onChange={setSortBy}
|
||||
data={[
|
||||
{ value: 'name-asc', label: 'Name A-Z' },
|
||||
{ value: 'name-desc', label: 'Name Z-A' },
|
||||
{ value: 'author', label: 'Author' },
|
||||
{ value: 'updated', label: 'Recently Updated' },
|
||||
]}
|
||||
style={{ width: 170 }}
|
||||
/>
|
||||
{repoOptions.length > 2 && (
|
||||
{!loading && (
|
||||
<Group gap="sm" mb="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search plugins..."
|
||||
leftSection={<Search size={14} />}
|
||||
size="xs"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
style={{ flex: 1, minWidth: 180, maxWidth: 300 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
allowDeselect={false}
|
||||
value={filterRepo}
|
||||
onChange={setFilterRepo}
|
||||
data={repoOptions}
|
||||
style={{ width: 160 }}
|
||||
value={sortBy}
|
||||
onChange={setSortBy}
|
||||
data={[
|
||||
{ value: 'name-asc', label: 'Name A-Z' },
|
||||
{ value: 'name-desc', label: 'Name Z-A' },
|
||||
{ value: 'author', label: 'Author' },
|
||||
{ value: 'updated', label: 'Recently Updated' },
|
||||
]}
|
||||
style={{ width: 170 }}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
size="xs"
|
||||
allowDeselect={false}
|
||||
value={filterStatus}
|
||||
onChange={setFilterStatus}
|
||||
data={[
|
||||
{ value: 'all', label: 'All Plugins' },
|
||||
{ value: 'installed', label: 'Installed' },
|
||||
{ value: 'not-installed', label: 'Not Installed' },
|
||||
{ value: 'compatible', label: 'Compatible' },
|
||||
]}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
{repoOptions.length > 2 && (
|
||||
<Select
|
||||
size="xs"
|
||||
allowDeselect={false}
|
||||
value={filterRepo}
|
||||
onChange={setFilterRepo}
|
||||
data={repoOptions}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
size="xs"
|
||||
allowDeselect={false}
|
||||
value={filterStatus}
|
||||
onChange={setFilterStatus}
|
||||
data={[
|
||||
{ value: 'all', label: 'All Plugins' },
|
||||
{ value: 'installed', label: 'Installed' },
|
||||
{ value: 'not-installed', label: 'Not Installed' },
|
||||
{ value: 'compatible', label: 'Compatible' },
|
||||
]}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
filteredPlugins.length === 0 &&
|
||||
availablePlugins.length > 0 && (
|
||||
{!loading &&
|
||||
filteredPlugins.length === 0 &&
|
||||
availablePlugins.length > 0 && (
|
||||
<Box>
|
||||
<Text c="dimmed">
|
||||
No plugins match your filters. Try adjusting your search or
|
||||
filter criteria.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && availablePlugins.length === 0 && (
|
||||
<Box>
|
||||
<Text c="dimmed">
|
||||
No plugins match your filters. Try adjusting your search or filter
|
||||
criteria.
|
||||
No plugins available. Try refreshing repos or adding a new plugin
|
||||
repository.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && availablePlugins.length === 0 && (
|
||||
<Box>
|
||||
<Text c="dimmed">
|
||||
No plugins available. Try refreshing repos or adding a new plugin
|
||||
repository.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && filteredPlugins.length > 0 && (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="md">
|
||||
{paginatedPlugins.map((p) => (
|
||||
<AvailablePluginCard
|
||||
key={`${p.repo_id}-${p.slug}`}
|
||||
plugin={p}
|
||||
appVersion={appVersion}
|
||||
multiRepo={repos.length > 1}
|
||||
onBeforeInstall={(slug) => {
|
||||
if (slug) {
|
||||
if (p.install_status === 'update_available') {
|
||||
recentlyUpdatedSlugs.current.add(slug);
|
||||
} else {
|
||||
recentlyInstalledSlugs.current.add(slug);
|
||||
{!loading && filteredPlugins.length > 0 && (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="md">
|
||||
{paginatedPlugins.map((p) => (
|
||||
<AvailablePluginCard
|
||||
key={`${p.repo_id}-${p.slug}`}
|
||||
plugin={p}
|
||||
appVersion={appVersion}
|
||||
multiRepo={repos.length > 1}
|
||||
onBeforeInstall={(slug) => {
|
||||
if (slug) {
|
||||
if (p.install_status === 'update_available') {
|
||||
recentlyUpdatedSlugs.current.add(slug);
|
||||
} else {
|
||||
recentlyInstalledSlugs.current.add(slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onInstalled={(slug) => {
|
||||
if (slug) recentlyInstalledSlugs.current.add(slug);
|
||||
fetchAvailablePlugins();
|
||||
}}
|
||||
onUninstalled={(slug) => {
|
||||
if (slug) recentlyUninstalledSlugs.current.add(slug);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
}}
|
||||
onInstalled={(slug) => {
|
||||
if (slug) recentlyInstalledSlugs.current.add(slug);
|
||||
fetchAvailablePlugins();
|
||||
}}
|
||||
onUninstalled={(slug) => {
|
||||
if (slug) recentlyUninstalledSlugs.current.add(slug);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!loading && filteredPlugins.length > 0 && (
|
||||
|
|
@ -551,398 +394,10 @@ export default function PluginBrowsePage() {
|
|||
</Box>
|
||||
)}
|
||||
|
||||
{/* Manage Repos Modal */}
|
||||
<Modal
|
||||
<ManageReposModal
|
||||
opened={repoModalOpen}
|
||||
onClose={() => setRepoModalOpen(false)}
|
||||
title={
|
||||
<Group justify="space-between" align="flex-start" w="100%">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text fw={600}>Plugin Repositories</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
Add third-party plugin repositories or manage existing ones.
|
||||
Manifests are fetched automatically at the configured interval.
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'left', flexShrink: 0 }}>
|
||||
<Text size="sm" fw={500} mb={2}>
|
||||
Refresh Interval
|
||||
</Text>
|
||||
<NumberInput
|
||||
value={refreshInterval}
|
||||
onChange={handleSaveInterval}
|
||||
min={0}
|
||||
max={168}
|
||||
size="xs"
|
||||
disabled={savingInterval}
|
||||
w={115}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
Hours, 0 to disable
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
}
|
||||
centered
|
||||
size="lg"
|
||||
styles={{
|
||||
title: { width: '100%' },
|
||||
header: { alignItems: 'flex-start' },
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{reposLoading && repos.length === 0 && <Loader size="sm" />}
|
||||
|
||||
{repos.map((repo) => (
|
||||
<React.Fragment key={repo.id}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--mantine-color-dark-6)',
|
||||
}}
|
||||
>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={500} size="sm" lineClamp={1}>
|
||||
{repo.name}
|
||||
</Text>
|
||||
{repo.is_official && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="filled"
|
||||
style={{ backgroundColor: '#14917E' }}
|
||||
>
|
||||
Official Repo
|
||||
</Badge>
|
||||
)}
|
||||
{repo.signature_verified === true && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<ShieldCheck size={10} />}
|
||||
>
|
||||
Verified Signature
|
||||
</Badge>
|
||||
)}
|
||||
{repo.signature_verified === false && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<ShieldAlert size={10} />}
|
||||
>
|
||||
Invalid Signature
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{repo.registry_url ? (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
<a
|
||||
href={repo.registry_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--mantine-color-blue-4)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{repo.registry_url}
|
||||
</a>
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{repo.url}
|
||||
</Text>
|
||||
{repo.last_fetched && (
|
||||
<Text
|
||||
size="xs"
|
||||
c={
|
||||
repo.last_fetch_status &&
|
||||
repo.last_fetch_status !== '200'
|
||||
? 'red'
|
||||
: 'dimmed'
|
||||
}
|
||||
>
|
||||
Last fetched:{' '}
|
||||
{new Date(repo.last_fetched).toLocaleString()}
|
||||
{repo.last_fetch_status &&
|
||||
repo.last_fetch_status !== '200'
|
||||
? ` · ${repo.last_fetch_status}`
|
||||
: repo.plugin_count != null
|
||||
? ` · ${repo.plugin_count} plugin${repo.plugin_count !== 1 ? 's' : ''} available`
|
||||
: ''}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{!repo.is_official && (
|
||||
<Stack gap={4} align="center">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
title="Edit public key"
|
||||
onClick={() => handleEditKey(repo)}
|
||||
>
|
||||
<KeyRound size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
title="Remove repo"
|
||||
onClick={() => setDeleteConfirmId(repo.id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Stack>
|
||||
)}
|
||||
</Group>
|
||||
{editingKeyRepoId === repo.id && (
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Textarea
|
||||
placeholder={
|
||||
'-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nOptional: Paste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
|
||||
}
|
||||
value={editKeyValue}
|
||||
onChange={(e) => setEditKeyValue(e.currentTarget.value)}
|
||||
size="xs"
|
||||
minRows={3}
|
||||
autosize
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={handleSaveKey}
|
||||
loading={savingKey}
|
||||
>
|
||||
Save Key
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setEditingKeyRepoId(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{!showAddRepo ? (
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Plus size={16} />}
|
||||
size="sm"
|
||||
onClick={() => setShowAddRepo(true)}
|
||||
>
|
||||
Add Repository
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Text fw={500} size="sm" mt="sm">
|
||||
Add Repository
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
minHeight: 90,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'var(--mantine-color-dark-6)',
|
||||
border:
|
||||
repoPreview && !previewLoading && !repoPreview.valid
|
||||
? '1px solid var(--mantine-color-red-7)'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
{previewLoading ? (
|
||||
<Group gap="xs" align="center">
|
||||
<Loader size={14} />
|
||||
<Text size="xs" c="dimmed">
|
||||
Checking manifest...
|
||||
</Text>
|
||||
</Group>
|
||||
) : repoPreview ? (
|
||||
repoPreview.valid ? (
|
||||
<Box>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={500} size="sm">
|
||||
{repoPreview.registry_name}
|
||||
</Text>
|
||||
{repoPreview.signature_verified === true && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<ShieldCheck size={10} />}
|
||||
>
|
||||
Verified Signature
|
||||
</Badge>
|
||||
)}
|
||||
{repoPreview.signature_verified === false && (
|
||||
<>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<ShieldCheck size={10} />}
|
||||
>
|
||||
Signed Manifest
|
||||
</Badge>
|
||||
<Text
|
||||
size="xs"
|
||||
c="var(--mantine-color-yellow-6)"
|
||||
fs="italic"
|
||||
>
|
||||
Public key required for verification
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{repoPreview.signature_verified == null && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
No Signature
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{repoPreview.registry_url ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
<a
|
||||
href={repoPreview.registry_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--mantine-color-blue-4)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{repoPreview.registry_url}
|
||||
</a>
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{newRepoUrl.trim()}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{repoPreview.plugin_count} plugin
|
||||
{repoPreview.plugin_count !== 1 ? 's' : ''} available
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Text size="xs" c="red">
|
||||
{repoPreview.errors?.join(' ') || 'Invalid manifest'}
|
||||
</Text>
|
||||
)
|
||||
) : (
|
||||
<Text size="xs" c="yellow">
|
||||
Third-party repositories are not reviewed by the Dispatcharr
|
||||
team.
|
||||
<br />
|
||||
Adding sources and installing plugins is done at your own
|
||||
risk.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<TextInput
|
||||
placeholder="Repository Manifest URL (ending in .json)"
|
||||
value={newRepoUrl}
|
||||
onChange={(e) => {
|
||||
setNewRepoUrl(e.currentTarget.value);
|
||||
fetchPreview(e.currentTarget.value, newRepoPublicKey);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
<Textarea
|
||||
placeholder={
|
||||
gpgKeyFocused
|
||||
? '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nPaste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
|
||||
: 'Optional: Paste public GPG key here'
|
||||
}
|
||||
value={newRepoPublicKey}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setNewRepoPublicKey(value);
|
||||
fetchPreview(newRepoUrl, value);
|
||||
}}
|
||||
size="sm"
|
||||
minRows={gpgKeyFocused || newRepoPublicKey ? 4 : 1}
|
||||
maxRows={8}
|
||||
autosize
|
||||
onFocus={() => setGpgKeyFocused(true)}
|
||||
onBlur={() => {
|
||||
if (!newRepoPublicKey) setGpgKeyFocused(false);
|
||||
}}
|
||||
styles={
|
||||
repoPreview?.valid &&
|
||||
repoPreview?.signature_verified === false &&
|
||||
!newRepoPublicKey.trim()
|
||||
? {
|
||||
input: { borderColor: 'var(--mantine-color-yellow-6)' },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowAddRepo(false);
|
||||
setNewRepoUrl('');
|
||||
setNewRepoPublicKey('');
|
||||
setRepoPreview(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddRepo}
|
||||
loading={addingRepo}
|
||||
disabled={!newRepoUrl.trim()}
|
||||
leftSection={<Plus size={16} />}
|
||||
size="sm"
|
||||
>
|
||||
Add Repo
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal
|
||||
opened={deleteConfirmId != null}
|
||||
onClose={() => setDeleteConfirmId(null)}
|
||||
title="Remove Repository"
|
||||
size="sm"
|
||||
centered
|
||||
>
|
||||
<Text size="sm">Are you sure you want to remove this repository?</Text>
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
Plugins installed from this repo will remain installed but become
|
||||
unmanaged.
|
||||
</Text>
|
||||
<Group mt="md" justify="flex-end" gap="xs">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={() => handleDeleteRepo(deleteConfirmId)}>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
/>
|
||||
</AppShellMain>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,9 +45,6 @@ const DvrSettingsForm = React.lazy(
|
|||
const SystemSettingsForm = React.lazy(
|
||||
() => import('../components/forms/settings/SystemSettingsForm.jsx')
|
||||
);
|
||||
const EpgSettingsForm = React.lazy(
|
||||
() => import('../components/forms/settings/EpgSettingsForm.jsx')
|
||||
);
|
||||
const NavOrderForm = React.lazy(
|
||||
() => import('../components/forms/settings/NavOrderForm.jsx')
|
||||
);
|
||||
|
|
@ -125,19 +122,6 @@ const SettingsPage = () => {
|
|||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="epg-settings">
|
||||
<AccordionControl>EPG</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<EpgSettingsForm
|
||||
active={accordianValue === 'epg-settings'}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="system-settings">
|
||||
<AccordionControl>System Settings</AccordionControl>
|
||||
<AccordionPanel>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,11 @@ import {
|
|||
stopTimeshiftSession,
|
||||
stopVODClient,
|
||||
} from '../utils/pages/StatsUtils.js';
|
||||
import {
|
||||
computeCatchupArchivePositionSecs,
|
||||
computeCatchupPlaybackSeconds,
|
||||
isCatchupPlayheadOutsideProgram,
|
||||
} from '../utils/cards/TimeshiftConnectionCardUtils.js';
|
||||
const VodConnectionCard = React.lazy(
|
||||
() => import('../components/cards/VodConnectionCard.jsx')
|
||||
);
|
||||
|
|
@ -342,8 +347,9 @@ const StatsPage = () => {
|
|||
};
|
||||
}, [activeChannelIds]); // Only re-run when active channel set changes
|
||||
|
||||
// Programme metadata for catch-up cards: fetch when sessions or seek
|
||||
// position (programme_start) change, then reschedule at programme end.
|
||||
// Catch-up programme metadata: fetch when missing or when the playhead has
|
||||
// left the cached programme's window; otherwise keep that result and only
|
||||
// do cheap local checks until the next boundary.
|
||||
const timeshiftProgrammeKey = useMemo(() => {
|
||||
return timeshiftSessions
|
||||
.map((session) => `${session.session_id}:${session.programme_start || ''}`)
|
||||
|
|
@ -352,9 +358,31 @@ const StatsPage = () => {
|
|||
}, [timeshiftSessions]);
|
||||
|
||||
const timeshiftSessionsRef = useRef(timeshiftSessions);
|
||||
const catchupProgramsRef = useRef(catchupPrograms);
|
||||
const programmeCheckRef = useRef(() => {});
|
||||
const playbackFingerprintRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
timeshiftSessionsRef.current = timeshiftSessions;
|
||||
|
||||
const fingerprint = timeshiftSessions
|
||||
.map(
|
||||
(session) =>
|
||||
`${session.session_id}:${session.playback_base_secs ?? ''}:${session.position_anchor_at ?? ''}:${session.paused ? 1 : 0}`
|
||||
)
|
||||
.sort()
|
||||
.join(',');
|
||||
if (
|
||||
playbackFingerprintRef.current &&
|
||||
fingerprint !== playbackFingerprintRef.current
|
||||
) {
|
||||
programmeCheckRef.current();
|
||||
}
|
||||
playbackFingerprintRef.current = fingerprint;
|
||||
}, [timeshiftSessions]);
|
||||
useEffect(() => {
|
||||
catchupProgramsRef.current = catchupPrograms;
|
||||
}, [catchupPrograms]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!timeshiftProgrammeKey) {
|
||||
|
|
@ -362,45 +390,151 @@ const StatsPage = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let timer = null;
|
||||
let inFlight = false;
|
||||
|
||||
const fetchPrograms = async () => {
|
||||
const sessions = timeshiftSessionsRef.current.map((session) => ({
|
||||
session_id: session.session_id,
|
||||
channel_uuid: session.channel_uuid,
|
||||
programme_start: session.programme_start,
|
||||
}));
|
||||
const programs = await getCatchupPrograms(sessions);
|
||||
setCatchupPrograms(programs);
|
||||
|
||||
if (programs && Object.keys(programs).length > 0) {
|
||||
const now = new Date();
|
||||
let nearestEndTime = null;
|
||||
|
||||
Object.values(programs).forEach((program) => {
|
||||
if (program && program.end_time) {
|
||||
const endTime = new Date(program.end_time);
|
||||
if (
|
||||
endTime > now &&
|
||||
(!nearestEndTime || endTime < nearestEndTime)
|
||||
) {
|
||||
nearestEndTime = endTime;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (nearestEndTime) {
|
||||
const timeUntilChange = nearestEndTime.getTime() - now.getTime();
|
||||
const fetchDelay = Math.max(timeUntilChange + 5000, 0);
|
||||
timer = setTimeout(fetchPrograms, fetchDelay);
|
||||
}
|
||||
const clearTimer = () => {
|
||||
if (timer != null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const sessionNeedsProgrammeFetch = (session, program) => {
|
||||
if (!program?.start_time || program.duration_secs == null) {
|
||||
return true;
|
||||
}
|
||||
return isCatchupPlayheadOutsideProgram({
|
||||
programmeStart: session.programme_start,
|
||||
programStartTime: program.start_time,
|
||||
programDurationSecs: program.duration_secs,
|
||||
positionAnchorAt: session.position_anchor_at,
|
||||
playbackBaseSecs: session.playback_base_secs,
|
||||
paused: Boolean(session.paused),
|
||||
});
|
||||
};
|
||||
|
||||
const msUntilNearestBoundary = () => {
|
||||
let nearestMs = null;
|
||||
timeshiftSessionsRef.current.forEach((session) => {
|
||||
const program = catchupProgramsRef.current?.[session.session_id];
|
||||
if (!program?.start_time || program.duration_secs == null) {
|
||||
return;
|
||||
}
|
||||
const position = computeCatchupPlaybackSeconds({
|
||||
programmeStart: session.programme_start,
|
||||
programStartTime: program.start_time,
|
||||
programDurationSecs: program.duration_secs,
|
||||
positionAnchorAt: session.position_anchor_at,
|
||||
playbackBaseSecs: session.playback_base_secs,
|
||||
paused: Boolean(session.paused),
|
||||
capToDuration: false,
|
||||
allowNegative: true,
|
||||
});
|
||||
if (position == null) {
|
||||
return;
|
||||
}
|
||||
if (position < 0) {
|
||||
nearestMs = 0;
|
||||
return;
|
||||
}
|
||||
const remainingMs = Math.max(
|
||||
0,
|
||||
(Number(program.duration_secs) - position) * 1000
|
||||
);
|
||||
if (nearestMs == null || remainingMs < nearestMs) {
|
||||
nearestMs = remainingMs;
|
||||
}
|
||||
});
|
||||
return nearestMs;
|
||||
};
|
||||
|
||||
const scheduleCheck = () => {
|
||||
clearTimer();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessions = timeshiftSessionsRef.current;
|
||||
const programs = catchupProgramsRef.current || {};
|
||||
if (
|
||||
sessions.some((session) =>
|
||||
sessionNeedsProgrammeFetch(session, programs[session.session_id])
|
||||
)
|
||||
) {
|
||||
fetchPrograms();
|
||||
return;
|
||||
}
|
||||
|
||||
// Wake near the programme end; also at least every 2s so a seek/heartbeat
|
||||
// that jumps the playhead is noticed without hitting the API.
|
||||
const remainingMs = msUntilNearestBoundary();
|
||||
const delay =
|
||||
remainingMs == null
|
||||
? 2000
|
||||
: Math.min(Math.max(remainingMs + 250, 250), 2000);
|
||||
timer = setTimeout(scheduleCheck, delay);
|
||||
};
|
||||
|
||||
const fetchPrograms = async () => {
|
||||
if (cancelled || inFlight) {
|
||||
return;
|
||||
}
|
||||
inFlight = true;
|
||||
clearTimer();
|
||||
try {
|
||||
const existingPrograms = catchupProgramsRef.current || {};
|
||||
const sessions = timeshiftSessionsRef.current.map((session) => {
|
||||
const existing = existingPrograms[session.session_id];
|
||||
// Archive playhead relative to the URL programme (uncapped). When the
|
||||
// card has already advanced and we only have URL math, omit position
|
||||
// and let the API fall back to Redis.
|
||||
const positionSecs = computeCatchupArchivePositionSecs({
|
||||
programmeStart: session.programme_start,
|
||||
programStartTime: existing?.start_time,
|
||||
positionAnchorAt: session.position_anchor_at,
|
||||
playbackBaseSecs: session.playback_base_secs,
|
||||
paused: Boolean(session.paused),
|
||||
});
|
||||
const payload = {
|
||||
session_id: session.session_id,
|
||||
channel_uuid: session.channel_uuid,
|
||||
programme_start: session.programme_start,
|
||||
};
|
||||
if (positionSecs != null) {
|
||||
payload.position_secs = positionSecs;
|
||||
}
|
||||
return payload;
|
||||
});
|
||||
const programs = await getCatchupPrograms(sessions);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
catchupProgramsRef.current = programs;
|
||||
setCatchupPrograms(programs);
|
||||
|
||||
const stillOutside = timeshiftSessionsRef.current.some((session) =>
|
||||
sessionNeedsProgrammeFetch(session, programs[session.session_id])
|
||||
);
|
||||
// No next guide entry (or unknown playhead): back off instead of spinning.
|
||||
timer = setTimeout(
|
||||
stillOutside ? fetchPrograms : scheduleCheck,
|
||||
stillOutside ? 30_000 : 250
|
||||
);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
programmeCheckRef.current = scheduleCheck;
|
||||
|
||||
fetchPrograms();
|
||||
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
cancelled = true;
|
||||
clearTimer();
|
||||
programmeCheckRef.current = () => {};
|
||||
};
|
||||
}, [timeshiftProgrammeKey]);
|
||||
|
||||
|
|
|
|||
395
frontend/src/pages/__tests__/Connect.test.jsx
Normal file
395
frontend/src/pages/__tests__/Connect.test.jsx
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── API mock ───────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../api', () => ({
|
||||
default: {
|
||||
deleteConnectIntegration: vi.fn(),
|
||||
updateConnectIntegration: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Store mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../store/connect', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Constants mock ─────────────────────────────────────────────────────────────
|
||||
vi.mock('../../constants', () => ({
|
||||
SUBSCRIPTION_EVENTS: {
|
||||
channel_start: 'Channel Started',
|
||||
channel_stop: 'Channel Stopped',
|
||||
recording_start: 'Recording Started',
|
||||
},
|
||||
}));
|
||||
|
||||
// ── ConnectionForm mock ────────────────────────────────────────────────────────
|
||||
vi.mock('../../components/forms/Connection', () => ({
|
||||
default: ({ connection, isOpen, onClose }) =>
|
||||
isOpen ? (
|
||||
<div data-testid="connection-form">
|
||||
<div data-testid="connection-form-id">{connection?.id ?? 'new'}</div>
|
||||
<button data-testid="connection-form-close" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
SquarePlus: () => <svg data-testid="icon-square-plus" />,
|
||||
Webhook: () => <svg data-testid="icon-webhook" />,
|
||||
FileCode: () => <svg data-testid="icon-file-code" />,
|
||||
Logs: () => <svg data-testid="icon-logs" />,
|
||||
}));
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Badge: ({ children, color, variant, size }) => (
|
||||
<span
|
||||
data-testid="badge"
|
||||
data-color={color}
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Box: ({ children, display, style }) => (
|
||||
<div data-display={display} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, onClick, variant, color, size, leftSection }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
data-variant={variant}
|
||||
data-color={color}
|
||||
data-size={size}
|
||||
>
|
||||
{leftSection}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Card: ({ children }) => <div data-testid="card">{children}</div>,
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Switch: ({ label, checked, onChange }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="toggle-switch"
|
||||
checked={checked ?? false}
|
||||
onChange={onChange}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
),
|
||||
Text: ({ children, fw }) => <span data-fw={fw}>{children}</span>,
|
||||
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
|
||||
useMantineTheme: () => ({
|
||||
tailwind: { green: { 5: '#22c55e' } },
|
||||
}),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import ConnectPage from '../Connect';
|
||||
import API from '../../api';
|
||||
import useConnectStore from '../../store/connect';
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
const makeIntegration = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: 'My Webhook',
|
||||
type: 'webhook',
|
||||
enabled: true,
|
||||
config: { url: 'https://example.com/hook' },
|
||||
subscriptions: [
|
||||
{ event: 'channel_start', enabled: true },
|
||||
{ event: 'channel_stop', enabled: false },
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupStore = (overrides = {}) => {
|
||||
const fetchIntegrations = vi.fn();
|
||||
vi.mocked(useConnectStore).mockReturnValue({
|
||||
integrations: [],
|
||||
isLoading: false,
|
||||
fetchIntegrations,
|
||||
...overrides,
|
||||
});
|
||||
return { fetchIntegrations };
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ConnectPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(API.deleteConnectIntegration).mockResolvedValue(undefined);
|
||||
vi.mocked(API.updateConnectIntegration).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
// ── Initialization ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('initialization', () => {
|
||||
it('calls fetchIntegrations on mount', () => {
|
||||
const { fetchIntegrations } = setupStore();
|
||||
render(<ConnectPage />);
|
||||
expect(fetchIntegrations).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Loading state ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows loading indicator when isLoading is true', () => {
|
||||
setupStore({ isLoading: true });
|
||||
render(<ConnectPage />);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show loading indicator when isLoading is false', () => {
|
||||
setupStore({ isLoading: false });
|
||||
render(<ConnectPage />);
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Integration list ───────────────────────────────────────────────────────
|
||||
|
||||
describe('integration list', () => {
|
||||
it('renders a card for each integration', () => {
|
||||
setupStore({
|
||||
integrations: [
|
||||
makeIntegration({ id: 1 }),
|
||||
makeIntegration({ id: 2, name: 'Other' }),
|
||||
],
|
||||
});
|
||||
render(<ConnectPage />);
|
||||
expect(screen.getAllByTestId('card')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders integration names', () => {
|
||||
setupStore({ integrations: [makeIntegration({ name: 'Plex Hook' })] });
|
||||
render(<ConnectPage />);
|
||||
expect(screen.getByText('Plex Hook')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no cards when integrations list is empty', () => {
|
||||
setupStore({ integrations: [] });
|
||||
render(<ConnectPage />);
|
||||
expect(screen.queryByTestId('card')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── New Connection button ──────────────────────────────────────────────────
|
||||
|
||||
describe('"New Connection" button', () => {
|
||||
it('renders the New Connection button', () => {
|
||||
setupStore();
|
||||
render(<ConnectPage />);
|
||||
expect(screen.getByText('New Connection')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ConnectionForm is not visible initially', () => {
|
||||
setupStore();
|
||||
render(<ConnectPage />);
|
||||
expect(screen.queryByTestId('connection-form')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens ConnectionForm with no connection when New Connection is clicked', () => {
|
||||
setupStore();
|
||||
render(<ConnectPage />);
|
||||
fireEvent.click(screen.getByText('New Connection'));
|
||||
expect(screen.getByTestId('connection-form')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('connection-form-id')).toHaveTextContent('new');
|
||||
});
|
||||
|
||||
it('closes ConnectionForm when its close button is clicked', () => {
|
||||
setupStore();
|
||||
render(<ConnectPage />);
|
||||
fireEvent.click(screen.getByText('New Connection'));
|
||||
fireEvent.click(screen.getByTestId('connection-form-close'));
|
||||
expect(screen.queryByTestId('connection-form')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit connection ────────────────────────────────────────────────────────
|
||||
|
||||
describe('edit connection', () => {
|
||||
it('opens ConnectionForm with the integration when Edit is clicked', () => {
|
||||
const integration = makeIntegration({ id: 7, name: 'My Hook' });
|
||||
setupStore({ integrations: [integration] });
|
||||
render(<ConnectPage />);
|
||||
fireEvent.click(screen.getByText('Edit'));
|
||||
expect(screen.getByTestId('connection-form')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('connection-form-id')).toHaveTextContent('7');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete connection ──────────────────────────────────────────────────────
|
||||
|
||||
describe('delete connection', () => {
|
||||
it('calls deleteConnectIntegration with the integration id when Delete is clicked', async () => {
|
||||
setupStore({ integrations: [makeIntegration({ id: 3 })] });
|
||||
render(<ConnectPage />);
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
await waitFor(() => {
|
||||
expect(API.deleteConnectIntegration).toHaveBeenCalledWith(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('IntegrationRow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(API.updateConnectIntegration).mockResolvedValue(undefined);
|
||||
vi.mocked(API.deleteConnectIntegration).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
const renderRow = (integrationOverrides = {}) => {
|
||||
const integration = makeIntegration(integrationOverrides);
|
||||
const { fetchIntegrations } = setupStore({ integrations: [integration] });
|
||||
render(<ConnectPage />);
|
||||
return { integration, fetchIntegrations };
|
||||
};
|
||||
|
||||
// ── Type icons ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('type icons', () => {
|
||||
it('shows webhook icon for webhook type', () => {
|
||||
renderRow({ type: 'webhook' });
|
||||
expect(screen.getByTestId('icon-webhook')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows file code icon for non-webhook type', () => {
|
||||
renderRow({ type: 'script' });
|
||||
expect(screen.getByTestId('icon-file-code')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Target display ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('target display', () => {
|
||||
it('shows webhook URL for webhook type', () => {
|
||||
renderRow({
|
||||
type: 'webhook',
|
||||
config: { url: 'https://hooks.example.com' },
|
||||
});
|
||||
expect(screen.getByText('https://hooks.example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows script path for non-webhook type', () => {
|
||||
renderRow({ type: 'script', config: { path: '/scripts/my-script.sh' } });
|
||||
expect(screen.getByText('/scripts/my-script.sh')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Enabled switch ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('enabled switch', () => {
|
||||
it('renders checked when integration.enabled is true', () => {
|
||||
renderRow({ enabled: true });
|
||||
expect(screen.getByTestId('toggle-switch')).toBeChecked();
|
||||
});
|
||||
|
||||
it('renders unchecked when integration.enabled is false', () => {
|
||||
renderRow({ enabled: false });
|
||||
expect(screen.getByTestId('toggle-switch')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('calls updateConnectIntegration with toggled enabled value on toggle', async () => {
|
||||
renderRow({ id: 5, enabled: true });
|
||||
fireEvent.click(screen.getByTestId('toggle-switch'));
|
||||
await waitFor(() => {
|
||||
expect(API.updateConnectIntegration).toHaveBeenCalledWith(
|
||||
5,
|
||||
expect.objectContaining({ enabled: false })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles from false to true', async () => {
|
||||
renderRow({ id: 5, enabled: false });
|
||||
fireEvent.click(screen.getByTestId('toggle-switch'));
|
||||
await waitFor(() => {
|
||||
expect(API.updateConnectIntegration).toHaveBeenCalledWith(
|
||||
5,
|
||||
expect.objectContaining({ enabled: true })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not throw when updateConnectIntegration fails', async () => {
|
||||
vi.mocked(API.updateConnectIntegration).mockRejectedValue(
|
||||
new Error('fail')
|
||||
);
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
renderRow({ enabled: true });
|
||||
|
||||
await expect(
|
||||
waitFor(() => fireEvent.click(screen.getByTestId('toggle-switch')))
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Subscription badges ────────────────────────────────────────────────────
|
||||
|
||||
describe('subscription badges', () => {
|
||||
it('renders a badge for each enabled subscription', () => {
|
||||
renderRow({
|
||||
subscriptions: [
|
||||
{ event: 'channel_start', enabled: true },
|
||||
{ event: 'recording_start', enabled: true },
|
||||
],
|
||||
});
|
||||
expect(screen.getByText('Channel Started')).toBeInTheDocument();
|
||||
expect(screen.getByText('Recording Started')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render badges for disabled subscriptions', () => {
|
||||
renderRow({
|
||||
subscriptions: [
|
||||
{ event: 'channel_start', enabled: true },
|
||||
{ event: 'channel_stop', enabled: false },
|
||||
],
|
||||
});
|
||||
expect(screen.getByText('Channel Started')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Channel Stopped')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the raw event name when not in SUBSCRIPTION_EVENTS', () => {
|
||||
renderRow({
|
||||
subscriptions: [{ event: 'custom_event', enabled: true }],
|
||||
});
|
||||
expect(screen.getByText('custom_event')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Action buttons ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('action buttons', () => {
|
||||
it('opens ConnectionForm with the integration when Edit is clicked', () => {
|
||||
const integration = makeIntegration({ id: 9, name: 'Test Hook' });
|
||||
setupStore({ integrations: [integration] });
|
||||
render(<ConnectPage />);
|
||||
fireEvent.click(screen.getByText('Edit'));
|
||||
expect(screen.getByTestId('connection-form-id')).toHaveTextContent('9');
|
||||
});
|
||||
|
||||
it('calls deleteConnectIntegration with the correct id when Delete is clicked', async () => {
|
||||
renderRow({ id: 11 });
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
await waitFor(() => {
|
||||
expect(API.deleteConnectIntegration).toHaveBeenCalledWith(11);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
462
frontend/src/pages/__tests__/ConnectLogs.test.jsx
Normal file
462
frontend/src/pages/__tests__/ConnectLogs.test.jsx
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
act,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── API mock ───────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../api', () => ({
|
||||
default: {
|
||||
getConnectLogs: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Store mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../store/connect', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Constants mock ─────────────────────────────────────────────────────────────
|
||||
vi.mock('../../constants', () => ({
|
||||
SUBSCRIPTION_EVENTS: {
|
||||
channel_start: 'Channel Started',
|
||||
channel_stop: 'Channel Stopped',
|
||||
},
|
||||
}));
|
||||
|
||||
// ── CustomTable mock ───────────────────────────────────────────────────────────
|
||||
vi.mock('../../components/tables/CustomTable', () => ({
|
||||
CustomTable: () => <div data-testid="custom-table" />,
|
||||
useTable: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
// ── Utils mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../utils', () => ({
|
||||
copyToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
FileCode: () => <svg data-testid="icon-file-code" />,
|
||||
Webhook: () => <svg data-testid="icon-webhook" />,
|
||||
}));
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Badge: ({ children, color, variant }) => (
|
||||
<span data-testid="badge" data-color={color} data-variant={variant}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Box: ({ children }) => <div>{children}</div>,
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
LoadingOverlay: ({ visible }) =>
|
||||
visible ? <div data-testid="loading-overlay" /> : null,
|
||||
NativeSelect: ({ value, onChange, data }) => (
|
||||
<select data-testid="page-size-select" value={value} onChange={onChange}>
|
||||
{data?.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
Pagination: ({ total, value, onChange }) => (
|
||||
<div data-testid="pagination">
|
||||
<span data-testid="pagination-total">{total}</span>
|
||||
<button
|
||||
data-testid="next-page"
|
||||
onClick={() => onChange(value + 1)}
|
||||
disabled={value >= total}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
Paper: ({ children }) => <div>{children}</div>,
|
||||
// Distinguish type filter (has 'webhook' option) from integration filter
|
||||
Select: ({ data, value, onChange }) => {
|
||||
const isTypeFilter = data?.some((d) => d.value === 'webhook');
|
||||
return (
|
||||
<select
|
||||
data-testid={isTypeFilter ? 'select-type' : 'select-integration'}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
{data?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
},
|
||||
Text: ({ children, size }) => <span data-size={size}>{children}</span>,
|
||||
Title: ({ children, order }) => <h3 data-order={order}>{children}</h3>,
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import ConnectLogsPage from '../ConnectLogs';
|
||||
import API from '../../api';
|
||||
import useConnectStore from '../../store/connect';
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
const makeIntegration = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: 'My Webhook',
|
||||
type: 'webhook',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupStore = ({
|
||||
integrations = [],
|
||||
fetchIntegrations = vi.fn(),
|
||||
} = {}) => {
|
||||
vi.mocked(useConnectStore).mockReturnValue({
|
||||
integrations,
|
||||
fetchIntegrations,
|
||||
});
|
||||
return { fetchIntegrations };
|
||||
};
|
||||
|
||||
const setupApiResponse = (overrides = {}) => {
|
||||
vi.mocked(API.getConnectLogs).mockResolvedValue({
|
||||
results: [],
|
||||
count: 0,
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ConnectLogsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupApiResponse();
|
||||
setupStore();
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the "Connect Logs" title', () => {
|
||||
render(<ConnectLogsPage />);
|
||||
expect(screen.getByText('Connect Logs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the type filter select', () => {
|
||||
render(<ConnectLogsPage />);
|
||||
expect(screen.getByTestId('select-type')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the integration filter select', () => {
|
||||
render(<ConnectLogsPage />);
|
||||
expect(screen.getByTestId('select-integration')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the table', () => {
|
||||
render(<ConnectLogsPage />);
|
||||
expect(screen.getByTestId('custom-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the page size select and pagination', () => {
|
||||
render(<ConnectLogsPage />);
|
||||
expect(screen.getByTestId('page-size-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('pagination')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Integration initialization ─────────────────────────────────────────────
|
||||
|
||||
describe('integration initialization', () => {
|
||||
it('calls fetchIntegrations on mount when integrations list is empty', async () => {
|
||||
const { fetchIntegrations } = setupStore({ integrations: [] });
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(fetchIntegrations).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call fetchIntegrations when integrations are already loaded', async () => {
|
||||
const { fetchIntegrations } = setupStore({
|
||||
integrations: [makeIntegration()],
|
||||
});
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalled();
|
||||
});
|
||||
expect(fetchIntegrations).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── API calls ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('API calls', () => {
|
||||
it('calls getConnectLogs on mount with page=1 and page_size=50', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ page: 1, page_size: 50 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not include type in params when type filter is empty', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalled();
|
||||
});
|
||||
const callArgs = vi.mocked(API.getConnectLogs).mock.calls[0][0];
|
||||
expect(callArgs).not.toHaveProperty('type');
|
||||
});
|
||||
|
||||
it('does not include integration in params when integration filter is empty', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalled();
|
||||
});
|
||||
const callArgs = vi.mocked(API.getConnectLogs).mock.calls[0][0];
|
||||
expect(callArgs).not.toHaveProperty('integration');
|
||||
});
|
||||
|
||||
it('handles an array API response', async () => {
|
||||
vi.mocked(API.getConnectLogs).mockResolvedValue([]);
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalled();
|
||||
expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts count from a paginated API response', async () => {
|
||||
vi.mocked(API.getConnectLogs).mockResolvedValue({
|
||||
results: [],
|
||||
count: 77,
|
||||
});
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/77/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Loading state ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows LoadingOverlay while the fetch is in progress', async () => {
|
||||
let resolve;
|
||||
vi.mocked(API.getConnectLogs).mockReturnValue(
|
||||
new Promise((r) => {
|
||||
resolve = r;
|
||||
})
|
||||
);
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading-overlay')).toBeInTheDocument();
|
||||
});
|
||||
await act(async () => {
|
||||
resolve({ results: [], count: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
it('hides LoadingOverlay after the fetch completes', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Pagination string ──────────────────────────────────────────────────────
|
||||
|
||||
describe('pagination string', () => {
|
||||
it('shows the correct range and total when results are returned', async () => {
|
||||
vi.mocked(API.getConnectLogs).mockResolvedValue({
|
||||
results: [],
|
||||
count: 120,
|
||||
});
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Showing 1-50 of 120')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Showing 1-0 of 0" when there are no logs', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Showing 1-0 of 0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Page count ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('page count', () => {
|
||||
it('computes pageCount as ceil(count / pageSize)', async () => {
|
||||
vi.mocked(API.getConnectLogs).mockResolvedValue({
|
||||
results: [],
|
||||
count: 110,
|
||||
});
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
// ceil(110 / 50) = 3
|
||||
expect(screen.getByTestId('pagination-total')).toHaveTextContent('3');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows at least 1 page when count is 0', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('pagination-total')).toHaveTextContent('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Type filter ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('type filter', () => {
|
||||
it('populates type filter with All, Webhooks, Scripts options', () => {
|
||||
render(<ConnectLogsPage />);
|
||||
const typeSelect = screen.getByTestId('select-type');
|
||||
expect(
|
||||
within(typeSelect).getByRole('option', { name: 'All' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(typeSelect).getByRole('option', { name: 'Webhooks' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(typeSelect).getByRole('option', { name: 'Scripts' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refetches with type param when type filter changes', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
|
||||
|
||||
fireEvent.change(screen.getByTestId('select-type'), {
|
||||
target: { value: 'webhook' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'webhook' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('omits type from params when filter is reset to empty', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
fireEvent.change(screen.getByTestId('select-type'), {
|
||||
target: { value: 'webhook' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'webhook' })
|
||||
);
|
||||
});
|
||||
|
||||
vi.mocked(API.getConnectLogs).mockClear();
|
||||
fireEvent.change(screen.getByTestId('select-type'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalled();
|
||||
const args = vi.mocked(API.getConnectLogs).mock.calls[0][0];
|
||||
expect(args).not.toHaveProperty('type');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Integration filter ─────────────────────────────────────────────────────
|
||||
|
||||
describe('integration filter', () => {
|
||||
it('populates integration select from store integrations', async () => {
|
||||
setupStore({
|
||||
integrations: [makeIntegration({ id: 3, name: 'Plex Hook' })],
|
||||
});
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('option', { name: 'Plex Hook' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('refetches with integration param when integration filter changes', async () => {
|
||||
setupStore({
|
||||
integrations: [makeIntegration({ id: 5, name: 'Hook 5' })],
|
||||
});
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
|
||||
|
||||
fireEvent.change(screen.getByTestId('select-integration'), {
|
||||
target: { value: '5' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ integration: '5' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Page size change ───────────────────────────────────────────────────────
|
||||
|
||||
describe('page size change', () => {
|
||||
it('refetches with new page_size when page size is changed', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
|
||||
|
||||
fireEvent.change(screen.getByTestId('page-size-select'), {
|
||||
target: { value: '100' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ page_size: 100 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('resets to page 1 when page size changes', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
|
||||
|
||||
fireEvent.change(screen.getByTestId('page-size-select'), {
|
||||
target: { value: '25' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ page: 1 })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Page navigation ────────────────────────────────────────────────────────
|
||||
|
||||
describe('page navigation', () => {
|
||||
it('refetches with page 2 when the next page button is clicked', async () => {
|
||||
vi.mocked(API.getConnectLogs).mockResolvedValue({
|
||||
results: [],
|
||||
count: 100,
|
||||
});
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
|
||||
|
||||
fireEvent.click(screen.getByTestId('next-page'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ page: 2 })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
631
frontend/src/pages/__tests__/PluginBrowse.test.jsx
Normal file
631
frontend/src/pages/__tests__/PluginBrowse.test.jsx
Normal file
|
|
@ -0,0 +1,631 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../store/plugins.jsx', () => {
|
||||
const mockStore = vi.fn();
|
||||
mockStore.getState = vi.fn(() => ({ repos: [], invalidatePlugins: vi.fn() }));
|
||||
return { usePluginStore: mockStore };
|
||||
});
|
||||
|
||||
vi.mock('../../store/settings.jsx', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Component mocks ────────────────────────────────────────────────────────────
|
||||
vi.mock('../../components/cards/AvailablePluginCard.jsx', () => ({
|
||||
default: ({ plugin }) => (
|
||||
<div data-testid="plugin-card" data-slug={plugin.slug}>
|
||||
{plugin.name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/modals/ManageReposModal.jsx', () => ({
|
||||
default: ({ opened, onClose }) =>
|
||||
opened ? (
|
||||
<div data-testid="manage-repos-modal">
|
||||
<button data-testid="manage-repos-close" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../utils/notificationUtils.js', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/pages/PluginsUtils.js', () => ({
|
||||
reloadPlugins: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/components/pluginUtils.js', () => ({
|
||||
compareVersions: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
Package: () => <svg data-testid="icon-package" />,
|
||||
RefreshCcw: () => <svg data-testid="icon-refresh" />,
|
||||
Search: () => <svg data-testid="icon-search" />,
|
||||
}));
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
ActionIcon: ({ children, onClick, loading, title }) => (
|
||||
<button
|
||||
data-testid="action-icon"
|
||||
data-loading={loading}
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
AppShellMain: ({ children }) => <main>{children}</main>,
|
||||
Badge: ({ children, color, variant }) => (
|
||||
<span data-testid="badge" data-color={color} data-variant={variant}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Box: ({ children }) => <div>{children}</div>,
|
||||
Button: ({ children, onClick, href, variant, color, leftSection }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
data-href={href}
|
||||
data-variant={variant}
|
||||
data-color={color}
|
||||
>
|
||||
{leftSection}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Loader: () => <div data-testid="loader" />,
|
||||
NativeSelect: ({ value, onChange, data }) => (
|
||||
<select data-testid="page-size-select" value={value} onChange={onChange}>
|
||||
{data?.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
Pagination: ({ total, value, onChange }) => (
|
||||
<div data-testid="pagination">
|
||||
<span data-testid="pagination-total">{total}</span>
|
||||
<span data-testid="pagination-value">{value}</span>
|
||||
<button
|
||||
data-testid="next-page"
|
||||
onClick={() => onChange(value + 1)}
|
||||
disabled={value >= total}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
Select: ({ data, value, onChange }) => {
|
||||
const isSort = data?.some((d) => d.value === 'name-asc');
|
||||
const isStatus = data?.some((d) => d.value === 'installed');
|
||||
const testId = isSort
|
||||
? 'sort-select'
|
||||
: isStatus
|
||||
? 'status-select'
|
||||
: 'repo-select';
|
||||
return (
|
||||
<select
|
||||
data-testid={testId}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
{data?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
},
|
||||
SimpleGrid: ({ children }) => <div data-testid="plugin-grid">{children}</div>,
|
||||
Text: ({ children, fw, size, c }) => (
|
||||
<span data-fw={fw} data-size={size} data-color={c}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
TextInput: ({ value, onChange, placeholder }) => (
|
||||
<input
|
||||
data-testid="search-input"
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import PluginBrowsePage from '../PluginBrowse';
|
||||
import { usePluginStore } from '../../store/plugins.jsx';
|
||||
import useSettingsStore from '../../store/settings.jsx';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
import { reloadPlugins } from '../../utils/pages/PluginsUtils.js';
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
const makePlugin = (overrides = {}) => ({
|
||||
slug: 'my-plugin',
|
||||
repo_id: 1,
|
||||
repo_name: 'Main Repo',
|
||||
name: 'My Plugin',
|
||||
description: 'A test plugin',
|
||||
author: 'Test Author',
|
||||
installed: false,
|
||||
install_status: null,
|
||||
deprecated: false,
|
||||
min_dispatcharr_version: null,
|
||||
max_dispatcharr_version: null,
|
||||
last_updated: '2024-01-01',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeRepo = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: 'Main Repo',
|
||||
url: 'https://example.com',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
let mockFetchRepos;
|
||||
let mockFetchAvailablePlugins;
|
||||
let mockRefreshRepo;
|
||||
let mockInvalidatePlugins;
|
||||
|
||||
const setupStore = ({
|
||||
repos = [],
|
||||
availablePlugins = [],
|
||||
availableLoading = false,
|
||||
} = {}) => {
|
||||
mockFetchRepos = vi.fn();
|
||||
mockFetchAvailablePlugins = vi.fn();
|
||||
mockRefreshRepo = vi.fn().mockResolvedValue(undefined);
|
||||
mockInvalidatePlugins = vi.fn();
|
||||
|
||||
vi.mocked(usePluginStore).mockImplementation((sel) =>
|
||||
sel({
|
||||
repos,
|
||||
availablePlugins,
|
||||
availableLoading,
|
||||
fetchRepos: mockFetchRepos,
|
||||
fetchAvailablePlugins: mockFetchAvailablePlugins,
|
||||
refreshRepo: mockRefreshRepo,
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(usePluginStore).getState.mockReturnValue({
|
||||
repos,
|
||||
invalidatePlugins: mockInvalidatePlugins,
|
||||
});
|
||||
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ version: { version: '1.0.0' } })
|
||||
);
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PluginBrowsePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(reloadPlugins).mockResolvedValue(undefined);
|
||||
setupStore();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// ── Initialization ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('initialization', () => {
|
||||
it('calls fetchRepos and fetchAvailablePlugins on mount', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
expect(mockFetchRepos).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchAvailablePlugins).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not refetch on re-render (hasFetched guard)', () => {
|
||||
const { rerender } = render(<PluginBrowsePage />);
|
||||
rerender(<PluginBrowsePage />);
|
||||
expect(mockFetchRepos).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders "Find Plugins" title', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.getByText('Find Plugins')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows plugin count badge when plugins are available', () => {
|
||||
setupStore({
|
||||
availablePlugins: [makePlugin(), makePlugin({ slug: 'p2' })],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.getByText('2 Plugins Available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show plugin count badge when no plugins', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.queryByText(/Plugins Available/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows repo count badge when repos > 1', () => {
|
||||
setupStore({ repos: [makeRepo(), makeRepo({ id: 2 })] });
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.getByText('2 Repos')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show repo count badge when only 1 repo', () => {
|
||||
setupStore({ repos: [makeRepo()] });
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.queryByText(/\d+ Repos/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Loader when loading and no plugins yet', () => {
|
||||
setupStore({ availableLoading: true, availablePlugins: [] });
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.getByTestId('loader')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show Loader when not loading', () => {
|
||||
setupStore({ availableLoading: false });
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.queryByTestId('loader')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show Loader when loading but plugins are already loaded', () => {
|
||||
setupStore({ availableLoading: true, availablePlugins: [makePlugin()] });
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.queryByTestId('loader')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Empty states ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('empty states', () => {
|
||||
it('shows "No plugins available" when there are no plugins', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
expect(
|
||||
screen.getByText(/No plugins available. Try refreshing repos/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "No plugins match" when all plugins are filtered out', () => {
|
||||
setupStore({ availablePlugins: [makePlugin({ name: 'Plex' })] });
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.change(screen.getByTestId('search-input'), {
|
||||
target: { value: 'xyznotfound' },
|
||||
});
|
||||
expect(
|
||||
screen.getByText(/No plugins match your filters/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show "No plugins match" when there are no plugins at all', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
expect(
|
||||
screen.queryByText(/No plugins match your filters/)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Plugin cards ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('plugin cards', () => {
|
||||
it('renders a card for each visible plugin', () => {
|
||||
setupStore({
|
||||
availablePlugins: [
|
||||
makePlugin({ slug: 'a', name: 'Plugin A' }),
|
||||
makePlugin({ slug: 'b', name: 'Plugin B' }),
|
||||
],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.getAllByTestId('plugin-card')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders no cards when no plugins available', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.queryByTestId('plugin-card')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Manage Repos modal ─────────────────────────────────────────────────────
|
||||
|
||||
describe('Manage Repos modal', () => {
|
||||
it('ManageReposModal is not visible initially', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
expect(
|
||||
screen.queryByTestId('manage-repos-modal')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens ManageReposModal when "Manage Repos" button is clicked', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.click(screen.getByText('Manage Repos'));
|
||||
expect(screen.getByTestId('manage-repos-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes ManageReposModal when its close handler is called', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.click(screen.getByText('Manage Repos'));
|
||||
fireEvent.click(screen.getByTestId('manage-repos-close'));
|
||||
expect(
|
||||
screen.queryByTestId('manage-repos-modal')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Refresh all ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('handleRefreshAll', () => {
|
||||
it('calls refreshRepo for each repo in the store', async () => {
|
||||
const repos = [makeRepo({ id: 1 }), makeRepo({ id: 2 })];
|
||||
setupStore({ repos });
|
||||
vi.mocked(usePluginStore).getState.mockReturnValue({
|
||||
repos,
|
||||
invalidatePlugins: mockInvalidatePlugins,
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.click(screen.getByTestId('action-icon'));
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshRepo).toHaveBeenCalledWith(1);
|
||||
expect(mockRefreshRepo).toHaveBeenCalledWith(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchAvailablePlugins, reloadPlugins, and invalidatePlugins after refresh', async () => {
|
||||
setupStore({ repos: [makeRepo()] });
|
||||
vi.mocked(usePluginStore).getState.mockReturnValue({
|
||||
repos: [makeRepo()],
|
||||
invalidatePlugins: mockInvalidatePlugins,
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.click(screen.getByTestId('action-icon'));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAvailablePlugins).toHaveBeenCalled();
|
||||
expect(reloadPlugins).toHaveBeenCalled();
|
||||
expect(mockInvalidatePlugins).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success notification on successful refresh', async () => {
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.click(screen.getByTestId('action-icon'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Refreshed', color: 'green' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error notification when refresh fails', async () => {
|
||||
mockRefreshRepo = vi.fn().mockRejectedValue(new Error('Network error'));
|
||||
vi.mocked(usePluginStore).mockImplementation((sel) =>
|
||||
sel({
|
||||
repos: [makeRepo()],
|
||||
availablePlugins: [],
|
||||
availableLoading: false,
|
||||
fetchRepos: vi.fn(),
|
||||
fetchAvailablePlugins: vi.fn(),
|
||||
refreshRepo: mockRefreshRepo,
|
||||
})
|
||||
);
|
||||
vi.mocked(usePluginStore).getState.mockReturnValue({
|
||||
repos: [makeRepo()],
|
||||
invalidatePlugins: vi.fn(),
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.click(screen.getByTestId('action-icon'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Error', color: 'red' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Search filter ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('search filter', () => {
|
||||
it('filters plugins by name', () => {
|
||||
setupStore({
|
||||
availablePlugins: [
|
||||
makePlugin({ slug: 'plex', name: 'Plex Plugin' }),
|
||||
makePlugin({ slug: 'other', name: 'Other Plugin' }),
|
||||
],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.change(screen.getByTestId('search-input'), {
|
||||
target: { value: 'plex' },
|
||||
});
|
||||
const cards = screen.getAllByTestId('plugin-card');
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0]).toHaveTextContent('Plex Plugin');
|
||||
});
|
||||
|
||||
it('filters plugins by description', () => {
|
||||
setupStore({
|
||||
availablePlugins: [
|
||||
makePlugin({
|
||||
slug: 'a',
|
||||
name: 'Alpha',
|
||||
description: 'streaming service',
|
||||
}),
|
||||
makePlugin({ slug: 'b', name: 'Beta', description: 'other thing' }),
|
||||
],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.change(screen.getByTestId('search-input'), {
|
||||
target: { value: 'streaming' },
|
||||
});
|
||||
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters plugins by author', () => {
|
||||
setupStore({
|
||||
availablePlugins: [
|
||||
makePlugin({ slug: 'a', name: 'A', author: 'alice' }),
|
||||
makePlugin({ slug: 'b', name: 'B', author: 'bob' }),
|
||||
],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.change(screen.getByTestId('search-input'), {
|
||||
target: { value: 'alice' },
|
||||
});
|
||||
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
setupStore({ availablePlugins: [makePlugin({ name: 'PlexPlugin' })] });
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.change(screen.getByTestId('search-input'), {
|
||||
target: { value: 'PLEXPLUGIN' },
|
||||
});
|
||||
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Status filter ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('status filter', () => {
|
||||
it('filters to installed plugins only', () => {
|
||||
setupStore({
|
||||
availablePlugins: [
|
||||
makePlugin({ slug: 'a', installed: true }),
|
||||
makePlugin({ slug: 'b', installed: false }),
|
||||
],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.change(screen.getByTestId('status-select'), {
|
||||
target: { value: 'installed' },
|
||||
});
|
||||
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('filters to not-installed plugins only', () => {
|
||||
setupStore({
|
||||
availablePlugins: [
|
||||
makePlugin({ slug: 'a', installed: true }),
|
||||
makePlugin({ slug: 'b', installed: false }),
|
||||
makePlugin({ slug: 'c', installed: false }),
|
||||
],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.change(screen.getByTestId('status-select'), {
|
||||
target: { value: 'not-installed' },
|
||||
});
|
||||
expect(screen.getAllByTestId('plugin-card')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Repo filter ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('repo filter', () => {
|
||||
it('does not show repo filter when only one repo', () => {
|
||||
setupStore({
|
||||
availablePlugins: [makePlugin({ repo_id: 1, repo_name: 'Repo 1' })],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
// repoOptions.length = 2 (all + 1 repo), which is NOT > 2, so hidden
|
||||
expect(screen.queryByTestId('repo-select')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows repo filter when there are multiple repos', () => {
|
||||
setupStore({
|
||||
availablePlugins: [
|
||||
makePlugin({ slug: 'a', repo_id: 1, repo_name: 'Repo 1' }),
|
||||
makePlugin({ slug: 'b', repo_id: 2, repo_name: 'Repo 2' }),
|
||||
makePlugin({ slug: 'c', repo_id: 3, repo_name: 'Repo 3' }),
|
||||
],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
// repoOptions.length = 4 (all + 3 repos), which IS > 2
|
||||
expect(screen.getByTestId('repo-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters plugins by selected repo', () => {
|
||||
setupStore({
|
||||
availablePlugins: [
|
||||
makePlugin({ slug: 'a', repo_id: 1, repo_name: 'Repo 1' }),
|
||||
makePlugin({ slug: 'b', repo_id: 2, repo_name: 'Repo 2' }),
|
||||
makePlugin({ slug: 'c', repo_id: 3, repo_name: 'Repo 3' }),
|
||||
],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.change(screen.getByTestId('repo-select'), {
|
||||
target: { value: '2' },
|
||||
});
|
||||
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Pagination ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('pagination', () => {
|
||||
it('shows pagination bar only when there are filtered plugins', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.queryByTestId('pagination')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows pagination when plugins are available', () => {
|
||||
setupStore({ availablePlugins: [makePlugin()] });
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.getByTestId('pagination')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows correct pagination range text', () => {
|
||||
const plugins = Array.from({ length: 5 }, (_, i) =>
|
||||
makePlugin({ slug: `plugin-${i}`, name: `Plugin ${i}` })
|
||||
);
|
||||
setupStore({ availablePlugins: plugins });
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.getByText('1 to 5 of 5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves perPage to localStorage when page size changes', () => {
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
setupStore({ availablePlugins: [makePlugin()] });
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.change(screen.getByTestId('page-size-select'), {
|
||||
target: { value: '18' },
|
||||
});
|
||||
expect(setItemSpy).toHaveBeenCalledWith('pluginBrowsePerPage', '18');
|
||||
});
|
||||
|
||||
it('reads initial perPage from localStorage', () => {
|
||||
localStorage.setItem('pluginBrowsePerPage', '27');
|
||||
const plugins = Array.from({ length: 27 }, (_, i) =>
|
||||
makePlugin({ slug: `p-${i}`, name: `Plugin ${i}` })
|
||||
);
|
||||
setupStore({ availablePlugins: plugins });
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.getByTestId('page-size-select')).toHaveValue('27');
|
||||
});
|
||||
|
||||
it('resets page to 1 when search query changes', () => {
|
||||
const plugins = Array.from({ length: 20 }, (_, i) =>
|
||||
makePlugin({ slug: `p-${i}`, name: `Plugin ${i}` })
|
||||
);
|
||||
setupStore({ availablePlugins: plugins });
|
||||
render(<PluginBrowsePage />);
|
||||
// Go to page 2
|
||||
fireEvent.click(screen.getByTestId('next-page'));
|
||||
expect(screen.getByTestId('pagination-value')).toHaveTextContent('2');
|
||||
// Change search → page resets
|
||||
fireEvent.change(screen.getByTestId('search-input'), {
|
||||
target: { value: 'Plugin' },
|
||||
});
|
||||
expect(screen.getByTestId('pagination-value')).toHaveTextContent('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -23,22 +23,39 @@ export const computeCatchupPlaybackSeconds = ({
|
|||
programDurationSecs,
|
||||
positionAnchorAt,
|
||||
playbackBaseSecs,
|
||||
paused = false,
|
||||
nowMs = getNowMs(),
|
||||
capToDuration = true,
|
||||
allowNegative = false,
|
||||
}) => {
|
||||
let elapsedSinceAnchor = 0;
|
||||
if (positionAnchorAt != null) {
|
||||
if (!paused && positionAnchorAt != null) {
|
||||
const anchor = Number(positionAnchorAt);
|
||||
if (!Number.isNaN(anchor)) {
|
||||
elapsedSinceAnchor = Math.max(0, nowMs / 1000 - anchor);
|
||||
}
|
||||
}
|
||||
|
||||
if (playbackBaseSecs != null && !Number.isNaN(Number(playbackBaseSecs))) {
|
||||
let position = Number(playbackBaseSecs) + elapsedSinceAnchor;
|
||||
if (position < 0) {
|
||||
// Byte-range / client-reported bases are relative to the programme that
|
||||
// contained the original catch-up URL. When the stats card has advanced to a
|
||||
// later guide entry, rebase onto that programme's start.
|
||||
let effectiveBase = playbackBaseSecs;
|
||||
if (effectiveBase != null && !Number.isNaN(Number(effectiveBase))) {
|
||||
const urlMs = parseCatchupTimestampMs(programmeStart);
|
||||
const epgStartMs = programStartTime ? Date.parse(programStartTime) : null;
|
||||
if (
|
||||
urlMs != null &&
|
||||
epgStartMs != null &&
|
||||
!Number.isNaN(epgStartMs) &&
|
||||
epgStartMs > urlMs
|
||||
) {
|
||||
effectiveBase = Number(effectiveBase) - (epgStartMs - urlMs) / 1000;
|
||||
}
|
||||
let position = Number(effectiveBase) + elapsedSinceAnchor;
|
||||
if (!allowNegative && position < 0) {
|
||||
position = 0;
|
||||
}
|
||||
if (programDurationSecs != null) {
|
||||
if (capToDuration && programDurationSecs != null) {
|
||||
position = Math.min(position, Number(programDurationSecs));
|
||||
}
|
||||
return position;
|
||||
|
|
@ -51,15 +68,88 @@ export const computeCatchupPlaybackSeconds = ({
|
|||
}
|
||||
const urlOffsetSec = (urlMs - epgStartMs) / 1000;
|
||||
let position = urlOffsetSec + elapsedSinceAnchor;
|
||||
if (position < 0) {
|
||||
if (!allowNegative && position < 0) {
|
||||
position = 0;
|
||||
}
|
||||
if (programDurationSecs != null) {
|
||||
if (capToDuration && programDurationSecs != null) {
|
||||
position = Math.min(position, Number(programDurationSecs));
|
||||
}
|
||||
return position;
|
||||
};
|
||||
|
||||
/**
|
||||
* True when the catch-up playhead is outside the displayed programme window.
|
||||
* Used to fetch the next guide entry once, then cache until that show ends.
|
||||
*/
|
||||
export const isCatchupPlayheadOutsideProgram = ({
|
||||
programmeStart,
|
||||
programStartTime,
|
||||
programDurationSecs,
|
||||
positionAnchorAt,
|
||||
playbackBaseSecs,
|
||||
paused = false,
|
||||
nowMs = getNowMs(),
|
||||
}) => {
|
||||
if (programDurationSecs == null || !programStartTime) {
|
||||
return true;
|
||||
}
|
||||
const position = computeCatchupPlaybackSeconds({
|
||||
programmeStart,
|
||||
programStartTime,
|
||||
programDurationSecs,
|
||||
positionAnchorAt,
|
||||
playbackBaseSecs,
|
||||
paused,
|
||||
nowMs,
|
||||
capToDuration: false,
|
||||
allowNegative: true,
|
||||
});
|
||||
if (position == null) {
|
||||
return false;
|
||||
}
|
||||
return position < 0 || position >= Number(programDurationSecs);
|
||||
};
|
||||
|
||||
/**
|
||||
* Archive playhead relative to the programme containing ``programmeStart``.
|
||||
* Uncapped so callers can detect when viewing has moved past that show.
|
||||
* Does not rebase onto a later displayed programme.
|
||||
*/
|
||||
export const computeCatchupArchivePositionSecs = ({
|
||||
programmeStart,
|
||||
programStartTime,
|
||||
positionAnchorAt,
|
||||
playbackBaseSecs,
|
||||
paused = false,
|
||||
nowMs = getNowMs(),
|
||||
}) => {
|
||||
let elapsedSinceAnchor = 0;
|
||||
if (!paused && positionAnchorAt != null) {
|
||||
const anchor = Number(positionAnchorAt);
|
||||
if (!Number.isNaN(anchor)) {
|
||||
elapsedSinceAnchor = Math.max(0, nowMs / 1000 - anchor);
|
||||
}
|
||||
}
|
||||
|
||||
// playback_base is always relative to the URL programme, even after the
|
||||
// stats card has advanced to a later guide entry.
|
||||
if (playbackBaseSecs != null && !Number.isNaN(Number(playbackBaseSecs))) {
|
||||
return Math.max(0, Number(playbackBaseSecs) + elapsedSinceAnchor);
|
||||
}
|
||||
|
||||
const urlMs = parseCatchupTimestampMs(programmeStart);
|
||||
const epgStartMs = programStartTime ? Date.parse(programStartTime) : null;
|
||||
if (urlMs == null || epgStartMs == null || Number.isNaN(epgStartMs)) {
|
||||
return null;
|
||||
}
|
||||
// Only valid while programStartTime is the programme that contains the URL.
|
||||
// Once the card has advanced, omit position and let the API use Redis.
|
||||
if (epgStartMs > urlMs) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, (urlMs - epgStartMs) / 1000 + elapsedSinceAnchor);
|
||||
};
|
||||
|
||||
export const calculateConnectionDuration = (connection) => {
|
||||
const seconds = getConnectionDurationSeconds(connection);
|
||||
return toFriendlyDuration(seconds, 'seconds');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
computeCatchupPlaybackSeconds,
|
||||
computeCatchupArchivePositionSecs,
|
||||
isCatchupPlayheadOutsideProgram,
|
||||
parseCatchupTimestampMs,
|
||||
} from '../TimeshiftConnectionCardUtils.js';
|
||||
|
||||
|
|
@ -35,4 +37,121 @@ describe('computeCatchupPlaybackSeconds', () => {
|
|||
});
|
||||
expect(position).toBeCloseTo(1830, 5);
|
||||
});
|
||||
|
||||
it('does not advance while paused', () => {
|
||||
const position = computeCatchupPlaybackSeconds({
|
||||
programmeStart: '2026-07-10:14-00',
|
||||
programStartTime: '2026-07-10T14:00:00+00:00',
|
||||
programDurationSecs: 3600,
|
||||
positionAnchorAt: 1000,
|
||||
playbackBaseSecs: 900,
|
||||
paused: true,
|
||||
nowMs: 1300 * 1000,
|
||||
});
|
||||
expect(position).toBeCloseTo(900, 5);
|
||||
});
|
||||
|
||||
it('rebases byte-range playhead onto a later displayed programme', () => {
|
||||
const position = computeCatchupPlaybackSeconds({
|
||||
programmeStart: '2026-07-10:14-00',
|
||||
programStartTime: '2026-07-10T14:30:00+00:00',
|
||||
programDurationSecs: 1800,
|
||||
positionAnchorAt: 1000,
|
||||
playbackBaseSecs: 1900,
|
||||
nowMs: 1000 * 1000,
|
||||
});
|
||||
// 1900s from 14:00 is 100s into the 14:30 programme.
|
||||
expect(position).toBeCloseTo(100, 5);
|
||||
});
|
||||
|
||||
it('can return uncapped position past programme end', () => {
|
||||
const position = computeCatchupPlaybackSeconds({
|
||||
programmeStart: '2026-07-10:14-00',
|
||||
programStartTime: '2026-07-10T14:00:00+00:00',
|
||||
programDurationSecs: 1800,
|
||||
positionAnchorAt: 1000,
|
||||
playbackBaseSecs: 1790,
|
||||
nowMs: 1040 * 1000,
|
||||
capToDuration: false,
|
||||
});
|
||||
expect(position).toBeCloseTo(1830, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCatchupPlayheadOutsideProgram', () => {
|
||||
it('is false while playhead is inside the programme', () => {
|
||||
expect(
|
||||
isCatchupPlayheadOutsideProgram({
|
||||
programmeStart: '2026-07-10:14-00',
|
||||
programStartTime: '2026-07-10T14:00:00+00:00',
|
||||
programDurationSecs: 1800,
|
||||
positionAnchorAt: 1000,
|
||||
playbackBaseSecs: 900,
|
||||
nowMs: 1000 * 1000,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is true once playhead reaches programme end', () => {
|
||||
expect(
|
||||
isCatchupPlayheadOutsideProgram({
|
||||
programmeStart: '2026-07-10:14-00',
|
||||
programStartTime: '2026-07-10T14:00:00+00:00',
|
||||
programDurationSecs: 1800,
|
||||
positionAnchorAt: 1000,
|
||||
playbackBaseSecs: 1800,
|
||||
nowMs: 1000 * 1000,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('detects leaving a later displayed programme', () => {
|
||||
expect(
|
||||
isCatchupPlayheadOutsideProgram({
|
||||
programmeStart: '2026-07-10:14-00',
|
||||
programStartTime: '2026-07-10T14:30:00+00:00',
|
||||
programDurationSecs: 1800,
|
||||
positionAnchorAt: 1000,
|
||||
playbackBaseSecs: 3600,
|
||||
nowMs: 1000 * 1000,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('detects rewind before a later displayed programme', () => {
|
||||
expect(
|
||||
isCatchupPlayheadOutsideProgram({
|
||||
programmeStart: '2026-07-10:14-00',
|
||||
programStartTime: '2026-07-10T14:30:00+00:00',
|
||||
programDurationSecs: 1800,
|
||||
positionAnchorAt: 1000,
|
||||
playbackBaseSecs: 1200,
|
||||
nowMs: 1000 * 1000,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeCatchupArchivePositionSecs', () => {
|
||||
it('returns uncapped absolute playhead from playback base', () => {
|
||||
const position = computeCatchupArchivePositionSecs({
|
||||
programmeStart: '2026-07-10:14-00',
|
||||
programStartTime: '2026-07-10T14:00:00+00:00',
|
||||
positionAnchorAt: 1000,
|
||||
playbackBaseSecs: 1900,
|
||||
nowMs: 1030 * 1000,
|
||||
});
|
||||
expect(position).toBeCloseTo(1930, 5);
|
||||
});
|
||||
|
||||
it('keeps URL-relative base after the card advances to a later programme', () => {
|
||||
const position = computeCatchupArchivePositionSecs({
|
||||
programmeStart: '2026-07-10:14-00',
|
||||
programStartTime: '2026-07-10T14:30:00+00:00',
|
||||
positionAnchorAt: 1000,
|
||||
playbackBaseSecs: 1900,
|
||||
nowMs: 1000 * 1000,
|
||||
});
|
||||
expect(position).toBeCloseTo(1900, 5);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
36
frontend/src/utils/forms/OutputProfileUtils.js
Normal file
36
frontend/src/utils/forms/OutputProfileUtils.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
|
||||
export const BUILT_IN_COMMANDS = [
|
||||
{ value: 'ffmpeg', label: 'FFmpeg' },
|
||||
{ value: '__custom__', label: 'Custom…' },
|
||||
];
|
||||
|
||||
export const COMMAND_EXAMPLES = {
|
||||
ffmpeg:
|
||||
'-i pipe:0 -c:v libx264 -b:v 2000k -vf scale=-2:720 -c:a copy -f mpegts pipe:1',
|
||||
};
|
||||
|
||||
export const toCommandSelection = (command) =>
|
||||
BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__')
|
||||
? command
|
||||
: '__custom__';
|
||||
|
||||
export const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
command: Yup.string().required('Command is required'),
|
||||
parameters: Yup.string(),
|
||||
});
|
||||
|
||||
export const addOutputProfile = (values) => {
|
||||
return API.addOutputProfile(values);
|
||||
};
|
||||
|
||||
export const updateOutputProfile = (values) => {
|
||||
return API.updateOutputProfile(values);
|
||||
};
|
||||
|
||||
export const getResolver = () => {
|
||||
return yupResolver(schema);
|
||||
};
|
||||
16
frontend/src/utils/forms/ServerGroupUtils.js
Normal file
16
frontend/src/utils/forms/ServerGroupUtils.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
});
|
||||
export const getResolver = () => {
|
||||
return yupResolver(schema);
|
||||
};
|
||||
export const updateServerGroup = (values) => {
|
||||
return API.updateServerGroup(values);
|
||||
};
|
||||
export const addServerGroup = (values) => {
|
||||
return API.addServerGroup(values);
|
||||
};
|
||||
210
frontend/src/utils/forms/__tests__/OutputProfileUtils.test.js
Normal file
210
frontend/src/utils/forms/__tests__/OutputProfileUtils.test.js
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Mocks ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('../../../api', () => ({
|
||||
default: {
|
||||
addOutputProfile: vi.fn(),
|
||||
updateOutputProfile: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockResolver = vi.fn();
|
||||
vi.mock('@hookform/resolvers/yup', () => ({
|
||||
yupResolver: vi.fn(() => mockResolver),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
import API from '../../../api';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import {
|
||||
BUILT_IN_COMMANDS,
|
||||
COMMAND_EXAMPLES,
|
||||
toCommandSelection,
|
||||
schema,
|
||||
addOutputProfile,
|
||||
updateOutputProfile,
|
||||
getResolver,
|
||||
} from '../OutputProfileUtils';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('OutputProfileUtils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(yupResolver).mockReturnValue(mockResolver);
|
||||
});
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('BUILT_IN_COMMANDS', () => {
|
||||
it('includes the ffmpeg entry', () => {
|
||||
expect(BUILT_IN_COMMANDS).toContainEqual({
|
||||
value: 'ffmpeg',
|
||||
label: 'FFmpeg',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes the custom entry', () => {
|
||||
expect(BUILT_IN_COMMANDS).toContainEqual({
|
||||
value: '__custom__',
|
||||
label: 'Custom…',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('COMMAND_EXAMPLES', () => {
|
||||
it('has a non-empty string example for ffmpeg', () => {
|
||||
expect(typeof COMMAND_EXAMPLES.ffmpeg).toBe('string');
|
||||
expect(COMMAND_EXAMPLES.ffmpeg.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── toCommandSelection ─────────────────────────────────────────────────────
|
||||
|
||||
describe('toCommandSelection', () => {
|
||||
it('returns the command value when it matches a non-custom built-in', () => {
|
||||
expect(toCommandSelection('ffmpeg')).toBe('ffmpeg');
|
||||
});
|
||||
|
||||
it('returns "__custom__" when command is "__custom__"', () => {
|
||||
// __custom__ is in BUILT_IN_COMMANDS but excluded by the o.value !== '__custom__' guard
|
||||
expect(toCommandSelection('__custom__')).toBe('__custom__');
|
||||
});
|
||||
|
||||
it('returns "__custom__" for an unrecognized command string', () => {
|
||||
expect(toCommandSelection('my-arbitrary-tool')).toBe('__custom__');
|
||||
});
|
||||
|
||||
it('returns "__custom__" for an empty string', () => {
|
||||
expect(toCommandSelection('')).toBe('__custom__');
|
||||
});
|
||||
|
||||
it('returns "__custom__" for undefined', () => {
|
||||
expect(toCommandSelection(undefined)).toBe('__custom__');
|
||||
});
|
||||
});
|
||||
|
||||
// ── schema ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('schema', () => {
|
||||
it('validates a fully populated object', async () => {
|
||||
await expect(
|
||||
schema.validate({
|
||||
name: 'HD Profile',
|
||||
command: 'ffmpeg',
|
||||
parameters: '-c:v copy',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
name: 'HD Profile',
|
||||
command: 'ffmpeg',
|
||||
parameters: '-c:v copy',
|
||||
});
|
||||
});
|
||||
|
||||
it('validates when parameters is omitted (optional)', async () => {
|
||||
await expect(
|
||||
schema.validate({ name: 'HD Profile', command: 'ffmpeg' })
|
||||
).resolves.toMatchObject({ name: 'HD Profile', command: 'ffmpeg' });
|
||||
});
|
||||
|
||||
it('rejects when name is missing', async () => {
|
||||
await expect(schema.validate({ command: 'ffmpeg' })).rejects.toThrow(
|
||||
'Name is required'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects when name is an empty string', async () => {
|
||||
await expect(
|
||||
schema.validate({ name: '', command: 'ffmpeg' })
|
||||
).rejects.toThrow('Name is required');
|
||||
});
|
||||
|
||||
it('rejects when command is missing', async () => {
|
||||
await expect(schema.validate({ name: 'HD Profile' })).rejects.toThrow(
|
||||
'Command is required'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects when command is an empty string', async () => {
|
||||
await expect(
|
||||
schema.validate({ name: 'HD Profile', command: '' })
|
||||
).rejects.toThrow('Command is required');
|
||||
});
|
||||
});
|
||||
|
||||
// ── addOutputProfile ───────────────────────────────────────────────────────
|
||||
|
||||
describe('addOutputProfile', () => {
|
||||
it('calls API.addOutputProfile with the provided values', async () => {
|
||||
const values = { name: 'New Profile', command: 'ffmpeg', parameters: '' };
|
||||
vi.mocked(API.addOutputProfile).mockResolvedValue({ id: 1, ...values });
|
||||
|
||||
await addOutputProfile(values);
|
||||
|
||||
expect(API.addOutputProfile).toHaveBeenCalledWith(values);
|
||||
expect(API.addOutputProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns the API response', async () => {
|
||||
const values = { name: 'New Profile', command: 'ffmpeg' };
|
||||
const response = { id: 42, ...values };
|
||||
vi.mocked(API.addOutputProfile).mockResolvedValue(response);
|
||||
|
||||
const result = await addOutputProfile(values);
|
||||
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
|
||||
it('propagates errors thrown by API.addOutputProfile', async () => {
|
||||
vi.mocked(API.addOutputProfile).mockRejectedValue(
|
||||
new Error('Network error')
|
||||
);
|
||||
|
||||
await expect(addOutputProfile({})).rejects.toThrow('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateOutputProfile ────────────────────────────────────────────────────
|
||||
|
||||
describe('updateOutputProfile', () => {
|
||||
it('calls API.updateOutputProfile with the provided values', async () => {
|
||||
const values = { id: 1, name: 'Updated Profile', command: 'ffmpeg' };
|
||||
vi.mocked(API.updateOutputProfile).mockResolvedValue(values);
|
||||
|
||||
await updateOutputProfile(values);
|
||||
|
||||
expect(API.updateOutputProfile).toHaveBeenCalledWith(values);
|
||||
expect(API.updateOutputProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns the API response', async () => {
|
||||
const values = { id: 7, name: 'Updated Profile', command: 'ffmpeg' };
|
||||
vi.mocked(API.updateOutputProfile).mockResolvedValue(values);
|
||||
|
||||
const result = await updateOutputProfile(values);
|
||||
|
||||
expect(result).toEqual(values);
|
||||
});
|
||||
|
||||
it('propagates errors thrown by API.updateOutputProfile', async () => {
|
||||
vi.mocked(API.updateOutputProfile).mockRejectedValue(
|
||||
new Error('Update failed')
|
||||
);
|
||||
|
||||
await expect(updateOutputProfile({})).rejects.toThrow('Update failed');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getResolver ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getResolver', () => {
|
||||
it('returns the result of yupResolver called with schema', () => {
|
||||
const resolver = getResolver();
|
||||
|
||||
expect(yupResolver).toHaveBeenCalledWith(schema);
|
||||
expect(resolver).toBe(mockResolver);
|
||||
});
|
||||
});
|
||||
});
|
||||
114
frontend/src/utils/forms/__tests__/ServerGroupUtils.test.js
Normal file
114
frontend/src/utils/forms/__tests__/ServerGroupUtils.test.js
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Mocks ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('../../../api', () => ({
|
||||
default: {
|
||||
addServerGroup: vi.fn(),
|
||||
updateServerGroup: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockResolver = vi.fn();
|
||||
vi.mock('@hookform/resolvers/yup', () => ({
|
||||
yupResolver: vi.fn(() => mockResolver),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
import API from '../../../api';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import {
|
||||
getResolver,
|
||||
addServerGroup,
|
||||
updateServerGroup,
|
||||
} from '../ServerGroupUtils';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ServerGroupUtils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(yupResolver).mockReturnValue(mockResolver);
|
||||
});
|
||||
|
||||
// ── getResolver ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getResolver', () => {
|
||||
it('calls yupResolver with a schema and returns the result', () => {
|
||||
const resolver = getResolver();
|
||||
|
||||
expect(yupResolver).toHaveBeenCalledTimes(1);
|
||||
expect(yupResolver).toHaveBeenCalledWith(expect.any(Object));
|
||||
expect(resolver).toBe(mockResolver);
|
||||
});
|
||||
|
||||
it('returns a new resolver on each call', () => {
|
||||
const resolverA = getResolver();
|
||||
const resolverB = getResolver();
|
||||
|
||||
expect(yupResolver).toHaveBeenCalledTimes(2);
|
||||
expect(resolverA).toBe(mockResolver);
|
||||
expect(resolverB).toBe(mockResolver);
|
||||
});
|
||||
});
|
||||
|
||||
// ── addServerGroup ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('addServerGroup', () => {
|
||||
it('calls API.addServerGroup with the provided values', async () => {
|
||||
const values = { name: 'US East' };
|
||||
vi.mocked(API.addServerGroup).mockResolvedValue({ id: 1, ...values });
|
||||
|
||||
await addServerGroup(values);
|
||||
|
||||
expect(API.addServerGroup).toHaveBeenCalledWith(values);
|
||||
expect(API.addServerGroup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns the API response', async () => {
|
||||
const values = { name: 'US East' };
|
||||
const response = { id: 1, ...values };
|
||||
vi.mocked(API.addServerGroup).mockResolvedValue(response);
|
||||
|
||||
const result = await addServerGroup(values);
|
||||
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
|
||||
it('propagates errors thrown by API.addServerGroup', async () => {
|
||||
vi.mocked(API.addServerGroup).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(addServerGroup({ name: 'Test' })).rejects.toThrow('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateServerGroup ──────────────────────────────────────────────────────
|
||||
|
||||
describe('updateServerGroup', () => {
|
||||
it('calls API.updateServerGroup with the provided values', async () => {
|
||||
const values = { id: 5, name: 'EU West' };
|
||||
vi.mocked(API.updateServerGroup).mockResolvedValue(values);
|
||||
|
||||
await updateServerGroup(values);
|
||||
|
||||
expect(API.updateServerGroup).toHaveBeenCalledWith(values);
|
||||
expect(API.updateServerGroup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns the API response', async () => {
|
||||
const values = { id: 5, name: 'EU West' };
|
||||
vi.mocked(API.updateServerGroup).mockResolvedValue(values);
|
||||
|
||||
const result = await updateServerGroup(values);
|
||||
|
||||
expect(result).toEqual(values);
|
||||
});
|
||||
|
||||
it('propagates errors thrown by API.updateServerGroup', async () => {
|
||||
vi.mocked(API.updateServerGroup).mockRejectedValue(new Error('Update failed'));
|
||||
|
||||
await expect(updateServerGroup({ id: 1, name: 'Test' })).rejects.toThrow('Update failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export const getEpgSettingsFormInitialValues = () => ({
|
||||
xmltv_prev_days_override: 0,
|
||||
});
|
||||
|
|
@ -25,3 +25,12 @@ export const deletePluginByKey = (key) => {
|
|||
export const getPluginDetailManifest = (repoId, manifestUrl) => {
|
||||
return API.getPluginDetailManifest(repoId, manifestUrl);
|
||||
};
|
||||
export const getPluginRepoSettings = () => {
|
||||
return API.getPluginRepoSettings();
|
||||
};
|
||||
export const updatePluginRepoSettings = (values) => {
|
||||
return API.updatePluginRepoSettings(values);
|
||||
};
|
||||
export const previewPluginRepo = (url, publicKey) => {
|
||||
return API.previewPluginRepo(url, publicKey);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ export const saveChangedSettings = async (settings, changedSettings) => {
|
|||
'epg_match_ignore_prefixes',
|
||||
'epg_match_ignore_suffixes',
|
||||
'epg_match_ignore_custom',
|
||||
'xmltv_prev_days_override',
|
||||
];
|
||||
const dvrFields = [
|
||||
'tv_template',
|
||||
|
|
@ -126,7 +125,6 @@ export const saveChangedSettings = async (settings, changedSettings) => {
|
|||
'retention_count',
|
||||
'schedule_day_of_week',
|
||||
'max_system_events',
|
||||
'xmltv_prev_days_override',
|
||||
];
|
||||
if (numericFields.includes(formKey) && value != null) {
|
||||
value = typeof value === 'number' ? value : parseInt(value, 10);
|
||||
|
|
@ -224,20 +222,6 @@ export const getChangedSettings = (values, settings) => {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (settingKey === 'xmltv_prev_days_override') {
|
||||
const baseline = Number(
|
||||
settings['epg_settings']?.value?.xmltv_prev_days_override ?? 0,
|
||||
);
|
||||
const nextVal =
|
||||
typeof actualValue === 'number'
|
||||
? actualValue
|
||||
: parseInt(actualValue, 10) || 0;
|
||||
if (nextVal !== baseline) {
|
||||
changedSettings[settingKey] = nextVal;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert array values (like m3u_hash_key) to comma-separated strings for comparison
|
||||
if (Array.isArray(actualValue)) {
|
||||
actualValue = actualValue.join(',');
|
||||
|
|
@ -312,12 +296,6 @@ export const parseSettings = (settings) => {
|
|||
epgSettings && Array.isArray(epgSettings.epg_match_ignore_custom)
|
||||
? epgSettings.epg_match_ignore_custom
|
||||
: [];
|
||||
parsed.xmltv_prev_days_override =
|
||||
epgSettings && epgSettings.xmltv_prev_days_override != null
|
||||
? typeof epgSettings.xmltv_prev_days_override === 'number'
|
||||
? epgSettings.xmltv_prev_days_override
|
||||
: parseInt(epgSettings.xmltv_prev_days_override, 10) || 0
|
||||
: 0;
|
||||
|
||||
// DVR settings - direct mapping with underscore keys
|
||||
const dvrSettings = settings['dvr_settings']?.value;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ vi.mock('../../../api.js', () => ({
|
|||
reloadPlugins: vi.fn(),
|
||||
deletePlugin: vi.fn(),
|
||||
getPluginDetailManifest: vi.fn(),
|
||||
getPluginRepoSettings: vi.fn(),
|
||||
updatePluginRepoSettings: vi.fn(),
|
||||
previewPluginRepo: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -344,4 +347,86 @@ describe('PluginsUtils', () => {
|
|||
expect(API.getPluginDetailManifest).toHaveBeenCalledWith(null, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPluginRepoSettings', () => {
|
||||
it('should call API getPluginRepoSettings', () => {
|
||||
PluginsUtils.getPluginRepoSettings();
|
||||
|
||||
expect(API.getPluginRepoSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return API response', () => {
|
||||
const mockResponse = { repos: [] };
|
||||
|
||||
API.getPluginRepoSettings.mockReturnValue(mockResponse);
|
||||
|
||||
const result = PluginsUtils.getPluginRepoSettings();
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePluginRepoSettings', () => {
|
||||
it('should call API updatePluginRepoSettings with values', () => {
|
||||
const values = { repos: ['https://example.com/repo.json'] };
|
||||
|
||||
PluginsUtils.updatePluginRepoSettings(values);
|
||||
|
||||
expect(API.updatePluginRepoSettings).toHaveBeenCalledWith(values);
|
||||
expect(API.updatePluginRepoSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return API response', () => {
|
||||
const values = { repos: ['https://example.com/repo.json'] };
|
||||
const mockResponse = { success: true };
|
||||
|
||||
API.updatePluginRepoSettings.mockReturnValue(mockResponse);
|
||||
|
||||
const result = PluginsUtils.updatePluginRepoSettings(values);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('previewPluginRepo', () => {
|
||||
it('should call API previewPluginRepo with url and publicKey', () => {
|
||||
const url = 'https://example.com/repo.json';
|
||||
const publicKey = 'public-key';
|
||||
|
||||
PluginsUtils.previewPluginRepo(url, publicKey);
|
||||
|
||||
expect(API.previewPluginRepo).toHaveBeenCalledWith(url, publicKey);
|
||||
expect(API.previewPluginRepo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return API response', () => {
|
||||
const url = 'https://example.com/repo.json';
|
||||
const publicKey = 'public-key';
|
||||
const mockResponse = { name: 'Test Repo', plugins: [] };
|
||||
|
||||
API.previewPluginRepo.mockReturnValue(mockResponse);
|
||||
|
||||
const result = PluginsUtils.previewPluginRepo(url, publicKey);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle empty string url and publicKey', () => {
|
||||
const url = '';
|
||||
const publicKey = '';
|
||||
|
||||
PluginsUtils.previewPluginRepo(url, publicKey);
|
||||
|
||||
expect(API.previewPluginRepo).toHaveBeenCalledWith('', '');
|
||||
});
|
||||
|
||||
it('should handle null url and publicKey', () => {
|
||||
const url = null;
|
||||
const publicKey = null;
|
||||
|
||||
PluginsUtils.previewPluginRepo(url, publicKey);
|
||||
|
||||
expect(API.previewPluginRepo).toHaveBeenCalledWith(null, null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,21 +10,21 @@ export default defineConfig({
|
|||
|
||||
server: {
|
||||
port: 9191,
|
||||
|
||||
// proxy: {
|
||||
// "/api": {
|
||||
// target: "http://localhost:5656", // Backend server
|
||||
// changeOrigin: true,
|
||||
// secure: false, // Set to true if backend uses HTTPS
|
||||
// // rewrite: (path) => path.replace(/^\/api/, ""), // Optional path rewrite
|
||||
// },
|
||||
// "/ws": {
|
||||
// target: "http://localhost:8001", // Backend server
|
||||
// changeOrigin: true,
|
||||
// secure: false, // Set to true if backend uses HTTPS
|
||||
// // rewrite: (path) => path.replace(/^\/api/, ""), // Optional path rewrite
|
||||
// },
|
||||
// },
|
||||
// Without this, /api/* is served as the React SPA in debug mode and
|
||||
// Swagger UI at /api/swagger/ never loads the OpenAPI schema.
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:5656",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
"/ws": {
|
||||
target: "http://127.0.0.1:8001",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
test: {
|
||||
|
|
|
|||
103
tests/test_openapi_schema.py
Normal file
103
tests/test_openapi_schema.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import SimpleTestCase, RequestFactory, override_settings
|
||||
|
||||
from apps.api.schema_views import LockedSpectacularAPIView, clear_schema_cache
|
||||
|
||||
|
||||
def _counting_generator(calls):
|
||||
class CountingGenerator:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def get_schema(self, request=None, public=True):
|
||||
calls["n"] += 1
|
||||
return {"openapi": "3.0.3", "paths": {}, "info": {"title": "t"}}
|
||||
|
||||
return CountingGenerator
|
||||
|
||||
|
||||
@override_settings(
|
||||
CACHES={
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
"LOCATION": "openapi-schema-tests",
|
||||
}
|
||||
}
|
||||
)
|
||||
class LockedSpectacularAPIViewTests(SimpleTestCase):
|
||||
def setUp(self):
|
||||
clear_schema_cache()
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def tearDown(self):
|
||||
clear_schema_cache()
|
||||
|
||||
def test_schema_cache_is_reused(self):
|
||||
view = LockedSpectacularAPIView.as_view()
|
||||
calls = {"n": 0}
|
||||
|
||||
with patch.object(
|
||||
LockedSpectacularAPIView, "generator_class", _counting_generator(calls)
|
||||
):
|
||||
for _ in range(3):
|
||||
response = view(self.factory.get("/api/schema/"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertEqual(calls["n"], 1)
|
||||
|
||||
def test_clear_schema_cache_forces_rebuild(self):
|
||||
view = LockedSpectacularAPIView.as_view()
|
||||
calls = {"n": 0}
|
||||
|
||||
with patch.object(
|
||||
LockedSpectacularAPIView, "generator_class", _counting_generator(calls)
|
||||
):
|
||||
self.assertEqual(view(self.factory.get("/api/schema/")).status_code, 200)
|
||||
clear_schema_cache()
|
||||
self.assertEqual(view(self.factory.get("/api/schema/")).status_code, 200)
|
||||
|
||||
self.assertEqual(calls["n"], 2)
|
||||
|
||||
def test_concurrent_first_load_uses_single_build(self):
|
||||
view = LockedSpectacularAPIView.as_view()
|
||||
call_count = 0
|
||||
in_flight = 0
|
||||
max_in_flight = 0
|
||||
counter_lock = threading.Lock()
|
||||
started = threading.Barrier(8)
|
||||
|
||||
class SlowGenerator:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def get_schema(self, request=None, public=True):
|
||||
nonlocal call_count, in_flight, max_in_flight
|
||||
with counter_lock:
|
||||
call_count += 1
|
||||
in_flight += 1
|
||||
max_in_flight = max(max_in_flight, in_flight)
|
||||
time.sleep(0.05)
|
||||
with counter_lock:
|
||||
in_flight -= 1
|
||||
return {"openapi": "3.0.3", "paths": {}, "info": {"title": "t"}}
|
||||
|
||||
with patch.object(
|
||||
LockedSpectacularAPIView, "generator_class", SlowGenerator
|
||||
):
|
||||
def hit(_):
|
||||
started.wait(timeout=5)
|
||||
response = view(self.factory.get("/api/schema/"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return response.data
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
futures = [pool.submit(hit, i) for i in range(8)]
|
||||
results = [f.result(timeout=10) for f in as_completed(futures)]
|
||||
|
||||
self.assertEqual(call_count, 1)
|
||||
self.assertEqual(max_in_flight, 1)
|
||||
self.assertTrue(all(r.get("openapi") == "3.0.3" for r in results))
|
||||
Loading…
Add table
Add a link
Reference in a new issue