diff --git a/web/src/shared/video-support/components/Thumbnail/style.scss b/web/src/shared/video-tutorials/components/widget/Thumbnail/style.scss
similarity index 84%
rename from web/src/shared/video-support/components/Thumbnail/style.scss
rename to web/src/shared/video-tutorials/components/widget/Thumbnail/style.scss
index 6a1c92010..2c7099cd2 100644
--- a/web/src/shared/video-support/components/Thumbnail/style.scss
+++ b/web/src/shared/video-tutorials/components/widget/Thumbnail/style.scss
@@ -23,14 +23,14 @@
align-items: center;
justify-content: center;
- .video-support-thumbnail-icon-badge {
+ .icon-badge {
display: flex;
align-items: center;
justify-content: center;
- padding: 8px;
+ padding: var(--spacing-sm);
background-color: var(--bg-default);
border-radius: var(--radius-full);
- box-shadow: 0 4px 12px 0 rgb(0 0 0 / 7%);
+ box-shadow: var(--menu-shadow);
}
}
}
diff --git a/web/src/shared/video-support/components/VideoCard/VideoCard.tsx b/web/src/shared/video-tutorials/components/widget/VideoCard/VideoCard.tsx
similarity index 69%
rename from web/src/shared/video-support/components/VideoCard/VideoCard.tsx
rename to web/src/shared/video-tutorials/components/widget/VideoCard/VideoCard.tsx
index 0292e91f9..b4f403c2c 100644
--- a/web/src/shared/video-support/components/VideoCard/VideoCard.tsx
+++ b/web/src/shared/video-tutorials/components/widget/VideoCard/VideoCard.tsx
@@ -1,9 +1,9 @@
import './style.scss';
-import type { VideoSupport } from '../../types';
+import type { VideoTutorial } from '../../../types';
import { Thumbnail } from '../Thumbnail/Thumbnail';
export interface VideoCardProps {
- video: VideoSupport;
+ video: VideoTutorial;
onClick: () => void;
}
@@ -13,8 +13,8 @@ export const VideoCard = ({ video, onClick }: VideoCardProps) => (
url={`https://img.youtube.com/vi/${video.youtubeVideoId}/hqdefault.jpg`}
title={video.title}
/>
-
-
{video.title}
+
+ {video.title}
);
diff --git a/web/src/shared/video-support/components/VideoCard/style.scss b/web/src/shared/video-tutorials/components/widget/VideoCard/style.scss
similarity index 62%
rename from web/src/shared/video-support/components/VideoCard/style.scss
rename to web/src/shared/video-tutorials/components/widget/VideoCard/style.scss
index 19800cd0b..ef7161816 100644
--- a/web/src/shared/video-support/components/VideoCard/style.scss
+++ b/web/src/shared/video-tutorials/components/widget/VideoCard/style.scss
@@ -18,19 +18,19 @@
&:hover {
box-shadow: 0 4px 16px 0 rgb(0 0 0 / 10%);
}
-}
-.video-support-card-info {
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
+ .info {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
-.video-support-card-title {
- font: var(--t-body-xs-400);
- color: var(--fg-default);
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
+ .title {
+ font: var(--t-body-xs-400);
+ color: var(--fg-default);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
}
diff --git a/web/src/shared/video-tutorials/components/widget/VideoOverlay/VideoOverlay.tsx b/web/src/shared/video-tutorials/components/widget/VideoOverlay/VideoOverlay.tsx
new file mode 100644
index 000000000..1c557b76b
--- /dev/null
+++ b/web/src/shared/video-tutorials/components/widget/VideoOverlay/VideoOverlay.tsx
@@ -0,0 +1,40 @@
+import './style.scss';
+import { IconButton } from '../../../../defguard-ui/components/IconButton/IconButton';
+import { ModalFoundation } from '../../../../defguard-ui/components/ModalFoundation/ModalFoundation';
+import type { VideoTutorial } from '../../../types';
+import { VideoPlayer } from '../../VideoPlayer/VideoPlayer';
+
+export interface VideoOverlayProps {
+ video: VideoTutorial | null;
+ isOpen: boolean;
+ onClose: () => void;
+ afterClose: () => void;
+}
+
+export const VideoOverlay = ({
+ video,
+ isOpen,
+ onClose,
+ afterClose,
+}: VideoOverlayProps) => {
+ return (
+
+ {video && (
+ <>
+
+
+
+
+ >
+ )}
+
+ );
+};
diff --git a/web/src/shared/video-tutorials/components/widget/VideoOverlay/style.scss b/web/src/shared/video-tutorials/components/widget/VideoOverlay/style.scss
new file mode 100644
index 000000000..76ff5f46d
--- /dev/null
+++ b/web/src/shared/video-tutorials/components/widget/VideoOverlay/style.scss
@@ -0,0 +1,58 @@
+// ---------------------------------------------------------------------------
+// VideoOverlay
+// ModalFoundation provides the backdrop and portal; we style the content here.
+// Error/skeleton/iframe styles live in VideoPlayer/style.scss.
+// ---------------------------------------------------------------------------
+
+.video-support-modal-container {
+ position: relative;
+ display: inline-block;
+ padding-top: var(--size-2xl);
+
+ // Modal close button — floats outside the modal card (top-right).
+ // Uses IconButton; overrides border-radius token to circular and adds background/shadow.
+ &-close {
+ --button-border-radius-sm: var(--radius-full);
+
+ position: absolute;
+ top: var(--spacing-xs);
+ right: calc(-1 * var(--size-2xl));
+ background-color: var(--bg-default);
+ box-shadow: 0 2px 8px 0 rgb(0 0 0 / 10%);
+ z-index: 1;
+
+ @include animate(box-shadow);
+ }
+}
+
+.video-support-modal {
+ background: var(--bg-default);
+ border-radius: var(--radius-lg);
+ box-shadow: 0 6px 30px 0 rgb(0 0 0 / 9%);
+ padding: var(--spacing-xl);
+ width: 920px;
+ max-width: 90vw;
+
+ // The shared VideoPlayer renders the iframe directly; size it here.
+ iframe {
+ display: block;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ border: none;
+ border-radius: var(--radius-sm);
+
+ &:not(.loaded) {
+ display: none;
+ }
+ }
+
+ // Overlay skeleton needs the aspect-ratio wrapper so the modal does not resize.
+ .video-player-skeleton {
+ aspect-ratio: 16 / 9;
+ }
+
+ // Overlay error fills the same 16/9 area.
+ .video-player-error.overlay {
+ aspect-ratio: 16 / 9;
+ }
+}
diff --git a/web/src/shared/video-tutorials/data.ts b/web/src/shared/video-tutorials/data.ts
new file mode 100644
index 000000000..ffc2d4545
--- /dev/null
+++ b/web/src/shared/video-tutorials/data.ts
@@ -0,0 +1,69 @@
+import { z } from 'zod';
+import type { VideoTutorialsMappings } from './types';
+
+// ---------------------------------------------------------------------------
+// Source resolution
+// ---------------------------------------------------------------------------
+
+/**
+ * Path (relative to the update service base URL) to load the video tutorials
+ * mapping from. Resolved by Vite at build time.
+ *
+ * The shared updateServiceClient resolves this against its baseURL
+ * (VITE_UPDATE_BASE_URL ?? 'https://pkgs.defguard.net/api'), so the
+ * production URL is https://pkgs.defguard.net/api/content/video-tutorials.
+ *
+ * Override VITE_VIDEO_TUTORIALS_URL to use a different path on the same server,
+ * or VITE_UPDATE_BASE_URL to redirect all update-service calls to a local
+ * server for development.
+ */
+export const videoTutorialsPath: string =
+ import.meta.env.VITE_VIDEO_TUTORIALS_URL ?? '/content/video-tutorials';
+
+// ---------------------------------------------------------------------------
+// Zod schema + parser
+// ---------------------------------------------------------------------------
+
+const videoTutorialSchema = z
+ .object({
+ youtubeVideoId: z
+ .string()
+ .regex(
+ /^[A-Za-z0-9_-]{11}$/,
+ 'youtubeVideoId must be exactly 11 alphanumeric/-/_ chars',
+ ),
+ title: z.string().min(1, 'title must be non-empty'),
+ description: z.string().min(1, 'description must be non-empty'),
+ appRoute: z.string().regex(/^\//, 'appRoute must start with "/"'),
+ docsUrl: z.string().url('docsUrl must be a valid URL'),
+ })
+ .strip();
+
+const sectionSchema = z
+ .object({
+ name: z.string().min(1, 'section name must be non-empty'),
+ videos: z.array(videoTutorialSchema),
+ })
+ .strip();
+
+const mappingsSchema = z.object({
+ versions: z.record(
+ z
+ .string()
+ .regex(
+ /^\d+\.\d+(\.\d+)?$/,
+ 'version key must be major.minor or major.minor.patch',
+ ),
+ z.array(sectionSchema),
+ ),
+});
+
+/**
+ * Validates raw JSON against the video tutorials mapping contract and returns a
+ * trusted VideoTutorialsMappings object.
+ * Throws a ZodError if the contract is violated.
+ */
+export function parseVideoTutorials(raw: unknown): VideoTutorialsMappings {
+ const parsed = mappingsSchema.parse(raw);
+ return parsed.versions;
+}
diff --git a/web/src/shared/video-tutorials/resolved.tsx b/web/src/shared/video-tutorials/resolved.tsx
new file mode 100644
index 000000000..5961db226
--- /dev/null
+++ b/web/src/shared/video-tutorials/resolved.tsx
@@ -0,0 +1,58 @@
+import { useQuery } from '@tanstack/react-query';
+import { useMatches } from '@tanstack/react-router';
+import { useApp } from '../hooks/useApp';
+import { videoTutorialsQueryOptions } from '../query';
+import { resolveVersion } from './resolver';
+import { canonicalizeRouteKey } from './route-key';
+import type { VideoTutorial, VideoTutorialsSection } from './types';
+
+// Matches routes defined under src/routes/_authorized/_default.tsx
+const CONTENT_ROUTE_PREFIX = '/_authorized/_default/';
+
+// Stable empty references — avoids triggering effects/memos that depend on these values.
+const EMPTY_VIDEO_TUTORIALS: VideoTutorial[] = [];
+const EMPTY_SECTIONS: VideoTutorialsSection[] = [];
+
+/**
+ * Derives the canonical route key for the current page from TanStack Router
+ * matches, skipping pathless shell/layout routes.
+ * Returns null if no content route is active.
+ */
+export function useVideoTutorialsRouteKey(): string | null {
+ const matches = useMatches();
+ // Find the deepest match that belongs to the authorized default shell
+ const contentMatch = [...matches]
+ .reverse()
+ .find((m) => m.routeId.startsWith(CONTENT_ROUTE_PREFIX));
+ if (!contentMatch) return null;
+ return canonicalizeRouteKey(contentMatch.fullPath);
+}
+
+/**
+ * Returns all sections from the newest eligible version for the current app version.
+ * Used by both VideoTutorialsModal and VideoSupportWidget — both display content
+ * from the same single version with no fallback to older versions.
+ * Returns an empty array when data is loading, errored, or no eligible version exists.
+ */
+export function useVideoTutorialsSections(): VideoTutorialsSection[] {
+ const { data } = useQuery(videoTutorialsQueryOptions);
+ const appVersion = useApp((s) => s.appInfo.version);
+
+ if (!data || !appVersion) return EMPTY_SECTIONS;
+ return resolveVersion(data, appVersion);
+}
+
+/**
+ * Returns the video tutorials for the current page, filtered from the newest
+ * eligible version. Returns an empty array when data is loading, errored, no
+ * eligible version exists, or no videos match the current route.
+ */
+export function useResolvedVideoTutorials(): VideoTutorial[] {
+ const sections = useVideoTutorialsSections();
+ const routeKey = useVideoTutorialsRouteKey();
+
+ if (!routeKey) return EMPTY_VIDEO_TUTORIALS;
+ return sections
+ .flatMap((s) => s.videos)
+ .filter((v) => canonicalizeRouteKey(v.appRoute) === routeKey);
+}
diff --git a/web/src/shared/video-tutorials/resolver.ts b/web/src/shared/video-tutorials/resolver.ts
new file mode 100644
index 000000000..45bed0c82
--- /dev/null
+++ b/web/src/shared/video-tutorials/resolver.ts
@@ -0,0 +1,38 @@
+import type { VideoTutorialsMappings, VideoTutorialsSection } from './types';
+import { compareVersions, parseVersion } from './version';
+
+/**
+ * Returns the sorted list of version keys that are eligible for the given app
+ * version (i.e. version key <= app version), ordered newest-to-oldest.
+ */
+function eligibleVersionsSorted(
+ mappings: VideoTutorialsMappings,
+ appVersion: NonNullable
>,
+): string[] {
+ return Object.keys(mappings)
+ .flatMap((key) => {
+ const parsed = parseVersion(key);
+ return parsed && compareVersions(parsed, appVersion) <= 0 ? [{ key, parsed }] : [];
+ })
+ .sort((a, b) => compareVersions(b.parsed, a.parsed))
+ .map(({ key }) => key);
+}
+
+/**
+ * Returns all sections from the newest eligible version (version key <= app version).
+ * Used by both the VideoTutorialsModal and the VideoSupportWidget — both always show
+ * content from the same single version with no fallback to older versions.
+ * Returns [] if no eligible version exists or the app version is invalid.
+ */
+export function resolveVersion(
+ mappings: VideoTutorialsMappings,
+ appVersionRaw: string,
+): VideoTutorialsSection[] {
+ const appVersion = parseVersion(appVersionRaw);
+ if (!appVersion) return [];
+
+ const versions = eligibleVersionsSorted(mappings, appVersion);
+ if (versions.length === 0) return [];
+
+ return mappings[versions[0]];
+}
diff --git a/web/src/shared/video-tutorials/route-key.ts b/web/src/shared/video-tutorials/route-key.ts
new file mode 100644
index 000000000..ada15ac5a
--- /dev/null
+++ b/web/src/shared/video-tutorials/route-key.ts
@@ -0,0 +1,35 @@
+/**
+ * Canonicalize a route key:
+ * - Trim surrounding whitespace
+ * - Ensure a leading "/"
+ * - Remove trailing "/" unless the result would be empty (root stays "/")
+ */
+export function canonicalizeRouteKey(raw: string): string {
+ let key = raw.trim();
+ if (!key.startsWith('/')) {
+ key = `/${key}`;
+ }
+ if (key.length > 1 && key.endsWith('/')) {
+ key = key.slice(0, -1);
+ }
+ return key;
+}
+
+/**
+ * Strips dynamic segments from a canonical route path, returning the longest
+ * static prefix that can be used for navigation and label lookup.
+ *
+ * Dynamic segments use TanStack Router's "$param" convention. Everything from
+ * the first "/$"-prefixed segment onward is removed.
+ *
+ * @example
+ * getNavRoot('/vpn-overview') // → '/vpn-overview'
+ * getNavRoot('/vpn-overview/$locationId') // → '/vpn-overview'
+ * getNavRoot('/acl/rules/$ruleId/edit') // → '/acl/rules'
+ * getNavRoot('/$id') // → '/'
+ */
+export function getNavRoot(route: string): string {
+ const canonical = canonicalizeRouteKey(route);
+ const paramIdx = canonical.indexOf('/$');
+ return paramIdx === -1 ? canonical : canonical.slice(0, paramIdx) || '/';
+}
diff --git a/web/src/shared/video-tutorials/route-label.ts b/web/src/shared/video-tutorials/route-label.ts
new file mode 100644
index 000000000..e1e5eb90f
--- /dev/null
+++ b/web/src/shared/video-tutorials/route-label.ts
@@ -0,0 +1,56 @@
+import { m } from '../../paraglide/messages';
+import { canonicalizeRouteKey } from './route-key';
+
+/**
+ * Maps a canonicalized app route to a human-readable navigation label.
+ *
+ * Uses the same m.cmp_nav_item_*() calls as the Navigation component but
+ * defined inline here to avoid a circular import with Navigation.tsx.
+ *
+ * The map is built lazily on first call (labels are functions, so they are
+ * safe to call at module init time, but we defer to keep import side-effects
+ * minimal).
+ */
+let _labelMap: Map string> | undefined;
+
+function getLabelMap(): Map string> {
+ if (_labelMap) return _labelMap;
+
+ _labelMap = new Map([
+ ['/vpn-overview', () => m.cmp_nav_item_overview()],
+ ['/locations', () => m.cmp_nav_item_locations()],
+ ['/users', () => m.cmp_nav_item_users()],
+ ['/groups', () => m.cmp_nav_item_groups()],
+ ['/enrollment', () => m.cmp_nav_item_enrollment()],
+ ['/acl/rules', () => m.cmp_nav_item_rules()],
+ ['/acl/destinations', () => m.cmp_nav_item_destinations()],
+ ['/acl/aliases', () => m.cmp_nav_item_aliases()],
+ ['/activity', () => m.cmp_nav_item_activity_log()],
+ ['/network-devices', () => m.cmp_nav_item_network_devices()],
+ ['/openid', () => m.cmp_nav_item_openid()],
+ ['/webhooks', () => m.cmp_nav_item_webhooks()],
+ ['/settings', () => m.cmp_nav_item_settings()],
+ ['/support', () => m.cmp_nav_item_support()],
+ ['/edges', () => m.cmp_nav_item_edges()],
+ ]);
+
+ return _labelMap;
+}
+
+/**
+ * Returns the translated navigation label for a given app route, or undefined
+ * if the route does not correspond to a known navigation item.
+ *
+ * The route is canonicalized before lookup, so trailing slashes and leading
+ * slash omissions are tolerated.
+ *
+ * @example
+ * getRouteLabel('/locations') // → "Locations"
+ * getRouteLabel('/settings/') // → "Settings"
+ * getRouteLabel('/unknown') // → undefined
+ */
+export function getRouteLabel(route: string): string | undefined {
+ const key = canonicalizeRouteKey(route);
+ const labelFn = getLabelMap().get(key);
+ return labelFn?.();
+}
diff --git a/web/src/shared/video-tutorials/store.ts b/web/src/shared/video-tutorials/store.ts
new file mode 100644
index 000000000..52fb7cdb3
--- /dev/null
+++ b/web/src/shared/video-tutorials/store.ts
@@ -0,0 +1,5 @@
+import { create } from 'zustand';
+
+type Store = { isOpen: boolean };
+
+export const useVideoTutorialsModal = create(() => ({ isOpen: false }));
diff --git a/web/src/shared/video-support/style.scss b/web/src/shared/video-tutorials/style.scss
similarity index 100%
rename from web/src/shared/video-support/style.scss
rename to web/src/shared/video-tutorials/style.scss
diff --git a/web/src/shared/video-tutorials/types.ts b/web/src/shared/video-tutorials/types.ts
new file mode 100644
index 000000000..10f7e4d64
--- /dev/null
+++ b/web/src/shared/video-tutorials/types.ts
@@ -0,0 +1,17 @@
+export interface VideoTutorial {
+ youtubeVideoId: string;
+ title: string;
+ description: string;
+ /** In-app route this video is associated with (must start with "/"). */
+ appRoute: string;
+ /** External documentation URL. */
+ docsUrl: string;
+}
+
+export interface VideoTutorialsSection {
+ name: string;
+ videos: VideoTutorial[];
+}
+
+// outer key = version string (e.g. "2.0"), value = ordered list of sections
+export type VideoTutorialsMappings = Record;
diff --git a/web/src/shared/video-support/version.ts b/web/src/shared/video-tutorials/version.ts
similarity index 100%
rename from web/src/shared/video-support/version.ts
rename to web/src/shared/video-tutorials/version.ts
diff --git a/web/tests/video-support.test.ts b/web/tests/video-support.test.ts
deleted file mode 100644
index 8e81b5a8b..000000000
--- a/web/tests/video-support.test.ts
+++ /dev/null
@@ -1,262 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { parseVideoSupport } from '../src/shared/video-support/data';
-import { resolveVideoSupport } from '../src/shared/video-support/resolver';
-import { canonicalizeRouteKey } from '../src/shared/video-support/route-key';
-import { parseVersion } from '../src/shared/video-support/version';
-import type { VideoSupportMappings } from '../src/shared/video-support/types';
-
-// ---------------------------------------------------------------------------
-// canonicalizeRouteKey
-// ---------------------------------------------------------------------------
-
-describe('canonicalizeRouteKey', () => {
- it('should preserve root slash', () => {
- expect(canonicalizeRouteKey('/')).toBe('/');
- });
-
- it('should add missing leading slash', () => {
- expect(canonicalizeRouteKey('users')).toBe('/users');
- });
-
- it('should strip non-root trailing slash', () => {
- expect(canonicalizeRouteKey('/settings/')).toBe('/settings');
- });
-
- it('should strip trailing slash and add leading slash together', () => {
- expect(canonicalizeRouteKey('settings/')).toBe('/settings');
- });
-
- it('should trim surrounding whitespace', () => {
- expect(canonicalizeRouteKey(' /users ')).toBe('/users');
- });
-
- it('should preserve dynamic route templates', () => {
- expect(canonicalizeRouteKey('/vpn-overview/$locationId')).toBe('/vpn-overview/$locationId');
- });
-
- it('should not strip the root slash when input is only a slash', () => {
- expect(canonicalizeRouteKey(' / ')).toBe('/');
- });
-});
-
-// ---------------------------------------------------------------------------
-// parseVersion
-// ---------------------------------------------------------------------------
-
-describe('parseVersion', () => {
- it('should parse major.minor', () => {
- expect(parseVersion('2.2')).toEqual({ major: 2, minor: 2, patch: 0 });
- });
-
- it('should parse major.minor.patch', () => {
- expect(parseVersion('2.2.1')).toEqual({ major: 2, minor: 2, patch: 1 });
- });
-
- it('should strip prerelease suffix', () => {
- expect(parseVersion('2.2.0-beta')).toEqual({ major: 2, minor: 2, patch: 0 });
- });
-
- it('should strip build metadata', () => {
- expect(parseVersion('2.2.0+build.1')).toEqual({ major: 2, minor: 2, patch: 0 });
- });
-
- it('should strip prerelease and build metadata together', () => {
- expect(parseVersion('2.2.0-beta+build.1')).toEqual({ major: 2, minor: 2, patch: 0 });
- });
-
- it('should return null for empty string', () => {
- expect(parseVersion('')).toBeNull();
- });
-
- it('should return null for non-semver string', () => {
- expect(parseVersion('not-a-version')).toBeNull();
- });
-
- it('should return null for single number', () => {
- expect(parseVersion('2')).toBeNull();
- });
-
- it('should trim whitespace before parsing', () => {
- expect(parseVersion(' 2.1 ')).toEqual({ major: 2, minor: 1, patch: 0 });
- });
-});
-
-// ---------------------------------------------------------------------------
-// resolveVideoSupport
-// ---------------------------------------------------------------------------
-
-const makeMappings = (): VideoSupportMappings => ({
- '2.0': {
- '/users': [{ youtubeVideoId: 'usrGuide200', title: 'Users 2.0' }],
- },
- '2.2': {
- '/users': [{ youtubeVideoId: 'usrGuide220', title: 'Users 2.2' }],
- '/settings': [{ youtubeVideoId: 'setGuide220', title: 'Settings 2.2' }],
- },
-});
-
-describe('resolveVideoSupport', () => {
- it('should return videos for an exact version match', () => {
- const result = resolveVideoSupport(makeMappings(), '2.2', '/users');
- expect(result).toHaveLength(1);
- expect(result[0].youtubeVideoId).toBe('usrGuide220');
- });
-
- it('should return the most recent eligible version when the exact version defines the route', () => {
- const result = resolveVideoSupport(makeMappings(), '2.2', '/users');
- // 2.2 defines /users, so we get the 2.2 entry (not the older 2.0 entry)
- expect(result[0].youtubeVideoId).toBe('usrGuide220');
- });
-
- it('should fall back to 2.0 for a route only defined there when running 2.2', () => {
- const mappings: VideoSupportMappings = {
- '2.0': { '/users': [{ youtubeVideoId: 'usrGuide200', title: 'Users 2.0' }] },
- '2.2': { '/settings': [{ youtubeVideoId: 'setGuide220', title: 'Settings 2.2' }] },
- };
- const result = resolveVideoSupport(mappings, '2.2', '/users');
- expect(result[0].youtubeVideoId).toBe('usrGuide200');
- });
-
- it('should not use a version newer than the runtime version', () => {
- const result = resolveVideoSupport(makeMappings(), '2.0', '/settings');
- expect(result).toHaveLength(0);
- });
-
- it('should preserve an explicit empty array without falling back', () => {
- const mappings: VideoSupportMappings = {
- '2.0': { '/users': [{ youtubeVideoId: 'usrGuide200', title: 'Users 2.0' }] },
- '2.2': { '/users': [] },
- };
- const result = resolveVideoSupport(mappings, '2.2', '/users');
- expect(result).toHaveLength(0);
- });
-
- it('should return empty array when no version defines the route', () => {
- const result = resolveVideoSupport(makeMappings(), '2.2', '/nonexistent');
- expect(result).toHaveLength(0);
- });
-
- it('should return empty array for an unparseable app version', () => {
- const result = resolveVideoSupport(makeMappings(), '', '/users');
- expect(result).toHaveLength(0);
- });
-
- it('should strip prerelease from runtime version before resolving', () => {
- const result = resolveVideoSupport(makeMappings(), '2.2.0-beta', '/users');
- expect(result[0].youtubeVideoId).toBe('usrGuide220');
- });
-});
-
-// ---------------------------------------------------------------------------
-// parseVideoSupport
-// ---------------------------------------------------------------------------
-
-const validRaw = {
- versions: {
- '2.2': {
- '/users': [
- {
- youtubeVideoId: 'abcDEFghiJK',
- title: 'Test video',
- },
- ],
- },
- },
-};
-
-describe('parseVideoSupport', () => {
- it('should accept a valid contract', () => {
- const result = parseVideoSupport(validRaw);
- expect(result['2.2']['/users']).toHaveLength(1);
- expect(result['2.2']['/users'][0].youtubeVideoId).toBe('abcDEFghiJK');
- });
-
- it('should canonicalize route keys (strip trailing slash)', () => {
- const raw = {
- versions: {
- '2.0': {
- '/settings/': [{ youtubeVideoId: 'abcDEFghiJK', title: 'Settings' }],
- },
- },
- };
- const result = parseVideoSupport(raw);
- expect(result['2.0']['/settings']).toBeDefined();
- expect(result['2.0']['/settings/']).toBeUndefined();
- });
-
- it('should reject an invalid youtubeVideoId (not 11 chars)', () => {
- const raw = {
- versions: {
- '2.2': {
- '/users': [{ youtubeVideoId: 'tooshort', title: 'Test' }],
- },
- },
- };
- expect(() => parseVideoSupport(raw)).toThrow();
- });
-
- it('should reject an empty title', () => {
- const raw = {
- versions: {
- '2.2': {
- '/users': [{ youtubeVideoId: 'abcDEFghiJK', title: '' }],
- },
- },
- };
- expect(() => parseVideoSupport(raw)).toThrow();
- });
-
- it('should reject duplicate route keys after canonicalization', () => {
- const raw = {
- versions: {
- '2.2': {
- '/settings': [{ youtubeVideoId: 'abcDEFghiJK', title: 'A' }],
- '/settings/': [{ youtubeVideoId: 'abcDEFghiJK', title: 'B' }],
- },
- },
- };
- expect(() => parseVideoSupport(raw)).toThrow(/[Dd]uplicate/);
- });
-
- it('should reject a route key missing a leading slash', () => {
- const raw = {
- versions: {
- '2.2': {
- 'settings': [{ youtubeVideoId: 'abcDEFghiJK', title: 'Test' }],
- },
- },
- };
- expect(() => parseVideoSupport(raw)).toThrow();
- });
-
- it('should reject an invalid version key format', () => {
- const raw = {
- versions: {
- 'v2.2': {
- '/users': [{ youtubeVideoId: 'abcDEFghiJK', title: 'Test' }],
- },
- },
- };
- expect(() => parseVideoSupport(raw)).toThrow();
- });
-
- it('should strip unknown fields from videos', () => {
- const raw = {
- versions: {
- '2.2': {
- '/users': [{ youtubeVideoId: 'abcDEFghiJK', title: 'Test', unknownField: 'ignored' }],
- },
- },
- };
- const result = parseVideoSupport(raw);
- expect((result['2.2']['/users'][0] as Record)['unknownField']).toBeUndefined();
- });
-
- it('should reject null input', () => {
- expect(() => parseVideoSupport(null)).toThrow();
- });
-
- it('should reject missing versions key', () => {
- expect(() => parseVideoSupport({})).toThrow();
- });
-});
diff --git a/web/tests/video-tutorials.test.ts b/web/tests/video-tutorials.test.ts
new file mode 100644
index 000000000..b6a83f9c2
--- /dev/null
+++ b/web/tests/video-tutorials.test.ts
@@ -0,0 +1,455 @@
+import { describe, expect, it } from 'vitest';
+import { parseVideoTutorials } from '../src/shared/video-tutorials/data';
+import { resolveAllSections, resolveVideoTutorials } from '../src/shared/video-tutorials/resolver';
+import { canonicalizeRouteKey } from '../src/shared/video-tutorials/route-key';
+import { parseVersion } from '../src/shared/video-tutorials/version';
+import type { VideoTutorialsMappings } from '../src/shared/video-tutorials/types';
+
+// ---------------------------------------------------------------------------
+// canonicalizeRouteKey
+// ---------------------------------------------------------------------------
+
+describe('canonicalizeRouteKey', () => {
+ it('should preserve root slash', () => {
+ expect(canonicalizeRouteKey('/')).toBe('/');
+ });
+
+ it('should add missing leading slash', () => {
+ expect(canonicalizeRouteKey('users')).toBe('/users');
+ });
+
+ it('should strip non-root trailing slash', () => {
+ expect(canonicalizeRouteKey('/settings/')).toBe('/settings');
+ });
+
+ it('should strip trailing slash and add leading slash together', () => {
+ expect(canonicalizeRouteKey('settings/')).toBe('/settings');
+ });
+
+ it('should trim surrounding whitespace', () => {
+ expect(canonicalizeRouteKey(' /users ')).toBe('/users');
+ });
+
+ it('should preserve dynamic route templates', () => {
+ expect(canonicalizeRouteKey('/vpn-overview/$locationId')).toBe('/vpn-overview/$locationId');
+ });
+
+ it('should not strip the root slash when input is only a slash', () => {
+ expect(canonicalizeRouteKey(' / ')).toBe('/');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// parseVersion
+// ---------------------------------------------------------------------------
+
+describe('parseVersion', () => {
+ it('should parse major.minor', () => {
+ expect(parseVersion('2.2')).toEqual({ major: 2, minor: 2, patch: 0 });
+ });
+
+ it('should parse major.minor.patch', () => {
+ expect(parseVersion('2.2.1')).toEqual({ major: 2, minor: 2, patch: 1 });
+ });
+
+ it('should strip prerelease suffix', () => {
+ expect(parseVersion('2.2.0-beta')).toEqual({ major: 2, minor: 2, patch: 0 });
+ });
+
+ it('should strip build metadata', () => {
+ expect(parseVersion('2.2.0+build.1')).toEqual({ major: 2, minor: 2, patch: 0 });
+ });
+
+ it('should strip prerelease and build metadata together', () => {
+ expect(parseVersion('2.2.0-beta+build.1')).toEqual({ major: 2, minor: 2, patch: 0 });
+ });
+
+ it('should return null for empty string', () => {
+ expect(parseVersion('')).toBeNull();
+ });
+
+ it('should return null for non-semver string', () => {
+ expect(parseVersion('not-a-version')).toBeNull();
+ });
+
+ it('should return null for single number', () => {
+ expect(parseVersion('2')).toBeNull();
+ });
+
+ it('should trim whitespace before parsing', () => {
+ expect(parseVersion(' 2.1 ')).toEqual({ major: 2, minor: 1, patch: 0 });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Shared fixture helpers (new schema)
+// ---------------------------------------------------------------------------
+
+const makeVideo = (id: string, appRoute: string) => ({
+ youtubeVideoId: id,
+ title: `Video ${id}`,
+ description: `Description for ${id}`,
+ appRoute,
+ docsUrl: 'https://docs.defguard.net/test',
+});
+
+const makeMappings = (): VideoTutorialsMappings => ({
+ '2.0': [
+ {
+ name: 'Identity',
+ videos: [makeVideo('usrGuide200', '/users')],
+ },
+ ],
+ '2.2': [
+ {
+ name: 'Identity',
+ videos: [makeVideo('usrGuide220', '/users')],
+ },
+ {
+ name: 'Admin',
+ videos: [makeVideo('setGuide220', '/settings')],
+ },
+ ],
+});
+
+// ---------------------------------------------------------------------------
+// resolveVideoTutorials
+// ---------------------------------------------------------------------------
+
+describe('resolveVideoTutorials', () => {
+ it('should return videos for an exact version match', () => {
+ const result = resolveVideoTutorials(makeMappings(), '2.2', '/users');
+ expect(result).toHaveLength(1);
+ expect(result[0].youtubeVideoId).toBe('usrGuide220');
+ });
+
+ it('should return the most recent eligible version when the exact version defines the route', () => {
+ const result = resolveVideoTutorials(makeMappings(), '2.2', '/users');
+ // 2.2 defines /users, so we get the 2.2 entry (not the older 2.0 entry)
+ expect(result[0].youtubeVideoId).toBe('usrGuide220');
+ });
+
+ it('should fall back to 2.0 for a route only defined there when running 2.2', () => {
+ const mappings: VideoTutorialsMappings = {
+ '2.0': [{ name: 'Identity', videos: [makeVideo('usrGuide200', '/users')] }],
+ '2.2': [{ name: 'Admin', videos: [makeVideo('setGuide220', '/settings')] }],
+ };
+ const result = resolveVideoTutorials(mappings, '2.2', '/users');
+ expect(result[0].youtubeVideoId).toBe('usrGuide200');
+ });
+
+ it('should not use a version newer than the runtime version', () => {
+ const result = resolveVideoTutorials(makeMappings(), '2.0', '/settings');
+ expect(result).toHaveLength(0);
+ });
+
+ it('should return empty array when no version defines the route', () => {
+ const result = resolveVideoTutorials(makeMappings(), '2.2', '/nonexistent');
+ expect(result).toHaveLength(0);
+ });
+
+ it('should return empty array for an unparseable app version', () => {
+ const result = resolveVideoTutorials(makeMappings(), '', '/users');
+ expect(result).toHaveLength(0);
+ });
+
+ it('should strip prerelease from runtime version before resolving', () => {
+ const result = resolveVideoTutorials(makeMappings(), '2.2.0-beta', '/users');
+ expect(result[0].youtubeVideoId).toBe('usrGuide220');
+ });
+
+ it('should collect matching videos from multiple sections in the same version', () => {
+ const mappings: VideoTutorialsMappings = {
+ '2.2': [
+ { name: 'Section A', videos: [makeVideo('videoA001', '/users')] },
+ { name: 'Section B', videos: [makeVideo('videoB001', '/users')] },
+ ],
+ };
+ const result = resolveVideoTutorials(mappings, '2.2', '/users');
+ expect(result).toHaveLength(2);
+ expect(result.map((v) => v.youtubeVideoId)).toContain('videoA001');
+ expect(result.map((v) => v.youtubeVideoId)).toContain('videoB001');
+ });
+
+ it('should canonicalize appRoute trailing slash when matching', () => {
+ // /locations/ in the JSON should match the /locations route key
+ const mappings: VideoTutorialsMappings = {
+ '2.2': [{ name: 'VPN', videos: [makeVideo('loc001xxxxx', '/locations/')] }],
+ };
+ const result = resolveVideoTutorials(mappings, '2.2', '/locations');
+ expect(result).toHaveLength(1);
+ expect(result[0].youtubeVideoId).toBe('loc001xxxxx');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// resolveAllSections
+// ---------------------------------------------------------------------------
+
+describe('resolveAllSections', () => {
+ it('should return all sections from the newest eligible version', () => {
+ const result = resolveAllSections(makeMappings(), '2.2');
+ expect(result).toHaveLength(2);
+ expect(result[0].name).toBe('Identity');
+ expect(result[1].name).toBe('Admin');
+ });
+
+ it('should respect the app version ceiling (not use versions newer than app)', () => {
+ const result = resolveAllSections(makeMappings(), '2.0');
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('Identity');
+ });
+
+ it('should return empty array for an unparseable app version', () => {
+ const result = resolveAllSections(makeMappings(), '');
+ expect(result).toHaveLength(0);
+ });
+
+ it('should return empty array when no eligible version exists', () => {
+ const result = resolveAllSections(makeMappings(), '1.0');
+ expect(result).toHaveLength(0);
+ });
+
+ it('should pick the newest when multiple versions are eligible', () => {
+ const result = resolveAllSections(makeMappings(), '3.0');
+ // 2.2 is newest eligible
+ expect(result).toHaveLength(2);
+ expect(result[1].name).toBe('Admin');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// parseVideoTutorials
+// ---------------------------------------------------------------------------
+
+const validRaw = {
+ versions: {
+ '2.2': [
+ {
+ name: 'Identity',
+ videos: [
+ {
+ youtubeVideoId: 'abcDEFghiJK',
+ title: 'Test video',
+ description: 'A test description',
+ appRoute: '/users',
+ docsUrl: 'https://docs.defguard.net/users',
+ },
+ ],
+ },
+ ],
+ },
+};
+
+describe('parseVideoTutorials', () => {
+ it('should accept a valid contract', () => {
+ const result = parseVideoTutorials(validRaw);
+ expect(result['2.2']).toHaveLength(1);
+ expect(result['2.2'][0].name).toBe('Identity');
+ expect(result['2.2'][0].videos[0].youtubeVideoId).toBe('abcDEFghiJK');
+ });
+
+ it('should reject an invalid youtubeVideoId (not 11 chars)', () => {
+ const raw = {
+ versions: {
+ '2.2': [
+ {
+ name: 'Test',
+ videos: [
+ {
+ youtubeVideoId: 'tooshort',
+ title: 'Test',
+ description: 'Desc',
+ appRoute: '/users',
+ docsUrl: 'https://docs.defguard.net',
+ },
+ ],
+ },
+ ],
+ },
+ };
+ expect(() => parseVideoTutorials(raw)).toThrow();
+ });
+
+ it('should reject an empty title', () => {
+ const raw = {
+ versions: {
+ '2.2': [
+ {
+ name: 'Test',
+ videos: [
+ {
+ youtubeVideoId: 'abcDEFghiJK',
+ title: '',
+ description: 'Desc',
+ appRoute: '/users',
+ docsUrl: 'https://docs.defguard.net',
+ },
+ ],
+ },
+ ],
+ },
+ };
+ expect(() => parseVideoTutorials(raw)).toThrow();
+ });
+
+ it('should reject an empty description', () => {
+ const raw = {
+ versions: {
+ '2.2': [
+ {
+ name: 'Test',
+ videos: [
+ {
+ youtubeVideoId: 'abcDEFghiJK',
+ title: 'Title',
+ description: '',
+ appRoute: '/users',
+ docsUrl: 'https://docs.defguard.net',
+ },
+ ],
+ },
+ ],
+ },
+ };
+ expect(() => parseVideoTutorials(raw)).toThrow();
+ });
+
+ it('should reject an appRoute missing a leading slash', () => {
+ const raw = {
+ versions: {
+ '2.2': [
+ {
+ name: 'Test',
+ videos: [
+ {
+ youtubeVideoId: 'abcDEFghiJK',
+ title: 'Title',
+ description: 'Desc',
+ appRoute: 'users',
+ docsUrl: 'https://docs.defguard.net',
+ },
+ ],
+ },
+ ],
+ },
+ };
+ expect(() => parseVideoTutorials(raw)).toThrow();
+ });
+
+ it('should reject an invalid docsUrl', () => {
+ const raw = {
+ versions: {
+ '2.2': [
+ {
+ name: 'Test',
+ videos: [
+ {
+ youtubeVideoId: 'abcDEFghiJK',
+ title: 'Title',
+ description: 'Desc',
+ appRoute: '/users',
+ docsUrl: 'not-a-url',
+ },
+ ],
+ },
+ ],
+ },
+ };
+ expect(() => parseVideoTutorials(raw)).toThrow();
+ });
+
+ it('should reject a section with an empty name', () => {
+ const raw = {
+ versions: {
+ '2.2': [
+ {
+ name: '',
+ videos: [],
+ },
+ ],
+ },
+ };
+ expect(() => parseVideoTutorials(raw)).toThrow();
+ });
+
+ it('should reject an invalid version key format', () => {
+ const raw = {
+ versions: {
+ 'v2.2': [
+ {
+ name: 'Test',
+ videos: [],
+ },
+ ],
+ },
+ };
+ expect(() => parseVideoTutorials(raw)).toThrow();
+ });
+
+ it('should strip unknown fields from videos', () => {
+ const raw = {
+ versions: {
+ '2.2': [
+ {
+ name: 'Test',
+ videos: [
+ {
+ youtubeVideoId: 'abcDEFghiJK',
+ title: 'Test',
+ description: 'Desc',
+ appRoute: '/users',
+ docsUrl: 'https://docs.defguard.net',
+ unknownField: 'ignored',
+ },
+ ],
+ },
+ ],
+ },
+ };
+ const result = parseVideoTutorials(raw);
+ expect(
+ (result['2.2'][0].videos[0] as Record)['unknownField'],
+ ).toBeUndefined();
+ });
+
+ it('should strip unknown fields from sections', () => {
+ const raw = {
+ versions: {
+ '2.2': [
+ {
+ name: 'Test',
+ videos: [],
+ extraSectionField: 'ignored',
+ },
+ ],
+ },
+ };
+ const result = parseVideoTutorials(raw);
+ expect((result['2.2'][0] as Record)['extraSectionField']).toBeUndefined();
+ });
+
+ it('should reject null input', () => {
+ expect(() => parseVideoTutorials(null)).toThrow();
+ });
+
+ it('should reject missing versions key', () => {
+ expect(() => parseVideoTutorials({})).toThrow();
+ });
+
+ it('should accept versions with an empty sections array', () => {
+ const raw = { versions: { '2.2': [] } };
+ const result = parseVideoTutorials(raw);
+ expect(result['2.2']).toHaveLength(0);
+ });
+
+ it('should accept a section with an empty videos array', () => {
+ const raw = {
+ versions: {
+ '2.2': [{ name: 'Empty Section', videos: [] }],
+ },
+ };
+ const result = parseVideoTutorials(raw);
+ expect(result['2.2'][0].videos).toHaveLength(0);
+ });
+});