From 5849082650eb9a5d95e989f770759f93a91309ca Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:51:58 -0700 Subject: [PATCH 1/7] Moved pluginUtils to utils folder --- .../components/cards/AvailablePluginCard.jsx | 6 +- frontend/src/components/cards/PluginCard.jsx | 2 +- frontend/src/components/pluginUtils.js | 25 ----- frontend/src/utils/components/pluginUtils.js | 99 +++++++++++++++++++ 4 files changed, 105 insertions(+), 27 deletions(-) delete mode 100644 frontend/src/components/pluginUtils.js create mode 100644 frontend/src/utils/components/pluginUtils.js diff --git a/frontend/src/components/cards/AvailablePluginCard.jsx b/frontend/src/components/cards/AvailablePluginCard.jsx index 7e594428..71b5ff1e 100644 --- a/frontend/src/components/cards/AvailablePluginCard.jsx +++ b/frontend/src/components/cards/AvailablePluginCard.jsx @@ -37,7 +37,11 @@ import { useNavigate, useLocation } from 'react-router-dom'; import API from '../../api'; import { usePluginStore } from '../../store/plugins'; import PluginDetailPanel from '../PluginDetailPanel.jsx'; -import { compareVersions } from '../pluginUtils.js'; +import { + buildCompatibilityTooltip, + compareVersions, + getInstallInfo, +} from '../../utils/components/pluginUtils.js'; import SizedInstallButton from '../theme/SizedInstallButton.jsx'; const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => { diff --git a/frontend/src/components/cards/PluginCard.jsx b/frontend/src/components/cards/PluginCard.jsx index f1b5d5f6..7aa71a46 100644 --- a/frontend/src/components/cards/PluginCard.jsx +++ b/frontend/src/components/cards/PluginCard.jsx @@ -24,7 +24,7 @@ import useSettingsStore from '../../store/settings.jsx'; import { usePluginStore } from '../../store/plugins.jsx'; import API from '../../api'; import PluginDetailPanel from '../PluginDetailPanel.jsx'; -import { compareVersions } from '../pluginUtils.js'; +import { compareVersions } from '../../utils/components/pluginUtils.js'; import { PluginDowngradeWarning, PluginSecurityWarning, diff --git a/frontend/src/components/pluginUtils.js b/frontend/src/components/pluginUtils.js deleted file mode 100644 index 3b867c01..00000000 --- a/frontend/src/components/pluginUtils.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Compare two semver-like version strings. - * Returns negative if a < b, 0 if equal, positive if a > b. - * - * If either version is a prerelease (any dot-segment contains non-digit - * characters), numeric ordering is meaningless. Fall back to exact string - * equality: 0 if identical, non-zero otherwise. - */ -export function compareVersions(a, b) { - if (!a || !b) return 0; - const normalize = (v) => v.replace(/^v/, ''); - const na = normalize(a); - const nb = normalize(b); - const isPrerelease = (v) => v.split('.').some((p) => !/^\d+$/.test(p)); - if (isPrerelease(na) || isPrerelease(nb)) { - return na === nb ? 0 : 1; - } - const pa = na.split('.').map(Number); - const pb = nb.split('.').map(Number); - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const diff = (pa[i] || 0) - (pb[i] || 0); - if (diff !== 0) return diff; - } - return 0; -} diff --git a/frontend/src/utils/components/pluginUtils.js b/frontend/src/utils/components/pluginUtils.js new file mode 100644 index 00000000..4ecc651c --- /dev/null +++ b/frontend/src/utils/components/pluginUtils.js @@ -0,0 +1,99 @@ +/** + * Compare two semver-like version strings. + * Returns negative if a < b, 0 if equal, positive if a > b. + * + * If either version is a prerelease (any dot-segment contains non-digit + * characters), numeric ordering is meaningless. Fall back to exact string + * equality: 0 if identical, non-zero otherwise. + */ +export function compareVersions(a, b) { + if (!a || !b) return 0; + const normalize = (v) => v.replace(/^v/, ''); + const na = normalize(a); + const nb = normalize(b); + const isPrerelease = (v) => v.split('.').some((p) => !/^\d+$/.test(p)); + if (isPrerelease(na) || isPrerelease(nb)) { + return na === nb ? 0 : 1; + } + const pa = na.split('.').map(Number); + const pb = nb.split('.').map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pa[i] || 0) - (pb[i] || 0); + if (diff !== 0) return diff; + } + return 0; +} + +export const buildCompatibilityTooltip = ( + selMeetsMin, + selectedVersionData, + selMeetsMax +) => { + const parts = []; + if (!selMeetsMin) + parts.push(`${selectedVersionData.min_dispatcharr_version} or newer`); + if (!selMeetsMax) + parts.push(`${selectedVersionData.max_dispatcharr_version} or older`); + return parts.join(' and '); +}; + +export function buildVersionSelectItems( + versions, + latestVersion, + installedVersion, + installedVersionIsPrerelease +) { + const buildLabel = (v) => + `v${v.version}` + + (v.prerelease ? ' (prerelease)' : '') + + (v.version === latestVersion ? ' (latest)' : '') + + (installedVersion && compareVersions(v.version, installedVersion) === 0 + ? ' (installed)' + : ''); + + let sorted = [...versions]; + if (installedVersionIsPrerelease) { + sorted = [ + ...sorted.filter((v) => v.prerelease), + ...sorted.filter((v) => !v.prerelease), + ]; + } + + const items = sorted.map((v) => ({ + value: v.version, + label: buildLabel(v), + disabled: false, + })); + + const installedMissing = + installedVersion && + !versions.some((v) => compareVersions(v.version, installedVersion) === 0); + + if (installedMissing) { + const ghost = { + value: installedVersion, + label: `v${installedVersion} (installed)`, + disabled: true, + }; + const idx = items.findIndex( + (item) => compareVersions(installedVersion, item.value) > 0 + ); + idx === -1 ? items.push(ghost) : items.splice(idx, 0, ghost); + } + + return items; +} + +export const getInstallInfo = (pendingInstall, plugin) => { + const isDowngrade = + pendingInstall && + plugin.installed_version && + compareVersions(pendingInstall.version, plugin.installed_version) < 0; + const isUpdate = + pendingInstall && + plugin.installed_version && + !isDowngrade && + compareVersions(pendingInstall.version, plugin.installed_version) > 0; + const isBadSig = plugin.signature_verified === false; + return { isDowngrade, isUpdate, isBadSig }; +}; From 92946057c6c6de57132eee7bdb6a1e52f9176494 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:52:30 -0700 Subject: [PATCH 2/7] Extracted utils --- .../components/backups/BackupManagerUtils.js | 61 +++++++++++++++++++ frontend/src/utils/pages/PluginsUtils.js | 9 ++- 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 frontend/src/utils/components/backups/BackupManagerUtils.js diff --git a/frontend/src/utils/components/backups/BackupManagerUtils.js b/frontend/src/utils/components/backups/BackupManagerUtils.js new file mode 100644 index 00000000..21d41f5b --- /dev/null +++ b/frontend/src/utils/components/backups/BackupManagerUtils.js @@ -0,0 +1,61 @@ +// Convert 24h time string to 12h format with period +import API from '../../../api.js'; + +export function to12Hour(time24) { + if (!time24) return { time: '12:00', period: 'AM' }; + const [hours, minutes] = time24.split(':').map(Number); + const period = hours >= 12 ? 'PM' : 'AM'; + const hours12 = hours % 12 || 12; + return { + time: `${hours12}:${String(minutes).padStart(2, '0')}`, + period, + }; +} + +// Convert 12h time + period to 24h format +export function to24Hour(time12, period) { + if (!time12) return '00:00'; + const [hours, minutes] = time12.split(':').map(Number); + let hours24 = hours; + if (period === 'PM' && hours !== 12) { + hours24 = hours + 12; + } else if (period === 'AM' && hours === 12) { + hours24 = 0; + } + return `${String(hours24).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; +} + +export const DAYS_OF_WEEK = [ + { value: '0', label: 'Sunday' }, + { value: '1', label: 'Monday' }, + { value: '2', label: 'Tuesday' }, + { value: '3', label: 'Wednesday' }, + { value: '4', label: 'Thursday' }, + { value: '5', label: 'Friday' }, + { value: '6', label: 'Saturday' }, +]; + +export const listBackups = () => { + return API.listBackups(); +}; +export const getBackupSchedule = () => { + return API.getBackupSchedule(); +}; +export const updateBackupSchedule = (settings) => { + return API.updateBackupSchedule(settings); +}; +export const createBackup = () => { + return API.createBackup(); +}; +export const uploadBackup = (file) => { + return API.uploadBackup(file); +}; +export const downloadBackup = (filename) => { + return API.downloadBackup(filename); +}; +export const restoreBackup = (filename, onProgress) => { + return API.restoreBackup(filename, onProgress); +}; +export const deleteBackup = (filename) => { + return API.deleteBackup(filename); +}; diff --git a/frontend/src/utils/pages/PluginsUtils.js b/frontend/src/utils/pages/PluginsUtils.js index 1226fbd6..3f11bfd2 100644 --- a/frontend/src/utils/pages/PluginsUtils.js +++ b/frontend/src/utils/pages/PluginsUtils.js @@ -9,7 +9,11 @@ export const runPluginAction = async (key, actionId) => { export const setPluginEnabled = async (key, next) => { return await API.setPluginEnabled(key, next); }; -export const importPlugin = async (importFile, overwrite = false, silent = false) => { +export const importPlugin = async ( + importFile, + overwrite = false, + silent = false +) => { return await API.importPlugin(importFile, overwrite, silent); }; export const reloadPlugins = async () => { @@ -18,3 +22,6 @@ export const reloadPlugins = async () => { export const deletePluginByKey = (key) => { return API.deletePlugin(key); }; +export const getPluginDetailManifest = (repoId, manifestUrl) => { + return API.getPluginDetailManifest(repoId, manifestUrl); +}; From 18f7e8584a79553a5eed3e5269d52887cdaa0f73 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:52:50 -0700 Subject: [PATCH 3/7] Added tests for utils --- .../components/__tests__/pluginUtils.test.js | 474 ++++++++++++++++++ .../__tests__/BackupManagerUtils.test.js | 327 ++++++++++++ .../pages/__tests__/PluginsUtils.test.js | 46 ++ 3 files changed, 847 insertions(+) create mode 100644 frontend/src/utils/components/__tests__/pluginUtils.test.js create mode 100644 frontend/src/utils/components/backups/__tests__/BackupManagerUtils.test.js diff --git a/frontend/src/utils/components/__tests__/pluginUtils.test.js b/frontend/src/utils/components/__tests__/pluginUtils.test.js new file mode 100644 index 00000000..bef2a415 --- /dev/null +++ b/frontend/src/utils/components/__tests__/pluginUtils.test.js @@ -0,0 +1,474 @@ +import { describe, it, expect } from 'vitest'; +import { + compareVersions, + buildCompatibilityTooltip, + buildVersionSelectItems, + getInstallInfo, +} from '../pluginUtils.js'; + +describe('pluginUtils', () => { + // ─────────────────────────────────────────────────────────────────────────── + // compareVersions + // ─────────────────────────────────────────────────────────────────────────── + describe('compareVersions', () => { + // ── basic ordering ────────────────────────────────────────────────────────── + describe('numeric ordering', () => { + it('returns 0 for identical versions', () => { + expect(compareVersions('1.2.3', '1.2.3')).toBe(0); + }); + + it('returns positive when a > b (major)', () => { + expect(compareVersions('2.0.0', '1.9.9')).toBeGreaterThan(0); + }); + + it('returns negative when a < b (major)', () => { + expect(compareVersions('1.0.0', '2.0.0')).toBeLessThan(0); + }); + + it('returns positive when a > b (minor)', () => { + expect(compareVersions('1.3.0', '1.2.9')).toBeGreaterThan(0); + }); + + it('returns negative when a < b (minor)', () => { + expect(compareVersions('1.2.0', '1.3.0')).toBeLessThan(0); + }); + + it('returns positive when a > b (patch)', () => { + expect(compareVersions('1.0.2', '1.0.1')).toBeGreaterThan(0); + }); + + it('returns negative when a < b (patch)', () => { + expect(compareVersions('1.0.1', '1.0.2')).toBeLessThan(0); + }); + + it('handles versions with different segment counts', () => { + expect(compareVersions('1.2', '1.2.0')).toBe(0); + expect(compareVersions('1.3', '1.2.9')).toBeGreaterThan(0); + }); + }); + + // ── v-prefix stripping ────────────────────────────────────────────────────── + describe('v-prefix stripping', () => { + it('strips leading "v" before comparing', () => { + expect(compareVersions('v1.2.3', '1.2.3')).toBe(0); + expect(compareVersions('v2.0.0', 'v1.0.0')).toBeGreaterThan(0); + expect(compareVersions('v1.0.0', 'v2.0.0')).toBeLessThan(0); + }); + }); + + // ── prerelease fallback ───────────────────────────────────────────────────── + describe('prerelease fallback (string equality)', () => { + it('returns 0 for identical prerelease strings', () => { + expect(compareVersions('1.0.0-beta.1', '1.0.0-beta.1')).toBe(0); + }); + + it('returns non-zero for different prerelease strings', () => { + expect(compareVersions('1.0.0-beta.1', '1.0.0-beta.2')).not.toBe(0); + }); + + it('returns non-zero when one side is prerelease and the other is not', () => { + expect(compareVersions('1.0.0-beta', '1.0.0')).not.toBe(0); + }); + + it('falls back to string equality even when one is numerically "larger"', () => { + // '2.0.0-rc1' contains a non-digit segment so the prerelease path is taken + expect(compareVersions('2.0.0-rc1', '1.0.0')).not.toBe(0); + }); + }); + + // ── null / undefined guards ───────────────────────────────────────────────── + describe('null / undefined guards', () => { + it('returns 0 when a is null', () => { + expect(compareVersions(null, '1.0.0')).toBe(0); + }); + + it('returns 0 when b is null', () => { + expect(compareVersions('1.0.0', null)).toBe(0); + }); + + it('returns 0 when both are null', () => { + expect(compareVersions(null, null)).toBe(0); + }); + + it('returns 0 when a is undefined', () => { + expect(compareVersions(undefined, '1.0.0')).toBe(0); + }); + + it('returns 0 when b is undefined', () => { + expect(compareVersions('1.0.0', undefined)).toBe(0); + }); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // buildCompatibilityTooltip + // ─────────────────────────────────────────────────────────────────────────── + describe('buildCompatibilityTooltip', () => { + const vd = { + min_dispatcharr_version: '2.0.0', + max_dispatcharr_version: '3.0.0', + }; + + it('returns min constraint when only min is not met', () => { + expect(buildCompatibilityTooltip(false, vd, true)).toBe('2.0.0 or newer'); + }); + + it('returns max constraint when only max is not met', () => { + expect(buildCompatibilityTooltip(true, vd, false)).toBe('3.0.0 or older'); + }); + + it('returns both constraints joined by " and " when neither is met', () => { + expect(buildCompatibilityTooltip(false, vd, false)).toBe( + '2.0.0 or newer and 3.0.0 or older' + ); + }); + + it('returns an empty string when both constraints are met', () => { + expect(buildCompatibilityTooltip(true, vd, true)).toBe(''); + }); + + it('uses the actual version values from selectedVersionData', () => { + const custom = { + min_dispatcharr_version: '1.5.0', + max_dispatcharr_version: '4.0.0', + }; + expect(buildCompatibilityTooltip(false, custom, false)).toBe( + '1.5.0 or newer and 4.0.0 or older' + ); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // buildVersionSelectItems + // ─────────────────────────────────────────────────────────────────────────── + describe('buildVersionSelectItems', () => { + // ── basic label building ──────────────────────────────────────────────────── + describe('label building', () => { + it('prefixes every version value with "v" in the label', () => { + const items = buildVersionSelectItems( + [{ version: '1.0.0', prerelease: false }], + '1.0.0', + null, + false + ); + expect(items[0].label).toMatch(/^v1\.0\.0/); + }); + + it('appends "(latest)" for the latest version', () => { + const items = buildVersionSelectItems( + [{ version: '2.0.0', prerelease: false }], + '2.0.0', + null, + false + ); + expect(items[0].label).toContain('(latest)'); + }); + + it('does not append "(latest)" for non-latest versions', () => { + const items = buildVersionSelectItems( + [ + { version: '2.0.0', prerelease: false }, + { version: '1.0.0', prerelease: false }, + ], + '2.0.0', + null, + false + ); + expect(items[1].label).not.toContain('(latest)'); + }); + + it('appends "(installed)" for the currently installed version', () => { + const items = buildVersionSelectItems( + [ + { version: '2.0.0', prerelease: false }, + { version: '1.0.0', prerelease: false }, + ], + '2.0.0', + '1.0.0', + false + ); + const installed = items.find((i) => i.value === '1.0.0'); + expect(installed.label).toContain('(installed)'); + }); + + it('does not append "(installed)" when installedVersion is null', () => { + const items = buildVersionSelectItems( + [{ version: '1.0.0', prerelease: false }], + '1.0.0', + null, + false + ); + expect(items[0].label).not.toContain('(installed)'); + }); + + it('appends "(prerelease)" for prerelease versions', () => { + const items = buildVersionSelectItems( + [{ version: '2.0.0-beta', prerelease: true }], + null, + null, + false + ); + expect(items[0].label).toContain('(prerelease)'); + }); + + it('does not append "(prerelease)" for stable versions', () => { + const items = buildVersionSelectItems( + [{ version: '1.0.0', prerelease: false }], + null, + null, + false + ); + expect(items[0].label).not.toContain('(prerelease)'); + }); + + it('can combine multiple suffixes on one item (installed + latest)', () => { + const items = buildVersionSelectItems( + [{ version: '1.0.0', prerelease: false }], + '1.0.0', + '1.0.0', + false + ); + expect(items[0].label).toContain('(latest)'); + expect(items[0].label).toContain('(installed)'); + }); + + it('all regular items have disabled: false', () => { + const items = buildVersionSelectItems( + [ + { version: '2.0.0', prerelease: false }, + { version: '1.0.0', prerelease: false }, + ], + '2.0.0', + null, + false + ); + items.forEach((item) => expect(item.disabled).toBe(false)); + }); + + it('value property equals the raw version string (no "v" prefix)', () => { + const items = buildVersionSelectItems( + [{ version: '1.2.3', prerelease: false }], + null, + null, + false + ); + expect(items[0].value).toBe('1.2.3'); + }); + }); + + // ── sort order (installedVersionIsPrerelease flag) ────────────────────────── + describe('sort order', () => { + const versions = [ + { version: '2.0.0', prerelease: false }, + { version: '2.0.0-beta', prerelease: true }, + { version: '1.0.0', prerelease: false }, + ]; + + it('preserves manifest order when installedVersionIsPrerelease is false', () => { + const items = buildVersionSelectItems(versions, '2.0.0', null, false); + expect(items.map((i) => i.value)).toEqual([ + '2.0.0', + '2.0.0-beta', + '1.0.0', + ]); + }); + + it('floats prereleases to the top when installedVersionIsPrerelease is true', () => { + const items = buildVersionSelectItems(versions, '2.0.0', null, true); + expect(items[0].value).toBe('2.0.0-beta'); + }); + + it('stable versions follow all prereleases when installedVersionIsPrerelease is true', () => { + const items = buildVersionSelectItems(versions, '2.0.0', null, true); + const stableValues = items + .filter((i) => !i.label.includes('(prerelease)')) + .map((i) => i.value); + expect(stableValues).toEqual(['2.0.0', '1.0.0']); + }); + }); + + // ── ghost item (installed version missing from manifest) ─────────────────── + describe('ghost item for missing installed version', () => { + it('inserts a disabled ghost item when installed version is absent from manifest', () => { + const items = buildVersionSelectItems( + [ + { version: '2.0.0', prerelease: false }, + { version: '1.0.0', prerelease: false }, + ], + '2.0.0', + '1.5.0', + false + ); + const ghost = items.find((i) => i.value === '1.5.0'); + expect(ghost).toBeDefined(); + expect(ghost.disabled).toBe(true); + }); + + it('ghost item label is "v (installed)"', () => { + const items = buildVersionSelectItems( + [ + { version: '2.0.0', prerelease: false }, + { version: '1.0.0', prerelease: false }, + ], + '2.0.0', + '1.5.0', + false + ); + const ghost = items.find((i) => i.value === '1.5.0'); + expect(ghost.label).toBe('v1.5.0 (installed)'); + }); + + it('ghost item is inserted between the first newer and first older item', () => { + const items = buildVersionSelectItems( + [ + { version: '2.0.0', prerelease: false }, + { version: '1.0.0', prerelease: false }, + ], + '2.0.0', + '1.5.0', + false + ); + const values = items.map((i) => i.value); + const ghostIdx = values.indexOf('1.5.0'); + const newerIdx = values.indexOf('2.0.0'); + const olderIdx = values.indexOf('1.0.0'); + expect(ghostIdx).toBeGreaterThan(newerIdx); + expect(ghostIdx).toBeLessThan(olderIdx); + }); + + it('ghost item is appended at the end when installed is older than all manifest versions', () => { + const items = buildVersionSelectItems( + [ + { version: '3.0.0', prerelease: false }, + { version: '2.0.0', prerelease: false }, + ], + '3.0.0', + '1.0.0', + false + ); + const values = items.map((i) => i.value); + expect(values[values.length - 1]).toBe('1.0.0'); + }); + + it('does not insert a ghost item when installed version IS in the manifest', () => { + const items = buildVersionSelectItems( + [ + { version: '2.0.0', prerelease: false }, + { version: '1.0.0', prerelease: false }, + ], + '2.0.0', + '1.0.0', + false + ); + expect(items.filter((i) => i.disabled)).toHaveLength(0); + }); + + it('does not insert a ghost item when installedVersion is null', () => { + const items = buildVersionSelectItems( + [{ version: '1.0.0', prerelease: false }], + '1.0.0', + null, + false + ); + expect(items.filter((i) => i.disabled)).toHaveLength(0); + }); + }); + + // ── edge cases ────────────────────────────────────────────────────────────── + describe('edge cases', () => { + it('returns an empty array when versions is empty', () => { + expect(buildVersionSelectItems([], null, null, false)).toEqual([]); + }); + + it('handles latestVersion being null — no "(latest)" label appended', () => { + const items = buildVersionSelectItems( + [{ version: '1.0.0', prerelease: false }], + null, + null, + false + ); + expect(items[0].label).not.toContain('(latest)'); + }); + + it('does not mutate the original versions array', () => { + const versions = [ + { version: '2.0.0', prerelease: false }, + { version: '2.0.0-beta', prerelease: true }, + ]; + const original = [...versions]; + buildVersionSelectItems(versions, '2.0.0', null, true); + expect(versions).toEqual(original); + }); + + it('returns one item per version when no ghost is needed', () => { + const versions = [ + { version: '3.0.0', prerelease: false }, + { version: '2.0.0', prerelease: false }, + { version: '1.0.0', prerelease: false }, + ]; + const items = buildVersionSelectItems( + versions, + '3.0.0', + '2.0.0', + false + ); + expect(items).toHaveLength(3); + }); + + it('returns versions.length + 1 items when a ghost is inserted', () => { + const versions = [ + { version: '2.0.0', prerelease: false }, + { version: '1.0.0', prerelease: false }, + ]; + const items = buildVersionSelectItems( + versions, + '2.0.0', + '1.5.0', + false + ); + expect(items).toHaveLength(3); + }); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // getInstallInfo + // ─────────────────────────────────────────────────────────────────────────── + describe('getInstallInfo', () => { + it('returns isDowngrade true when pendingInstall is lower than installed_version', () => { + const pendingInstall = { version: '1.0.0' }; + const plugin = { installed_version: '2.0.0', signature_verified: true }; + const info = getInstallInfo(pendingInstall, plugin); + expect(info.isDowngrade).toBe(true); + expect(info.isUpdate).toBe(false); + expect(info.isBadSig).toBe(false); + }); + + it('returns isUpdate true when pendingInstall is higher than installed_version', () => { + const pendingInstall = { version: '3.0.0' }; + const plugin = { installed_version: '2.0.0', signature_verified: true }; + const info = getInstallInfo(pendingInstall, plugin); + expect(info.isDowngrade).toBe(false); + expect(info.isUpdate).toBe(true); + expect(info.isBadSig).toBe(false); + }); + + it('returns isBadSig true when signature_verified is false', () => { + const pendingInstall = { version: '2.0.0' }; + const plugin = { installed_version: '2.0.0', signature_verified: false }; + const info = getInstallInfo(pendingInstall, plugin); + expect(info.isDowngrade).toBe(false); + expect(info.isUpdate).toBe(false); + expect(info.isBadSig).toBe(true); + }); + + it('returns all false when no conditions are met', () => { + const pendingInstall = { version: '2.0.0' }; + const plugin = { installed_version: '2.0.0', signature_verified: true }; + const info = getInstallInfo(pendingInstall, plugin); + expect(info.isDowngrade).toBe(false); + expect(info.isUpdate).toBe(false); + expect(info.isBadSig).toBe(false); + }); + }); +}); diff --git a/frontend/src/utils/components/backups/__tests__/BackupManagerUtils.test.js b/frontend/src/utils/components/backups/__tests__/BackupManagerUtils.test.js new file mode 100644 index 00000000..b1d53a36 --- /dev/null +++ b/frontend/src/utils/components/backups/__tests__/BackupManagerUtils.test.js @@ -0,0 +1,327 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── API mock ─────────────────────────────────────────────────────────────────── +vi.mock('../../../../api.js', () => ({ + default: { + listBackups: vi.fn(), + getBackupSchedule: vi.fn(), + updateBackupSchedule: vi.fn(), + createBackup: vi.fn(), + uploadBackup: vi.fn(), + downloadBackup: vi.fn(), + restoreBackup: vi.fn(), + deleteBackup: vi.fn(), + }, +})); + +import API from '../../../../api.js'; +import { + to12Hour, + to24Hour, + DAYS_OF_WEEK, + listBackups, + getBackupSchedule, + updateBackupSchedule, + createBackup, + uploadBackup, + downloadBackup, + restoreBackup, + deleteBackup, +} from '../BackupManagerUtils.js'; + +// ────────────────────────────────────────────────────────────────────────────── + +describe('BackupManagerUtils', () => { + describe('to12Hour', () => { + it('converts midnight (00:00) to 12:00 AM', () => { + expect(to12Hour('00:00')).toEqual({ time: '12:00', period: 'AM' }); + }); + + it('converts noon (12:00) to 12:00 PM', () => { + expect(to12Hour('12:00')).toEqual({ time: '12:00', period: 'PM' }); + }); + + it('converts 13:00 to 1:00 PM', () => { + expect(to12Hour('13:00')).toEqual({ time: '1:00', period: 'PM' }); + }); + + it('converts 09:05 to 9:05 AM', () => { + expect(to12Hour('09:05')).toEqual({ time: '9:05', period: 'AM' }); + }); + + it('converts 23:59 to 11:59 PM', () => { + expect(to12Hour('23:59')).toEqual({ time: '11:59', period: 'PM' }); + }); + + it('converts 01:30 to 1:30 AM', () => { + expect(to12Hour('01:30')).toEqual({ time: '1:30', period: 'AM' }); + }); + + it('converts 12:45 to 12:45 PM', () => { + expect(to12Hour('12:45')).toEqual({ time: '12:45', period: 'PM' }); + }); + + it('returns default when called with null', () => { + expect(to12Hour(null)).toEqual({ time: '12:00', period: 'AM' }); + }); + + it('returns default when called with undefined', () => { + expect(to12Hour(undefined)).toEqual({ time: '12:00', period: 'AM' }); + }); + + it('returns default when called with empty string', () => { + expect(to12Hour('')).toEqual({ time: '12:00', period: 'AM' }); + }); + + it('pads single-digit minutes correctly', () => { + expect(to12Hour('14:05')).toEqual({ time: '2:05', period: 'PM' }); + }); + }); + + // ────────────────────────────────────────────────────────────────────────────── + + describe('to24Hour', () => { + it('converts 12:00 AM to 00:00', () => { + expect(to24Hour('12:00', 'AM')).toBe('00:00'); + }); + + it('converts 12:00 PM to 12:00', () => { + expect(to24Hour('12:00', 'PM')).toBe('12:00'); + }); + + it('converts 1:00 PM to 13:00', () => { + expect(to24Hour('1:00', 'PM')).toBe('13:00'); + }); + + it('converts 11:59 PM to 23:59', () => { + expect(to24Hour('11:59', 'PM')).toBe('23:59'); + }); + + it('converts 9:05 AM to 09:05', () => { + expect(to24Hour('9:05', 'AM')).toBe('09:05'); + }); + + it('converts 12:30 AM to 00:30', () => { + expect(to24Hour('12:30', 'AM')).toBe('00:30'); + }); + + it('converts 12:45 PM to 12:45', () => { + expect(to24Hour('12:45', 'PM')).toBe('12:45'); + }); + + it('converts 1:00 AM to 01:00', () => { + expect(to24Hour('1:00', 'AM')).toBe('01:00'); + }); + + it('returns 00:00 when time12 is null', () => { + expect(to24Hour(null, 'AM')).toBe('00:00'); + }); + + it('returns 00:00 when time12 is undefined', () => { + expect(to24Hour(undefined, 'PM')).toBe('00:00'); + }); + + it('returns 00:00 when time12 is empty string', () => { + expect(to24Hour('', 'PM')).toBe('00:00'); + }); + + it('pads hours and minutes to two digits', () => { + expect(to24Hour('2:05', 'PM')).toBe('14:05'); + }); + }); + + // ────────────────────────────────────────────────────────────────────────────── + + describe('to12Hour / to24Hour roundtrip', () => { + const cases = [ + '00:00', + '00:30', + '01:00', + '09:05', + '11:59', + '12:00', + '12:01', + '13:00', + '14:05', + '23:59', + ]; + + it.each(cases)('round-trips %s', (time24) => { + const { time, period } = to12Hour(time24); + expect(to24Hour(time, period)).toBe(time24); + }); + }); + + // ────────────────────────────────────────────────────────────────────────────── + + describe('DAYS_OF_WEEK', () => { + it('has 7 entries', () => { + expect(DAYS_OF_WEEK).toHaveLength(7); + }); + + it('starts with Sunday (value "0")', () => { + expect(DAYS_OF_WEEK[0]).toEqual({ value: '0', label: 'Sunday' }); + }); + + it('ends with Saturday (value "6")', () => { + expect(DAYS_OF_WEEK[6]).toEqual({ value: '6', label: 'Saturday' }); + }); + + it('contains Monday through Friday in order', () => { + const labels = DAYS_OF_WEEK.map((d) => d.label); + expect(labels).toEqual([ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ]); + }); + + it('has string values "0"–"6"', () => { + DAYS_OF_WEEK.forEach((day, i) => { + expect(day.value).toBe(String(i)); + }); + }); + }); + + // ────────────────────────────────────────────────────────────────────────────── + + describe('API proxy functions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('listBackups', () => { + it('delegates to API.listBackups and returns its result', async () => { + const data = [{ filename: 'backup1.zip' }]; + vi.mocked(API.listBackups).mockResolvedValue(data); + await expect(listBackups()).resolves.toEqual(data); + expect(API.listBackups).toHaveBeenCalledTimes(1); + }); + + it('propagates rejection from API.listBackups', async () => { + vi.mocked(API.listBackups).mockRejectedValue(new Error('network')); + await expect(listBackups()).rejects.toThrow('network'); + }); + }); + + describe('getBackupSchedule', () => { + it('delegates to API.getBackupSchedule and returns its result', async () => { + const schedule = { enabled: true, time: '02:00' }; + vi.mocked(API.getBackupSchedule).mockResolvedValue(schedule); + await expect(getBackupSchedule()).resolves.toEqual(schedule); + expect(API.getBackupSchedule).toHaveBeenCalledTimes(1); + }); + + it('propagates rejection from API.getBackupSchedule', async () => { + vi.mocked(API.getBackupSchedule).mockRejectedValue(new Error('fail')); + await expect(getBackupSchedule()).rejects.toThrow('fail'); + }); + }); + + describe('updateBackupSchedule', () => { + it('passes settings through to API.updateBackupSchedule', async () => { + const settings = { enabled: false, time: '03:00' }; + const updated = { ...settings, id: 1 }; + vi.mocked(API.updateBackupSchedule).mockResolvedValue(updated); + await expect(updateBackupSchedule(settings)).resolves.toEqual(updated); + expect(API.updateBackupSchedule).toHaveBeenCalledWith(settings); + }); + + it('propagates rejection', async () => { + vi.mocked(API.updateBackupSchedule).mockRejectedValue(new Error('err')); + await expect(updateBackupSchedule({})).rejects.toThrow('err'); + }); + }); + + describe('createBackup', () => { + it('delegates to API.createBackup and returns its result', async () => { + const result = { filename: 'new-backup.zip' }; + vi.mocked(API.createBackup).mockResolvedValue(result); + await expect(createBackup()).resolves.toEqual(result); + expect(API.createBackup).toHaveBeenCalledTimes(1); + }); + + it('propagates rejection', async () => { + vi.mocked(API.createBackup).mockRejectedValue(new Error('disk full')); + await expect(createBackup()).rejects.toThrow('disk full'); + }); + }); + + describe('uploadBackup', () => { + it('passes file to API.uploadBackup', async () => { + const file = new File(['data'], 'backup.zip'); + const result = { filename: 'backup.zip' }; + vi.mocked(API.uploadBackup).mockResolvedValue(result); + await expect(uploadBackup(file)).resolves.toEqual(result); + expect(API.uploadBackup).toHaveBeenCalledWith(file); + }); + + it('propagates rejection', async () => { + vi.mocked(API.uploadBackup).mockRejectedValue( + new Error('upload error') + ); + await expect(uploadBackup(new File([], 'x.zip'))).rejects.toThrow( + 'upload error' + ); + }); + }); + + describe('downloadBackup', () => { + it('passes filename to API.downloadBackup', async () => { + const filename = 'backup-2024.zip'; + vi.mocked(API.downloadBackup).mockResolvedValue({ filename }); + await expect(downloadBackup(filename)).resolves.toEqual({ filename }); + expect(API.downloadBackup).toHaveBeenCalledWith(filename); + }); + + it('propagates rejection', async () => { + vi.mocked(API.downloadBackup).mockRejectedValue(new Error('not found')); + await expect(downloadBackup('missing.zip')).rejects.toThrow( + 'not found' + ); + }); + }); + + describe('restoreBackup', () => { + it('passes filename and onProgress to API.restoreBackup', async () => { + const filename = 'backup.zip'; + const onProgress = vi.fn(); + const result = { success: true }; + vi.mocked(API.restoreBackup).mockResolvedValue(result); + await expect(restoreBackup(filename, onProgress)).resolves.toEqual( + result + ); + expect(API.restoreBackup).toHaveBeenCalledWith(filename, onProgress); + }); + + it('propagates rejection', async () => { + vi.mocked(API.restoreBackup).mockRejectedValue( + new Error('restore failed') + ); + await expect(restoreBackup('backup.zip', vi.fn())).rejects.toThrow( + 'restore failed' + ); + }); + }); + + describe('deleteBackup', () => { + it('passes filename to API.deleteBackup', async () => { + const filename = 'old-backup.zip'; + vi.mocked(API.deleteBackup).mockResolvedValue(undefined); + await expect(deleteBackup(filename)).resolves.toBeUndefined(); + expect(API.deleteBackup).toHaveBeenCalledWith(filename); + }); + + it('propagates rejection', async () => { + vi.mocked(API.deleteBackup).mockRejectedValue( + new Error('delete error') + ); + await expect(deleteBackup('old.zip')).rejects.toThrow('delete error'); + }); + }); + }); +}); diff --git a/frontend/src/utils/pages/__tests__/PluginsUtils.test.js b/frontend/src/utils/pages/__tests__/PluginsUtils.test.js index e43bceb4..7d175451 100644 --- a/frontend/src/utils/pages/__tests__/PluginsUtils.test.js +++ b/frontend/src/utils/pages/__tests__/PluginsUtils.test.js @@ -10,6 +10,7 @@ vi.mock('../../../api.js', () => ({ importPlugin: vi.fn(), reloadPlugins: vi.fn(), deletePlugin: vi.fn(), + getPluginDetailManifest: vi.fn(), }, })); @@ -298,4 +299,49 @@ describe('PluginsUtils', () => { expect(API.deletePlugin).toHaveBeenCalledWith(null); }); }); + + describe('getPluginDetailManifest', () => { + it('should call API getPluginDetailManifest with repoId and manifestUrl', () => { + const repoId = 'test-repo'; + const manifestUrl = 'https://example.com/manifest.json'; + + PluginsUtils.getPluginDetailManifest(repoId, manifestUrl); + + expect(API.getPluginDetailManifest).toHaveBeenCalledWith( + repoId, + manifestUrl + ); + expect(API.getPluginDetailManifest).toHaveBeenCalledTimes(1); + }); + + it('should return API response', () => { + const repoId = 'test-repo'; + const manifestUrl = 'https://example.com/manifest.json'; + const mockResponse = { name: 'Test Plugin', version: '1.0.0' }; + + API.getPluginDetailManifest.mockReturnValue(mockResponse); + + const result = PluginsUtils.getPluginDetailManifest(repoId, manifestUrl); + + expect(result).toEqual(mockResponse); + }); + + it('should handle empty string repoId and manifestUrl', () => { + const repoId = ''; + const manifestUrl = ''; + + PluginsUtils.getPluginDetailManifest(repoId, manifestUrl); + + expect(API.getPluginDetailManifest).toHaveBeenCalledWith('', ''); + }); + + it('should handle null repoId and manifestUrl', () => { + const repoId = null; + const manifestUrl = null; + + PluginsUtils.getPluginDetailManifest(repoId, manifestUrl); + + expect(API.getPluginDetailManifest).toHaveBeenCalledWith(null, null); + }); + }); }); From 71f1c7d5e57c80e71ade181d1cfe08186d98eca8 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:54:57 -0700 Subject: [PATCH 4/7] Slight component refactoring --- frontend/src/components/AboutModal.jsx | 6 +- frontend/src/components/PluginDetailPanel.jsx | 143 +++++++----------- .../src/components/backups/BackupManager.jsx | 136 ++++++----------- .../components/cards/AvailablePluginCard.jsx | 41 +++-- .../settings/ConnectionSecurityPanel.jsx | 25 ++- .../forms/settings/UserLimitsForm.jsx | 32 ++-- frontend/src/pages/PluginBrowse.jsx | 2 +- 7 files changed, 146 insertions(+), 239 deletions(-) diff --git a/frontend/src/components/AboutModal.jsx b/frontend/src/components/AboutModal.jsx index 55342cb3..ce4bdc8f 100644 --- a/frontend/src/components/AboutModal.jsx +++ b/frontend/src/components/AboutModal.jsx @@ -10,8 +10,8 @@ import { Text, Tooltip, } from '@mantine/core'; -import { BookOpen, Github, Heart, Users } from 'lucide-react'; -import { DiscordIcon } from './icons.jsx'; +import { BookOpen, Heart, Users } from 'lucide-react'; +import { DiscordIcon, GitHubIcon } from './icons.jsx'; import logo from '../images/logo.png'; import useSettingsStore from '../store/settings'; @@ -76,7 +76,7 @@ const AboutModal = ({ isOpen, onClose }) => { target="_blank" rel="noopener noreferrer" variant="default" - leftSection={} + leftSection={} fullWidth > GitHub diff --git a/frontend/src/components/PluginDetailPanel.jsx b/frontend/src/components/PluginDetailPanel.jsx index 27d553a9..f7a5593f 100644 --- a/frontend/src/components/PluginDetailPanel.jsx +++ b/frontend/src/components/PluginDetailPanel.jsx @@ -9,6 +9,9 @@ import { Select, Stack, Table, + TableTbody, + TableTd, + TableTr, Text, Tooltip, } from '@mantine/core'; @@ -21,7 +24,11 @@ import { ShieldCheck, Trash2, } from 'lucide-react'; -import { compareVersions } from './pluginUtils.js'; +import { + buildCompatibilityTooltip, + buildVersionSelectItems, + compareVersions, +} from '../utils/components/pluginUtils.js'; import { formatKB } from '../utils/networkUtils.js'; import { DiscordIcon, GitHubIcon } from './icons.jsx'; @@ -294,42 +301,12 @@ const PluginDetailPanel = ({ {manifest.versions?.length > 0 && (() => { - const installedMissing = - installedVersion && - !manifest.versions.some( - (v) => compareVersions(v.version, installedVersion) === 0 - ); - const buildLabel = (v) => - `v${v.version}${v.prerelease ? ' (prerelease)' : ''}${v.version === manifest.latest?.version ? ' (latest)' : ''}${installedVersion && compareVersions(v.version, installedVersion) === 0 ? ' (installed)' : ''}`; - - let versions = [...manifest.versions]; - if (installedVersionIsPrerelease) { - const prereleases = versions.filter((v) => v.prerelease); - const stable = versions.filter((v) => !v.prerelease); - versions = [...prereleases, ...stable]; - } - - const versionItems = versions.map((v) => ({ - value: v.version, - label: buildLabel(v), - disabled: false, - })); - if (installedMissing) { - const ghostItem = { - value: installedVersion, - label: `v${installedVersion} (installed)`, - disabled: true, - }; - // Insert in sorted position (newest first, matching manifest order convention) - const idx = versionItems.findIndex( - (item) => compareVersions(installedVersion, item.value) > 0 - ); - if (idx === -1) { - versionItems.push(ghostItem); - } else { - versionItems.splice(idx, 0, ghostItem); - } - } + const versionItems = buildVersionSelectItems( + manifest.versions, + manifest.latest?.version, + installedVersion, + installedVersionIsPrerelease + ); return ( <> @@ -372,21 +349,17 @@ const PluginDetailPanel = ({ selectedVersionData && !isSelSame && (() => { - const parts = []; - if (!selMeetsMin) - parts.push( - `${selectedVersionData.min_dispatcharr_version} or newer` - ); - if (!selMeetsMax) - parts.push( - `${selectedVersionData.max_dispatcharr_version} or older` - ); + const tooltip = buildCompatibilityTooltip( + selMeetsMin, + selectedVersionData, + selMeetsMax + ); const label = !selMeetsMin ? `Min ${selectedVersionData.min_dispatcharr_version}` : `Max ${selectedVersionData.max_dispatcharr_version}`; return ( - + {selectedVersionData.build_timestamp && ( - - + + Built - - + + {new Date( selectedVersionData.build_timestamp ).toLocaleString()} - - + + )} {Number.isFinite(selectedVersionData.size) && selectedVersionData.size > 0 && ( - - + + File Size - - + + {formatKB(selectedVersionData.size)} - - + + )} {selectedVersionData.min_dispatcharr_version && ( - - + + Min Version - - + + {selectedVersionData.min_dispatcharr_version} - - + + )} {selectedVersionData.max_dispatcharr_version && ( - - + + Max Version - - + + {selectedVersionData.max_dispatcharr_version} - - + + )} {selectedVersionData.commit_sha_short && ( - - + + Commit - - + + {manifest.registry_url ? ( - + + )} {selectedVersionData.url && ( - - + + Download - - + + {selectedVersionData.url.split('/').pop()} - - + + )} - + )} diff --git a/frontend/src/components/backups/BackupManager.jsx b/frontend/src/components/backups/BackupManager.jsx index c58755b2..b1c0ffe3 100644 --- a/frontend/src/components/backups/BackupManager.jsx +++ b/frontend/src/components/backups/BackupManager.jsx @@ -14,7 +14,6 @@ import { Stack, Switch, Text, - TextInput, Tooltip, } from '@mantine/core'; import { @@ -25,16 +24,32 @@ import { SquarePlus, UploadCloud, } from 'lucide-react'; -import { notifications } from '@mantine/notifications'; -import dayjs from 'dayjs'; - -import API from '../../api'; import ConfirmationDialog from '../ConfirmationDialog'; import useLocalStorage from '../../hooks/useLocalStorage'; import useWarningsStore from '../../store/warnings'; import { CustomTable, useTable } from '../tables/CustomTable'; import { validateCronExpression } from '../../utils/cronUtils'; import ScheduleInput from '../forms/ScheduleInput'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { formatBytes } from '../../utils/networkUtils.js'; +import { + format, + getDefaultTimeZone, + useDateTimeFormat, +} from '../../utils/dateTimeUtils.js'; +import { + createBackup, + DAYS_OF_WEEK, + deleteBackup, + downloadBackup, + getBackupSchedule, + listBackups, + restoreBackup, + to12Hour, + to24Hour, + updateBackupSchedule, + uploadBackup, +} from '../../utils/components/backups/BackupManagerUtils.js'; const RowActions = ({ row, @@ -81,58 +96,6 @@ const RowActions = ({ ); }; -// Convert 24h time string to 12h format with period -function to12Hour(time24) { - if (!time24) return { time: '12:00', period: 'AM' }; - const [hours, minutes] = time24.split(':').map(Number); - const period = hours >= 12 ? 'PM' : 'AM'; - const hours12 = hours % 12 || 12; - return { - time: `${hours12}:${String(minutes).padStart(2, '0')}`, - period, - }; -} - -// Convert 12h time + period to 24h format -function to24Hour(time12, period) { - if (!time12) return '00:00'; - const [hours, minutes] = time12.split(':').map(Number); - let hours24 = hours; - if (period === 'PM' && hours !== 12) { - hours24 = hours + 12; - } else if (period === 'AM' && hours === 12) { - hours24 = 0; - } - return `${String(hours24).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; -} - -// Get default timezone (same as Settings page) -function getDefaultTimeZone() { - try { - return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; - } catch { - return 'UTC'; - } -} - -const DAYS_OF_WEEK = [ - { value: '0', label: 'Sunday' }, - { value: '1', label: 'Monday' }, - { value: '2', label: 'Tuesday' }, - { value: '3', label: 'Wednesday' }, - { value: '4', label: 'Thursday' }, - { value: '5', label: 'Friday' }, - { value: '6', label: 'Saturday' }, -]; - -function formatBytes(bytes) { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`; -} - export default function BackupManager() { const [backups, setBackups] = useState([]); const [loading, setLoading] = useState(false); @@ -147,18 +110,9 @@ export default function BackupManager() { const [deleting, setDeleting] = useState(false); // Read user's preferences from settings - const [timeFormat] = useLocalStorage('time-format', '12h'); - const [dateFormatSetting] = useLocalStorage('date-format', 'mdy'); + const { fullDateTimeFormat, timeFormatSetting } = useDateTimeFormat(); const [userTimezone] = useLocalStorage('time-zone', getDefaultTimeZone()); - const is12Hour = timeFormat === '12h'; - - // Format date according to user preferences - const formatDate = (dateString) => { - const date = dayjs(dateString); - const datePart = dateFormatSetting === 'mdy' ? 'MM/DD/YYYY' : 'DD/MM/YYYY'; - const timePart = is12Hour ? 'h:mm:ss A' : 'HH:mm:ss'; - return date.format(`${datePart}, ${timePart}`); - }; + const is12Hour = timeFormatSetting === '12h'; // Warning suppression for confirmation dialogs const suppressWarning = useWarningsStore((s) => s.suppressWarning); @@ -213,7 +167,7 @@ export default function BackupManager() { minSize: 180, cell: ({ cell }) => ( - {formatDate(cell.getValue())} + {format(cell.getValue(), fullDateTimeFormat)} ), }, @@ -267,10 +221,10 @@ export default function BackupManager() { const loadBackups = async () => { setLoading(true); try { - const backupList = await API.listBackups(); + const backupList = await listBackups(); setBackups(backupList); } catch (error) { - notifications.show({ + showNotification({ title: 'Error', message: error?.message || 'Failed to load backups', color: 'red', @@ -283,7 +237,7 @@ export default function BackupManager() { const loadSchedule = async () => { setScheduleLoading(true); try { - const settings = await API.getBackupSchedule(); + const settings = await getBackupSchedule(); setSchedule(settings); setScheduleType(settings.cron_expression ? 'cron' : 'interval'); @@ -294,7 +248,7 @@ export default function BackupManager() { setTimePeriod(period); setScheduleChanged(false); - } catch (error) { + } catch { // Ignore errors on initial load - settings may not exist yet } finally { setScheduleLoading(false); @@ -340,17 +294,17 @@ export default function BackupManager() { ? schedule : { ...schedule, cron_expression: '' }; - const updated = await API.updateBackupSchedule(scheduleToSave); + const updated = await updateBackupSchedule(scheduleToSave); setSchedule(updated); setScheduleChanged(false); - notifications.show({ + showNotification({ title: 'Success', message: 'Backup schedule saved', color: 'green', }); } catch (error) { - notifications.show({ + showNotification({ title: 'Error', message: error?.message || 'Failed to save schedule', color: 'red', @@ -363,15 +317,15 @@ export default function BackupManager() { const handleCreateBackup = async () => { setCreating(true); try { - await API.createBackup(); - notifications.show({ + await createBackup(); + showNotification({ title: 'Success', message: 'Backup created successfully', color: 'green', }); await loadBackups(); } catch (error) { - notifications.show({ + showNotification({ title: 'Error', message: error?.message || 'Failed to create backup', color: 'red', @@ -384,14 +338,14 @@ export default function BackupManager() { const handleDownload = async (filename) => { setDownloading(filename); try { - await API.downloadBackup(filename); - notifications.show({ + await downloadBackup(filename); + showNotification({ title: 'Download Started', message: `Downloading ${filename}...`, color: 'blue', }); } catch (error) { - notifications.show({ + showNotification({ title: 'Error', message: error?.message || 'Failed to download backup', color: 'red', @@ -409,15 +363,15 @@ export default function BackupManager() { const handleDeleteConfirm = async () => { setDeleting(true); try { - await API.deleteBackup(selectedBackup.name); - notifications.show({ + await deleteBackup(selectedBackup.name); + showNotification({ title: 'Success', message: 'Backup deleted successfully', color: 'green', }); await loadBackups(); } catch (error) { - notifications.show({ + showNotification({ title: 'Error', message: error?.message || 'Failed to delete backup', color: 'red', @@ -437,8 +391,8 @@ export default function BackupManager() { const handleRestoreConfirm = async () => { setRestoring(true); try { - await API.restoreBackup(selectedBackup.name); - notifications.show({ + await restoreBackup(selectedBackup.name); + showNotification({ title: 'Restore Complete', message: 'Backup restored successfully. A restart is recommended to ensure all services are running against the restored data.', @@ -446,7 +400,7 @@ export default function BackupManager() { }); setTimeout(() => window.location.reload(), 4000); } catch (error) { - notifications.show({ + showNotification({ title: 'Error', message: error?.message || 'Failed to restore backup', color: 'red', @@ -462,8 +416,8 @@ export default function BackupManager() { if (!uploadFile) return; try { - await API.uploadBackup(uploadFile); - notifications.show({ + await uploadBackup(uploadFile); + showNotification({ title: 'Success', message: 'Backup uploaded successfully', color: 'green', @@ -472,7 +426,7 @@ export default function BackupManager() { setUploadFile(null); await loadBackups(); } catch (error) { - notifications.show({ + showNotification({ title: 'Error', message: error?.message || 'Failed to upload backup', color: 'red', diff --git a/frontend/src/components/cards/AvailablePluginCard.jsx b/frontend/src/components/cards/AvailablePluginCard.jsx index 71b5ff1e..497a5435 100644 --- a/frontend/src/components/cards/AvailablePluginCard.jsx +++ b/frontend/src/components/cards/AvailablePluginCard.jsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import { - ActionIcon, Avatar, Badge, Box, @@ -33,8 +32,7 @@ import { PluginSecurityWarning, PluginSupportDisclaimer, } from '../PluginWarnings.jsx'; -import { useNavigate, useLocation } from 'react-router-dom'; -import API from '../../api'; +import { useLocation, useNavigate } from 'react-router-dom'; import { usePluginStore } from '../../store/plugins'; import PluginDetailPanel from '../PluginDetailPanel.jsx'; import { @@ -43,6 +41,11 @@ import { getInstallInfo, } from '../../utils/components/pluginUtils.js'; import SizedInstallButton from '../theme/SizedInstallButton.jsx'; +import { + deletePluginByKey, + getPluginDetailManifest, + setPluginEnabled, +} from '../../utils/pages/PluginsUtils.js'; const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => { if (isOfficial) { @@ -319,7 +322,7 @@ const AvailablePluginCard = ({ if (enableNow && installedKey) { setEnabling(true); try { - await API.setPluginEnabled(installedKey, true); + await setPluginEnabled(installedKey, true); } finally { setEnabling(false); } @@ -333,7 +336,7 @@ const AvailablePluginCard = ({ if (!key) return; setUninstalling(true); try { - const resp = await API.deletePlugin(key); + const resp = await deletePluginByKey(key); if (resp?.success) { onUninstalled?.(plugin.slug); usePluginStore.getState().invalidatePlugins(); @@ -379,7 +382,7 @@ const AvailablePluginCard = ({ return; } setDetailLoading(true); - const result = await API.getPluginDetailManifest( + const result = await getPluginDetailManifest( plugin.repo_id, plugin.manifest_url ); @@ -534,17 +537,17 @@ const AvailablePluginCard = ({ {!meetsVersion && (() => { - const parts = []; - if (!meetsMinVersion) - parts.push(`${plugin.min_dispatcharr_version} or newer`); - if (!meetsMaxVersion) - parts.push(`${plugin.max_dispatcharr_version} or older`); + const tooltip = buildCompatibilityTooltip( + meetsMinVersion, + plugin, + meetsMaxVersion + ); const label = !meetsMinVersion ? `Min ${plugin.min_dispatcharr_version}` : `Max ${plugin.max_dispatcharr_version}`; return ( { - const isDowngrade = - pendingInstall && - plugin.installed_version && - compareVersions(pendingInstall.version, plugin.installed_version) < 0; - const isUpdate = - pendingInstall && - plugin.installed_version && - !isDowngrade && - compareVersions(pendingInstall.version, plugin.installed_version) > 0; - const isBadSig = plugin.signature_verified === false; + const { isDowngrade, isUpdate, isBadSig } = getInstallInfo( + pendingInstall, + plugin + ); const actionLabel = isDowngrade ? 'Downgrade' : isUpdate diff --git a/frontend/src/components/forms/settings/ConnectionSecurityPanel.jsx b/frontend/src/components/forms/settings/ConnectionSecurityPanel.jsx index 9d6016d4..5797b6ef 100644 --- a/frontend/src/components/forms/settings/ConnectionSecurityPanel.jsx +++ b/frontend/src/components/forms/settings/ConnectionSecurityPanel.jsx @@ -51,12 +51,11 @@ const RedisStatus = ({ tls }) => { : 'The connection is encrypted but the server identity is not verified'; } - let mtlsColor = 'gray'; - let mtlsTooltip = 'Mutual authentication is not active'; - if (enabled && mtls) { - mtlsColor = 'green'; - mtlsTooltip = 'Both client and server verify each other with certificates'; - } + const mtlsColor = enabled && mtls ? 'green' : 'gray'; + const mtlsTooltip = + enabled && mtls + ? 'Both client and server verify each other with certificates' + : 'Mutual authentication is not active'; return ( @@ -94,11 +93,10 @@ const PostgresStatus = ({ tls }) => { const sslMode = tls?.ssl_mode; const mtls = tls?.mtls ?? false; + const modeBadge = enabled && sslMode ? sslMode : 'Off'; let modeColor = 'gray'; let modeTooltip = 'Verification mode is not active — TLS is disabled'; - let modeBadge = 'Off'; if (enabled && sslMode) { - modeBadge = sslMode; if (sslMode === 'verify-full') { modeColor = 'green'; modeTooltip = 'Server certificate and hostname are both verified'; @@ -112,12 +110,11 @@ const PostgresStatus = ({ tls }) => { } } - let mtlsColor = 'gray'; - let mtlsTooltip = 'Mutual authentication is not active'; - if (enabled && mtls) { - mtlsColor = 'green'; - mtlsTooltip = 'Both client and server verify each other with certificates'; - } + const mtlsColor = enabled && mtls ? 'green' : 'gray'; + const mtlsTooltip = + enabled && mtls + ? 'Both client and server verify each other with certificates' + : 'Mutual authentication is not active'; return ( diff --git a/frontend/src/components/forms/settings/UserLimitsForm.jsx b/frontend/src/components/forms/settings/UserLimitsForm.jsx index b6a9ed5e..ebb2d87f 100644 --- a/frontend/src/components/forms/settings/UserLimitsForm.jsx +++ b/frontend/src/components/forms/settings/UserLimitsForm.jsx @@ -2,15 +2,7 @@ import useSettingsStore from '../../../store/settings.jsx'; import React, { useEffect, useState } from 'react'; import { useForm } from '@mantine/form'; import { updateSetting } from '../../../utils/pages/SettingsUtils.js'; -import { - Alert, - Button, - Flex, - NumberInput, - Stack, - TextInput, - Checkbox, -} from '@mantine/core'; +import { Alert, Button, Flex, Stack, Checkbox } from '@mantine/core'; import { USER_LIMITS_OPTIONS } from '../../../constants.js'; const USER_LIMIT_DEFAULTS = Object.keys(USER_LIMITS_OPTIONS).reduce( @@ -77,20 +69,14 @@ const UserLimitsForm = React.memo(({ active }) => { > )} - {Object.keys(USER_LIMITS_OPTIONS).reduce((acc, key) => { - const option = USER_LIMITS_OPTIONS[key]; - acc.push( - - ); - return acc; - }, [])} + {Object.entries(USER_LIMITS_OPTIONS).map(([key, option]) => ( + + ))} + {children} + + ) : null, + SimpleGrid: ({ children, cols }) =>
{children}
, + Stack: ({ children }) =>
{children}
, + Text: ({ children, fw, size, c, span }) => + span ? ( + + {children} + + ) : ( +

+ {children} +

+ ), + Tooltip: ({ children, label, position }) => ( +
+ {children} +
+ ), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import useSettingsStore from '../../store/settings'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const setupStore = (version = { version: '1.2.3', timestamp: '20240601' }) => { + vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ version })); +}; + +describe('AboutModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Visibility ─────────────────────────────────────────────────────────────── + + describe('visibility', () => { + it('renders modal content when isOpen is true', () => { + setupStore(); + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('does not render modal content when isOpen is false', () => { + setupStore(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('calls onClose when close button is clicked', () => { + setupStore(); + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('renders with the correct modal title', () => { + setupStore(); + render(); + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'About Dispatcharr' + ); + }); + }); + + // ── Version string ─────────────────────────────────────────────────────────── + + describe('version string', () => { + it('displays version with timestamp when both are present', () => { + setupStore({ version: '2.0.0', timestamp: '20240601' }); + render(); + expect(screen.getByText('v2.0.0-20240601')).toBeInTheDocument(); + }); + + it('displays version without timestamp when timestamp is null', () => { + setupStore({ version: '1.5.0', timestamp: null }); + render(); + expect(screen.getByText('v1.5.0')).toBeInTheDocument(); + }); + + it('displays version without timestamp when timestamp is undefined', () => { + setupStore({ version: '1.5.0' }); + render(); + expect(screen.getByText('v1.5.0')).toBeInTheDocument(); + }); + + it('falls back to v0.0.0 when version is undefined', () => { + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ version: undefined }) + ); + render(); + expect(screen.getByText('v0.0.0')).toBeInTheDocument(); + }); + + it('falls back to v0.0.0 when version object has no version field', () => { + setupStore({ version: '', timestamp: null }); + render(); + expect(screen.getByText('v0.0.0')).toBeInTheDocument(); + }); + }); + + // ── Logo & branding ────────────────────────────────────────────────────────── + + describe('logo and branding', () => { + it('renders the Dispatcharr logo', () => { + setupStore(); + render(); + const logo = screen.getByAltText('Dispatcharr'); + expect(logo).toBeInTheDocument(); + expect(logo).toHaveAttribute('src', 'mocked-logo.png'); + }); + + it('renders the app name "Dispatcharr"', () => { + setupStore(); + render(); + expect(screen.getByText('Dispatcharr')).toBeInTheDocument(); + }); + }); + + // ── Action buttons ─────────────────────────────────────────────────────────── + + describe('action buttons', () => { + it('renders the Documentation button with correct href', () => { + setupStore(); + render(); + expect(screen.getByText('Documentation').closest('a')).toHaveAttribute( + 'href', + 'https://dispatcharr.github.io/Dispatcharr-Docs/' + ); + }); + + it('renders the Discord button with correct href', () => { + setupStore(); + render(); + expect(screen.getByText('Discord').closest('a')).toHaveAttribute( + 'href', + 'https://discord.gg/Sp45V5BcxU' + ); + }); + + it('renders the GitHub button with correct href', () => { + setupStore(); + render(); + expect(screen.getByText('GitHub').closest('a')).toHaveAttribute( + 'href', + 'https://github.com/Dispatcharr/Dispatcharr' + ); + }); + + it('renders the Donate button with correct href', () => { + setupStore(); + render(); + expect(screen.getByText('Donate').closest('a')).toHaveAttribute( + 'href', + 'https://opencollective.com/dispatcharr/contribute' + ); + }); + + it('all external buttons open in a new tab', () => { + setupStore(); + render(); + const buttons = screen.getAllByTestId('button'); + buttons.forEach((btn) => { + expect(btn).toHaveAttribute('target', '_blank'); + }); + }); + + it('all external buttons have noopener noreferrer rel', () => { + setupStore(); + render(); + const buttons = screen.getAllByTestId('button'); + buttons.forEach((btn) => { + expect(btn).toHaveAttribute('rel', 'noopener noreferrer'); + }); + }); + + it('renders 4 action buttons', () => { + setupStore(); + render(); + expect(screen.getAllByTestId('button')).toHaveLength(4); + }); + }); + + // ── Icons ──────────────────────────────────────────────────────────────────── + + describe('icons', () => { + it('renders BookOpen icon for Documentation button', () => { + setupStore(); + render(); + expect(screen.getByTestId('icon-book-open')).toBeInTheDocument(); + }); + + it('renders DiscordIcon for Discord button', () => { + setupStore(); + render(); + expect(screen.getByTestId('discord-icon')).toBeInTheDocument(); + }); + + it('renders GitHubIcon for GitHub button', () => { + setupStore(); + render(); + expect(screen.getByTestId('github-icon')).toBeInTheDocument(); + }); + + it('renders Heart icon for Donate button', () => { + setupStore(); + render(); + expect(screen.getByTestId('icon-heart')).toBeInTheDocument(); + }); + + it('renders Users icon in Contributors section', () => { + setupStore(); + render(); + expect(screen.getByTestId('icon-users')).toBeInTheDocument(); + }); + }); + + // ── Contributors section ───────────────────────────────────────────────────── + + describe('contributors section', () => { + it('renders the Contributors heading', () => { + setupStore(); + render(); + expect(screen.getByText('Contributors')).toBeInTheDocument(); + }); + + it('renders the contributors description text', () => { + setupStore(); + render(); + expect( + screen.getByText( + /Dispatcharr is built by the community, for the community/i + ) + ).toBeInTheDocument(); + }); + }); + + // ── Memorial section ───────────────────────────────────────────────────────── + + describe('memorial section', () => { + it('renders the memorial text for Jesse Mann', () => { + setupStore(); + render(); + expect(screen.getByText(/In memory of/i)).toBeInTheDocument(); + }); + + it('renders Jesse Mann name in the memorial', () => { + setupStore(); + render(); + expect(screen.getByText('Jesse Mann')).toBeInTheDocument(); + }); + + it('renders the memorial tooltip with correct label', () => { + setupStore(); + render(); + const tooltip = screen + .getByText('Jesse Mann') + .closest('[data-tooltip="Remembering Jesse Mann"]'); + expect(tooltip).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/__tests__/PluginDetailPanel.test.jsx b/frontend/src/components/__tests__/PluginDetailPanel.test.jsx new file mode 100644 index 00000000..d92c0fb6 --- /dev/null +++ b/frontend/src/components/__tests__/PluginDetailPanel.test.jsx @@ -0,0 +1,863 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import PluginDetailPanel from '../PluginDetailPanel'; + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../pluginUtils.js', () => ({ + compareVersions: vi.fn(), + buildCompatibilityTooltip: vi.fn(), + buildVersionSelectItems: vi.fn(), +})); + +vi.mock('../../utils/networkUtils.js', () => ({ + formatKB: vi.fn((kb) => `${kb} KB`), +})); + +// ── Icon mocks ───────────────────────────────────────────────────────────────── +vi.mock('../icons.jsx', () => ({ + DiscordIcon: ({ size }) => ( + + ), + GitHubIcon: ({ size }) => , +})); + +vi.mock('lucide-react', () => ({ + AlertTriangle: ({ size, color }) => ( + + ), + Ban: ({ size }) => , + Download: ({ size }) => , + RefreshCw: ({ size }) => ( + + ), + ShieldAlert: ({ size }) => ( + + ), + ShieldCheck: ({ size }) => ( + + ), + Trash2: ({ size }) => , +})); + +// ── Mantine core mock ────────────────────────────────────────────────────────── +vi.mock('@mantine/core', async () => ({ + ActionIcon: ({ children, href, target, rel, color }) => ( + + {children} + + ), + Alert: ({ children, title, color, icon }) => ( +
+
{title}
+ {icon} + {children} +
+ ), + Badge: ({ + children, + component, + href, + target, + rel, + leftSection, + color, + style, + }) => + component === 'a' ? ( + + {leftSection} + {children} + + ) : ( + + {leftSection} + {children} + + ), + Button: ({ children, onClick, disabled, variant, color, leftSection }) => ( + + ), + Group: ({ children }) =>
{children}
, + Loader: ({ size }) => , + Select: ({ label, value, onChange, data, disabled }) => ( + + ), + Stack: ({ children }) =>
{children}
, + Table: ({ children }) => {children}
, + TableTbody: ({ children }) => {children}, + TableTd: ({ children, style }) => {children}, + TableTr: ({ children }) => {children}, + Text: ({ children, size, c, fw, component, href, target, rel }) => + component === 'a' ? ( + + {children} + + ) : ( + + {children} + + ), + Tooltip: ({ children, label }) =>
{children}
, +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import { + compareVersions, + buildCompatibilityTooltip, + buildVersionSelectItems, +} from '../../utils/components/pluginUtils.js'; + +// ────────────────────────────────────────────────────────────────────────────── +// Factories & helpers +// ────────────────────────────────────────────────────────────────────────────── + +const makeVersion = (version, overrides = {}) => ({ + version, + prerelease: false, + url: `https://example.com/plugin-${version}.zip`, + checksum_sha256: `sha256-${version}`, + size: 1024, + build_timestamp: '2024-01-15T10:00:00Z', + min_dispatcharr_version: null, + max_dispatcharr_version: null, + commit_sha: `abc${version}`, + commit_sha_short: `abc`, + ...overrides, +}); + +const makeManifest = (overrides = {}) => ({ + description: 'A useful plugin.', + author: 'Test Author', + license: 'MIT', + repo_url: 'https://github.com/example/plugin', + discord_thread: null, + deprecated: false, + registry_url: null, + versions: [makeVersion('2.0.0'), makeVersion('1.0.0')], + latest: { version: '2.0.0' }, + ...overrides, +}); + +const makeDetail = (manifestOverrides = {}, overrides = {}) => ({ + manifest: makeManifest(manifestOverrides), + signature_verified: true, + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + detail: makeDetail(), + detailLoading: false, + selectedVersion: '2.0.0', + onVersionChange: vi.fn(), + installedVersion: null, + installedVersionIsPrerelease: false, + appVersion: '1.5.0', + installing: false, + uninstalling: false, + onInstall: vi.fn(), + onUninstall: vi.fn(), + installStatus: 'not_installed', + installedSourceRepoName: null, + repoId: 1, + slug: 'my-plugin', + ...overrides, +}); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('PluginDetailPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + // default: every compareVersions call returns 0 (versions equal) unless + // individual tests override it + vi.mocked(compareVersions).mockReturnValue(0); + vi.mocked(buildCompatibilityTooltip).mockReturnValue('1.0.0 or newer'); + // default: buildVersionSelectItems returns two standard items so that + // any test rendering the version select has populated options + vi.mocked(buildVersionSelectItems).mockReturnValue([ + { value: '2.0.0', label: 'v2.0.0 (latest)', disabled: false }, + { value: '1.0.0', label: 'v1.0.0', disabled: false }, + ]); + }); + + // ── Loading & error states ───────────────────────────────────────────────── + + describe('loading and error states', () => { + it('shows a loader when detailLoading is true', () => { + render(); + expect(screen.getByTestId('loader')).toBeInTheDocument(); + expect(screen.getByText(/Loading plugin details/i)).toBeInTheDocument(); + }); + + it('shows error text when detail is null', () => { + render( + + ); + expect( + screen.getByText(/Failed to load plugin details/i) + ).toBeInTheDocument(); + }); + + it('shows error text when detail has no manifest', () => { + render( + + ); + expect( + screen.getByText(/Failed to load plugin details/i) + ).toBeInTheDocument(); + }); + }); + + // ── Description ─────────────────────────────────────────────────────────── + + describe('description', () => { + it('renders manifest description', () => { + render(); + expect(screen.getByText('A useful plugin.')).toBeInTheDocument(); + }); + + it('does not render description section when absent', () => { + render( + + ); + expect(screen.queryByText('A useful plugin.')).not.toBeInTheDocument(); + }); + }); + + // ── Author & license badges ──────────────────────────────────────────────── + + describe('author and license badges', () => { + it('renders author badge', () => { + render(); + expect(screen.getByText('Test Author')).toBeInTheDocument(); + }); + + it('does not render author badge when author is absent', () => { + render( + + ); + expect(screen.queryByText('Test Author')).not.toBeInTheDocument(); + }); + + it('renders license badge as a link to spdx.org', () => { + render(); + const badge = screen + .getAllByTestId('badge') + .find((el) => el.tagName === 'A' && el.href?.includes('spdx.org')); + expect(badge).toBeTruthy(); + expect(badge.href).toContain('MIT'); + }); + + it('does not render license badge when license is absent', () => { + render( + + ); + const badges = screen.getAllByTestId('badge'); + expect(badges.find((b) => b.href?.includes('spdx.org'))).toBeUndefined(); + }); + }); + + // ── Signature badges ─────────────────────────────────────────────────────── + + describe('signature badge', () => { + it('shows "Verified Signature" badge when signature_verified is true', () => { + render(); + expect(screen.getByText('Verified Signature')).toBeInTheDocument(); + expect(screen.getByTestId('icon-shield-check')).toBeInTheDocument(); + }); + + it('shows "Unverified" badge when signature_verified is false', () => { + render( + + ); + expect(screen.getByText('Unverified')).toBeInTheDocument(); + expect(screen.getByTestId('icon-shield-alert')).toBeInTheDocument(); + }); + + it('renders no signature badge when signature_verified is null', () => { + render( + + ); + expect(screen.queryByText('Verified Signature')).not.toBeInTheDocument(); + expect(screen.queryByText('Unverified')).not.toBeInTheDocument(); + }); + }); + + // ── GitHub link ──────────────────────────────────────────────────────────── + + describe('GitHub link', () => { + it('renders GitHub icon linking to repo_url', () => { + render(); + expect(screen.getByTestId('github-icon')).toBeInTheDocument(); + const link = screen + .getAllByTestId('action-icon') + .find((el) => el.href?.includes('github.com')); + expect(link).toBeTruthy(); + }); + + it('does not render GitHub icon when repo_url is absent', () => { + render( + + ); + expect(screen.queryByTestId('github-icon')).not.toBeInTheDocument(); + }); + }); + + // ── Discord link ─────────────────────────────────────────────────────────── + + describe('Discord link', () => { + it('renders Discord icon when discord_thread is set', () => { + render( + + ); + expect(screen.getByTestId('discord-icon')).toBeInTheDocument(); + }); + + it('does not render Discord icon when discord_thread is null', () => { + render(); + expect(screen.queryByTestId('discord-icon')).not.toBeInTheDocument(); + }); + + it('rewrites discord.com/channels URL to discord:// protocol', () => { + render( + + ); + const link = screen + .getAllByTestId('action-icon') + .find((el) => el.href?.startsWith('discord://')); + expect(link).toBeTruthy(); + }); + + it('keeps non-discord.com/channels URL unchanged', () => { + const url = 'https://discord.gg/invite/abc'; + render( + + ); + const link = screen + .getAllByTestId('action-icon') + .find((el) => el.href === url); + expect(link).toBeTruthy(); + }); + }); + + // ── Deprecated alert ─────────────────────────────────────────────────────── + + describe('deprecated alert', () => { + it('shows deprecated alert when deprecated is true', () => { + render( + + ); + expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByTestId('alert-title')).toHaveTextContent( + 'Deprecated Plugin' + ); + expect(screen.getByTestId('icon-ban')).toBeInTheDocument(); + }); + + it('does not show deprecated alert when deprecated is false', () => { + render(); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + }); + + // ── Version select ───────────────────────────────────────────────────────── + + describe('version select', () => { + it('renders the version select when versions exist', () => { + render(); + expect(screen.getByTestId('version-select')).toBeInTheDocument(); + }); + + it('does not render version select when versions list is empty', () => { + render( + + ); + expect(screen.queryByTestId('version-select')).not.toBeInTheDocument(); + }); + + it('renders options from buildVersionSelectItems return value', () => { + render(); + // default mock returns v2.0.0 (latest) and v1.0.0 + expect(screen.getByText('v2.0.0 (latest)')).toBeInTheDocument(); + expect(screen.getByText('v1.0.0')).toBeInTheDocument(); + }); + + it('calls buildVersionSelectItems with manifest versions, latest version, installedVersion, and installedVersionIsPrerelease', () => { + const detail = makeDetail({ + versions: [makeVersion('2.0.0'), makeVersion('1.0.0')], + latest: { version: '2.0.0' }, + }); + render( + + ); + expect(buildVersionSelectItems).toHaveBeenCalledWith( + detail.manifest.versions, + '2.0.0', + '1.0.0', + false + ); + }); + + it('passes installedVersionIsPrerelease=true to buildVersionSelectItems', () => { + render( + + ); + expect(buildVersionSelectItems).toHaveBeenCalledWith( + expect.any(Array), + expect.anything(), + '2.0.0-beta', + true + ); + }); + + it('passes null installedVersion to buildVersionSelectItems when not installed', () => { + render( + + ); + expect(buildVersionSelectItems).toHaveBeenCalledWith( + expect.any(Array), + expect.anything(), + null, + false + ); + }); + + it('does not call buildVersionSelectItems when versions list is empty', () => { + render( + + ); + expect(buildVersionSelectItems).not.toHaveBeenCalled(); + }); + + it('renders a disabled ghost option when buildVersionSelectItems returns one', () => { + vi.mocked(buildVersionSelectItems).mockReturnValue([ + { value: '2.0.0', label: 'v2.0.0 (latest)', disabled: false }, + { value: '1.5.0', label: 'v1.5.0 (installed)', disabled: true }, + { value: '1.0.0', label: 'v1.0.0', disabled: false }, + ]); + render( + + ); + const ghostOption = screen + .getAllByRole('option') + .find((o) => o.value === '1.5.0'); + expect(ghostOption).toBeTruthy(); + expect(ghostOption.disabled).toBe(true); + }); + + it('calls onVersionChange when version select changes', () => { + const onVersionChange = vi.fn(); + render(); + fireEvent.change(screen.getByTestId('version-select'), { + target: { value: '1.0.0' }, + }); + expect(onVersionChange).toHaveBeenCalledWith('1.0.0'); + }); + }); + + // ── Install button ───────────────────────────────────────────────────────── + + describe('install button', () => { + it('shows "Install" button when plugin is not installed', () => { + render(); + expect(screen.getByTestId('button')).toHaveTextContent('Install'); + }); + + it('calls onInstall with correct params when Install is clicked', () => { + const onInstall = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('button')); + expect(onInstall).toHaveBeenCalledWith( + expect.objectContaining({ + slug: 'my-plugin', + version: '2.0.0', + download_url: 'https://example.com/plugin-2.0.0.zip', + }) + ); + }); + + it('shows installing spinner while installing is true', () => { + render(); + expect(screen.getByTestId('button')).toHaveTextContent('Installing…'); + expect(screen.getByTestId('loader')).toBeInTheDocument(); + }); + + it('shows "Update" button when a newer version is selected over installed', () => { + vi.mocked(compareVersions).mockImplementation((a, b) => { + const strip = (v) => v.replace(/^v/, ''); + const pa = strip(a).split('.').map(Number); + const pb = strip(b).split('.').map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] || 0) - (pb[i] || 0); + if (d !== 0) return d; + } + return 0; + }); + render( + + ); + expect(screen.getByTestId('button')).toHaveTextContent('Update'); + }); + + it('shows "Downgrade" button when an older version is selected over installed', () => { + vi.mocked(compareVersions).mockImplementation((a, b) => { + const strip = (v) => v.replace(/^v/, ''); + const pa = strip(a).split('.').map(Number); + const pb = strip(b).split('.').map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] || 0) - (pb[i] || 0); + if (d !== 0) return d; + } + return 0; + }); + render( + + ); + expect(screen.getByTestId('button')).toHaveTextContent('Downgrade'); + }); + + it('shows "Uninstall" button when installed version equals selected version', () => { + vi.mocked(compareVersions).mockReturnValue(0); + render( + + ); + expect(screen.getByTestId('button')).toHaveTextContent('Uninstall'); + }); + + it('calls onUninstall when Uninstall is clicked', () => { + vi.mocked(compareVersions).mockReturnValue(0); + const onUninstall = vi.fn(); + render( + + ); + fireEvent.click(screen.getByTestId('button')); + expect(onUninstall).toHaveBeenCalled(); + }); + + it('shows "Uninstalling…" and loader while uninstalling', () => { + vi.mocked(compareVersions).mockReturnValue(0); + render( + + ); + expect(screen.getByTestId('button')).toHaveTextContent('Uninstalling…'); + }); + + it('shows "Overwrite" for unmanaged install status', () => { + render( + + ); + expect(screen.getByTestId('button')).toHaveTextContent('Overwrite'); + }); + + it('shows "Overwrite" for different_repo install status', () => { + render( + + ); + expect(screen.getByTestId('button')).toHaveTextContent('Overwrite'); + }); + + it('disables install button when no selectedVersionData url', () => { + const detail = makeDetail({ + versions: [makeVersion('2.0.0', { url: null })], + latest: { version: '2.0.0' }, + }); + render(); + expect(screen.getByTestId('button')).toBeDisabled(); + }); + + it('shows "Incompatible" when version does not meet min requirement', () => { + // selMeetsMin=false: appVersion < min_dispatcharr_version + vi.mocked(compareVersions).mockImplementation((a, b) => { + // appVersion (1.5.0) vs min (2.0.0): return negative + if (a === '1.5.0' && b === '2.0.0') return -1; + return 0; + }); + const detail = makeDetail({ + versions: [makeVersion('2.0.0', { min_dispatcharr_version: '2.0.0' })], + latest: { version: '2.0.0' }, + }); + render( + + ); + expect(screen.getByTestId('button')).toHaveTextContent('Incompatible'); + }); + }); + + // ── Compatibility warning ────────────────────────────────────────────────── + + describe('compatibility warning', () => { + it('shows compatibility warning tooltip when version is incompatible and not same as installed', () => { + vi.mocked(compareVersions).mockImplementation((a, b) => { + if (a === '1.5.0' && b === '2.0.0') return -1; + return 0; + }); + vi.mocked(buildCompatibilityTooltip).mockReturnValue('2.0.0 or newer'); + const detail = makeDetail({ + versions: [makeVersion('2.0.0', { min_dispatcharr_version: '2.0.0' })], + latest: { version: '2.0.0' }, + }); + render( + + ); + const tooltip = screen + .getAllByRole('generic') + .find((el) => + el.getAttribute('data-tooltip')?.includes('Incompatible') + ); + expect(tooltip).toBeTruthy(); + }); + }); + + // ── Version detail table ─────────────────────────────────────────────────── + + describe('version detail table', () => { + it('renders build timestamp', () => { + render(); + // The date is locale-formatted; just check the "Built" label exists + expect(screen.getByText('Built')).toBeInTheDocument(); + }); + + it('renders file size via formatKB', () => { + render(); + expect(screen.getByText('File Size')).toBeInTheDocument(); + expect(screen.getByText('1024 KB')).toBeInTheDocument(); + }); + + it('does not render file size row when size is 0', () => { + const detail = makeDetail({ + versions: [makeVersion('2.0.0', { size: 0 })], + latest: { version: '2.0.0' }, + }); + render(); + expect(screen.queryByText('File Size')).not.toBeInTheDocument(); + }); + + it('renders min version row when present', () => { + const detail = makeDetail({ + versions: [makeVersion('2.0.0', { min_dispatcharr_version: '1.0.0' })], + latest: { version: '2.0.0' }, + }); + render(); + expect(screen.getByText('Min Version')).toBeInTheDocument(); + expect(screen.getByText('1.0.0')).toBeInTheDocument(); + }); + + it('does not render min version row when absent', () => { + render(); + expect(screen.queryByText('Min Version')).not.toBeInTheDocument(); + }); + + it('renders max version row when present', () => { + const detail = makeDetail({ + versions: [makeVersion('2.0.0', { max_dispatcharr_version: '3.0.0' })], + latest: { version: '2.0.0' }, + }); + render(); + expect(screen.getByText('Max Version')).toBeInTheDocument(); + expect(screen.getByText('3.0.0')).toBeInTheDocument(); + }); + + it('renders commit short SHA', () => { + render(); + expect(screen.getByText('Commit')).toBeInTheDocument(); + expect(screen.getByText('abc')).toBeInTheDocument(); + }); + + it('renders commit as a link when registry_url is present', () => { + const detail = makeDetail({ + registry_url: 'https://github.com/example/plugin', + versions: [makeVersion('2.0.0', { commit_sha: 'abc123full' })], + latest: { version: '2.0.0' }, + }); + render(); + const commitLink = screen + .getAllByTestId('text-link') + .find((el) => el.href?.includes('abc123full')); + expect(commitLink).toBeTruthy(); + }); + + it('renders download URL as a link', () => { + render(); + expect(screen.getByText('Download')).toBeInTheDocument(); + const link = screen + .getAllByTestId('text-link') + .find((el) => el.href?.includes('plugin-2.0.0.zip')); + expect(link).toBeTruthy(); + }); + }); + + // ── buildVersionSelectItems integration ─────────────────────────────────── + + describe('buildVersionSelectItems integration', () => { + it('passes manifest.latest.version to buildVersionSelectItems', () => { + const detail = makeDetail({ latest: { version: '2.0.0' } }); + render(); + expect(buildVersionSelectItems).toHaveBeenCalledWith( + detail.manifest.versions, + '2.0.0', + null, // installedVersion from defaultProps + false // installedVersionIsPrerelease from defaultProps + ); + }); + + it('passes undefined latest to buildVersionSelectItems when manifest.latest is absent', () => { + const detail = makeDetail({ latest: null }); + render(); + expect(buildVersionSelectItems).toHaveBeenCalledWith( + detail.manifest.versions, + undefined, + null, // installedVersion from defaultProps + false // installedVersionIsPrerelease from defaultProps + ); + }); + + it('Select data reflects exactly what buildVersionSelectItems returns', () => { + const customItems = [ + { value: '3.0.0', label: 'v3.0.0 (latest)', disabled: false }, + { value: '2.0.0', label: 'v2.0.0 (installed)', disabled: false }, + ]; + vi.mocked(buildVersionSelectItems).mockReturnValue(customItems); + render( + + ); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(2); + expect(options[0].value).toBe('3.0.0'); + expect(options[1].value).toBe('2.0.0'); + }); + }); +}); diff --git a/frontend/src/components/__tests__/PluginWarnings.test.jsx b/frontend/src/components/__tests__/PluginWarnings.test.jsx new file mode 100644 index 00000000..dd454419 --- /dev/null +++ b/frontend/src/components/__tests__/PluginWarnings.test.jsx @@ -0,0 +1,266 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { + PluginSecurityWarning, + PluginSupportDisclaimer, + PluginDowngradeWarning, + PluginInfoNote, + PluginRestartWarning, +} from '../PluginWarnings'; + +// ── Image mock ───────────────────────────────────────────────────────────────── +vi.mock('../../images/logo.png', () => ({ default: 'mocked-logo.png' })); + +// ── lucide-react mock ────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + AlertTriangle: ({ size }) => ( + + ), + Info: ({ size }) => , + OctagonAlert: ({ size }) => ( + + ), +})); + +// ── Mantine core mock ────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Box: ({ children, style }) =>
{children}
, + Text: ({ children, size, style }) => ( +

+ {children} +

+ ), +})); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('PluginWarnings', () => { + // ── PluginSecurityWarning ──────────────────────────────────────────────────── + + describe('PluginSecurityWarning', () => { + it('renders children text', () => { + render( + This plugin is dangerous + ); + expect(screen.getByText('This plugin is dangerous')).toBeInTheDocument(); + }); + + it('renders the OctagonAlert icon', () => { + render(Warning); + expect(screen.getByTestId('icon-octagon-alert')).toBeInTheDocument(); + }); + + it('does not render AlertTriangle or Info icons', () => { + render(Warning); + expect( + screen.queryByTestId('icon-alert-triangle') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('icon-info')).not.toBeInTheDocument(); + }); + + it('renders different children correctly', () => { + render( + + Critical security issue + + ); + expect(screen.getByText('Critical')).toBeInTheDocument(); + }); + }); + + // ── PluginSupportDisclaimer ────────────────────────────────────────────────── + + describe('PluginSupportDisclaimer', () => { + it('renders the disclaimer text', () => { + render(); + expect( + screen.getByText( + /Dispatcharr community support cannot assist with third-party plugin issues/i + ) + ).toBeInTheDocument(); + }); + + it('mentions plugin Discord thread', () => { + render(); + expect( + screen.getByText(/use the plugin.*Discord thread/i) + ).toBeInTheDocument(); + }); + + it('mentions submitting an issue on the plugin repository', () => { + render(); + expect( + screen.getByText(/submit an issue.*on the plugin.*repository/i) + ).toBeInTheDocument(); + }); + + it('renders the Dispatcharr logo image', () => { + render(); + const logo = screen.getByAltText('Dispatcharr'); + expect(logo).toBeInTheDocument(); + expect(logo).toHaveAttribute('src', 'mocked-logo.png'); + }); + + it('renders logo as non-draggable', () => { + render(); + const logo = screen.getByAltText('Dispatcharr'); + expect(logo).toHaveAttribute('draggable', 'false'); + }); + + it('does not render any lucide icons', () => { + render(); + expect( + screen.queryByTestId('icon-octagon-alert') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('icon-alert-triangle') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('icon-info')).not.toBeInTheDocument(); + }); + }); + + // ── PluginDowngradeWarning ─────────────────────────────────────────────────── + + describe('PluginDowngradeWarning', () => { + it('renders children text', () => { + render( + + Downgrading may break things + + ); + expect( + screen.getByText('Downgrading may break things') + ).toBeInTheDocument(); + }); + + it('renders the AlertTriangle icon', () => { + render(Caution); + expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument(); + }); + + it('does not render OctagonAlert or Info icons', () => { + render(Caution); + expect( + screen.queryByTestId('icon-octagon-alert') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('icon-info')).not.toBeInTheDocument(); + }); + + it('renders JSX children correctly', () => { + render( + + Inner content + + ); + expect(screen.getByTestId('inner')).toBeInTheDocument(); + }); + }); + + // ── PluginInfoNote ─────────────────────────────────────────────────────────── + + describe('PluginInfoNote', () => { + it('renders children text', () => { + render(This is an informational note.); + expect( + screen.getByText('This is an informational note.') + ).toBeInTheDocument(); + }); + + it('renders the Info icon', () => { + render(Note); + expect(screen.getByTestId('icon-info')).toBeInTheDocument(); + }); + + it('does not render OctagonAlert or AlertTriangle icons', () => { + render(Note); + expect( + screen.queryByTestId('icon-octagon-alert') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('icon-alert-triangle') + ).not.toBeInTheDocument(); + }); + + it('renders JSX children correctly', () => { + render( + + details + + ); + expect(screen.getByTestId('info-child')).toBeInTheDocument(); + }); + }); + + // ── PluginRestartWarning ───────────────────────────────────────────────────── + + describe('PluginRestartWarning', () => { + it('renders the restart warning text', () => { + render(); + expect( + screen.getByText(/Importing a plugin may briefly restart the backend/i) + ).toBeInTheDocument(); + }); + + it('mentions temporary disconnect', () => { + render(); + expect( + screen.getByText(/you might see a.*temporary disconnect/i) + ).toBeInTheDocument(); + }); + + it('mentions automatic reconnect', () => { + render(); + expect( + screen.getByText(/the app will.*reconnect automatically/i) + ).toBeInTheDocument(); + }); + + it('renders the AlertTriangle icon', () => { + render(); + expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument(); + }); + + it('does not render OctagonAlert or Info icons', () => { + render(); + expect( + screen.queryByTestId('icon-octagon-alert') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('icon-info')).not.toBeInTheDocument(); + }); + }); + + // ── Shared layout structure ────────────────────────────────────────────────── + + describe('shared layout structure', () => { + it('PluginSecurityWarning renders xs Text', () => { + render(msg); + expect(screen.getByText('msg')).toHaveAttribute('data-size', 'xs'); + }); + + it('PluginSupportDisclaimer renders xs Text', () => { + render(); + const text = screen.getByText( + /Dispatcharr community support cannot assist/i + ); + expect(text).toHaveAttribute('data-size', 'xs'); + }); + + it('PluginDowngradeWarning renders xs Text', () => { + render(msg); + expect(screen.getByText('msg')).toHaveAttribute('data-size', 'xs'); + }); + + it('PluginInfoNote renders xs Text', () => { + render(msg); + expect(screen.getByText('msg')).toHaveAttribute('data-size', 'xs'); + }); + + it('PluginRestartWarning renders xs Text', () => { + render(); + expect(screen.getByText(/Importing a plugin/i)).toHaveAttribute( + 'data-size', + 'xs' + ); + }); + }); +}); diff --git a/frontend/src/components/backups/__tests__/BackupManager.test.jsx b/frontend/src/components/backups/__tests__/BackupManager.test.jsx new file mode 100644 index 00000000..4fe1820c --- /dev/null +++ b/frontend/src/components/backups/__tests__/BackupManager.test.jsx @@ -0,0 +1,1117 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import BackupManager from '../BackupManager'; + +// ── BackupManagerUtils ───────────────────────────────────────────────────────── +vi.mock('../../../utils/components/backups/BackupManagerUtils.js', () => ({ + listBackups: vi.fn(), + getBackupSchedule: vi.fn(), + updateBackupSchedule: vi.fn(), + createBackup: vi.fn(), + uploadBackup: vi.fn(), + downloadBackup: vi.fn(), + restoreBackup: vi.fn(), + deleteBackup: vi.fn(), + to12Hour: vi.fn(), + to24Hour: vi.fn(), + DAYS_OF_WEEK: [ + { value: '0', label: 'Sunday' }, + { value: '1', label: 'Monday' }, + { value: '2', label: 'Tuesday' }, + { value: '3', label: 'Wednesday' }, + { value: '4', label: 'Thursday' }, + { value: '5', label: 'Friday' }, + { value: '6', label: 'Saturday' }, + ], +})); + +// ── hooks ────────────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => ['UTC', vi.fn()]), +})); + +// ── store ────────────────────────────────────────────────────────────────────── +vi.mock('../../../store/warnings', () => ({ + default: vi.fn((sel) => sel({ suppressWarning: vi.fn() })), +})); + +// ── dateTimeUtils ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/dateTimeUtils.js', () => ({ + format: vi.fn(() => '01/01/2024, 10:00:00 AM'), + getDefaultTimeZone: vi.fn(() => 'UTC'), + useDateTimeFormat: vi.fn(() => ({ + fullDateTimeFormat: 'MM/DD/YYYY, HH:mm:ss', + timeFormatSetting: '24h', + })), +})); + +// ── utility functions ────────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); +vi.mock('../../../utils/networkUtils.js', () => ({ + formatBytes: vi.fn((bytes) => `${bytes} B`), +})); +vi.mock('../../../utils/cronUtils', () => ({ + validateCronExpression: vi.fn(() => ({ valid: true })), +})); + +// ── CustomTable ──────────────────────────────────────────────────────────────── +vi.mock('../../tables/CustomTable', () => ({ + CustomTable: ({ table }) => ( +
+ {table.__rows?.map((row, i) => ( +
+ {table.__bodyCellRenderFns?.actions?.({ + cell: { column: { id: 'actions' } }, + row, + })} +
+ ))} +
+ ), + useTable: vi.fn(({ data, bodyCellRenderFns }) => ({ + __rows: (data ?? []).map((item) => ({ original: item })), + __bodyCellRenderFns: bodyCellRenderFns, + })), +})); + +// ── ScheduleInput ────────────────────────────────────────────────────────────── +vi.mock('../../forms/ScheduleInput', () => ({ + default: ({ + children, + scheduleType, + onScheduleTypeChange, + cronValue, + onCronChange, + disabled, + }) => ( +
+ {scheduleType === 'cron' ? ( + <> + + + + ) : ( + <> + {children} + {!disabled && ( + + )} + + )} +
+ ), +})); + +// ── ConfirmationDialog ───────────────────────────────────────────────────────── +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ + opened, + onClose, + onConfirm, + title, + message, + confirmLabel, + cancelLabel, + loading, + }) => + opened ? ( +
+
{title}
+
{message}
+ + +
+ ) : null, +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + Download: () => , + RefreshCcw: () => , + RotateCcw: () => , + SquareMinus: () => , + SquarePlus: () => , + UploadCloud: () => , +})); + +// ── @mantine/core ────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, color, loading, disabled }) => ( + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, disabled, loading, color, variant }) => ( + + ), + FileInput: ({ onChange, label, accept }) => ( + + ), + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Loader: ({ size }) =>
, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + NumberInput: ({ value, onChange, label, description, min, disabled }) => ( + + ), + Paper: ({ children }) =>
{children}
, + Select: ({ value, onChange, label, data, disabled }) => ( + + ), + Stack: ({ children }) =>
{children}
, + Switch: ({ checked, onChange, label, disabled }) => ( + + ), + Text: ({ children, size, c, style }) => ( + + {children} + + ), + Tooltip: ({ children, label }) =>
{children}
, +})); + +// ── imports after mocks ──────────────────────────────────────────────────────── +import { + listBackups, + getBackupSchedule, + updateBackupSchedule, + createBackup, + uploadBackup, + downloadBackup, + restoreBackup, + deleteBackup, + to12Hour, + to24Hour, +} from '../../../utils/components/backups/BackupManagerUtils.js'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import { useDateTimeFormat } from '../../../utils/dateTimeUtils.js'; + +// ── fixtures ─────────────────────────────────────────────────────────────────── +const defaultSchedule = { + enabled: true, + frequency: 'daily', + time: '03:00', + day_of_week: 0, + retention_count: 5, + cron_expression: '', +}; + +const defaultBackups = [ + { + name: 'backup-2024-01-01.zip', + size: 1024000, + created: '2024-01-01T10:00:00Z', + }, + { + name: 'backup-2024-01-02.zip', + size: 2048000, + created: '2024-01-02T10:00:00Z', + }, +]; + +const setupMocks = ({ schedule = defaultSchedule, backups = [] } = {}) => { + vi.mocked(listBackups).mockResolvedValue(backups); + vi.mocked(getBackupSchedule).mockResolvedValue(schedule); + vi.mocked(updateBackupSchedule).mockResolvedValue({ ...schedule }); + vi.mocked(createBackup).mockResolvedValue({}); + vi.mocked(uploadBackup).mockResolvedValue({}); + vi.mocked(downloadBackup).mockResolvedValue({}); + vi.mocked(restoreBackup).mockResolvedValue({}); + vi.mocked(deleteBackup).mockResolvedValue({}); + vi.mocked(to12Hour).mockReturnValue({ time: '3:00', period: 'AM' }); + vi.mocked(to24Hour).mockReturnValue('03:00'); +}; + +/** + * Render BackupManager and wait for both initial API calls to settle. + * `to12Hour` is called inside `loadSchedule` after `getBackupSchedule` resolves, + * so its first invocation is a reliable "initial load complete" indicator. + */ +const renderAndLoad = async (opts = {}) => { + setupMocks(opts); + render(); + await waitFor(() => expect(vi.mocked(to12Hour)).toHaveBeenCalled()); +}; + +// ───────────────────────────────────────────────────────────────────────────── + +describe('BackupManager', () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: { reload: vi.fn() }, + }); + }); + + // ── Initial render and data loading ──────────────────────────────────────── + + describe('initial data loading', () => { + it('renders "Scheduled Backups" heading', async () => { + await renderAndLoad(); + expect(screen.getByText('Scheduled Backups')).toBeInTheDocument(); + }); + + it('calls listBackups on mount', async () => { + await renderAndLoad(); + expect(vi.mocked(listBackups)).toHaveBeenCalledTimes(1); + }); + + it('calls getBackupSchedule on mount', async () => { + await renderAndLoad(); + expect(vi.mocked(getBackupSchedule)).toHaveBeenCalledTimes(1); + }); + + it('calls to12Hour with the loaded schedule time', async () => { + await renderAndLoad(); + expect(vi.mocked(to12Hour)).toHaveBeenCalledWith(defaultSchedule.time); + }); + + it('sets scheduleType to "cron" when loaded schedule has a cron_expression', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, cron_expression: '0 3 * * *' }, + }); + expect(screen.getByTestId('cron-input')).toBeInTheDocument(); + }); + + it('sets scheduleType to "interval" when loaded schedule has no cron_expression', async () => { + await renderAndLoad(); + expect(screen.getByTestId('switch-to-cron')).toBeInTheDocument(); + }); + + it('handles getBackupSchedule failure silently without showing a notification', async () => { + vi.mocked(getBackupSchedule).mockRejectedValue( + new Error('Network error') + ); + vi.mocked(listBackups).mockResolvedValue([]); + vi.mocked(to12Hour).mockReturnValue({ time: '3:00', period: 'AM' }); + render(); + await waitFor(() => expect(vi.mocked(listBackups)).toHaveBeenCalled()); + expect(vi.mocked(showNotification)).not.toHaveBeenCalled(); + }); + + it('shows an error notification when listBackups fails', async () => { + vi.mocked(listBackups).mockRejectedValue(new Error('Failed')); + vi.mocked(getBackupSchedule).mockResolvedValue(defaultSchedule); + vi.mocked(to12Hour).mockReturnValue({ time: '3:00', period: 'AM' }); + render(); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ) + ); + }); + }); + + // ── Backup list display ───────────────────────────────────────────────────── + + describe('backup list display', () => { + it('shows "No backups found" when list is empty', async () => { + await renderAndLoad({ backups: [] }); + expect(screen.getByText(/No backups found/)).toBeInTheDocument(); + }); + + it('renders CustomTable when backups are present', async () => { + await renderAndLoad({ backups: defaultBackups }); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('renders one table row per backup', async () => { + await renderAndLoad({ backups: defaultBackups }); + expect(screen.getAllByTestId('table-row')).toHaveLength( + defaultBackups.length + ); + }); + + it('does not render CustomTable when list is empty', async () => { + await renderAndLoad({ backups: [] }); + expect(screen.queryByTestId('custom-table')).not.toBeInTheDocument(); + }); + }); + + // ── Create backup ─────────────────────────────────────────────────────────── + + describe('create backup', () => { + it('calls createBackup when "Create Backup" button is clicked', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByText('Create Backup')); + await waitFor(() => + expect(vi.mocked(createBackup)).toHaveBeenCalledTimes(1) + ); + }); + + it('shows success notification after creating backup', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByText('Create Backup')); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Success', color: 'green' }) + ) + ); + }); + + it('refreshes backup list after creating backup', async () => { + await renderAndLoad(); + vi.mocked(listBackups).mockClear(); + fireEvent.click(screen.getByText('Create Backup')); + await waitFor(() => + expect(vi.mocked(listBackups)).toHaveBeenCalledTimes(1) + ); + }); + + it('shows error notification when createBackup fails', async () => { + await renderAndLoad(); + vi.mocked(createBackup).mockRejectedValue(new Error('Server error')); + fireEvent.click(screen.getByText('Create Backup')); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ) + ); + }); + }); + + // ── Refresh ───────────────────────────────────────────────────────────────── + + describe('refresh', () => { + it('calls listBackups again when Refresh is clicked', async () => { + await renderAndLoad(); + vi.mocked(listBackups).mockClear(); + fireEvent.click(screen.getByText('Refresh')); + await waitFor(() => + expect(vi.mocked(listBackups)).toHaveBeenCalledTimes(1) + ); + }); + }); + + // ── Download backup ───────────────────────────────────────────────────────── + + describe('download backup', () => { + it('calls downloadBackup with the correct filename', async () => { + await renderAndLoad({ backups: defaultBackups }); + const downloadBtn = screen + .getAllByTestId('icon-download')[0] + .closest('button'); + fireEvent.click(downloadBtn); + await waitFor(() => + expect(vi.mocked(downloadBackup)).toHaveBeenCalledWith( + defaultBackups[0].name + ) + ); + }); + + it('shows "Download Started" notification', async () => { + await renderAndLoad({ backups: defaultBackups }); + const downloadBtn = screen + .getAllByTestId('icon-download')[0] + .closest('button'); + fireEvent.click(downloadBtn); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Download Started', color: 'blue' }) + ) + ); + }); + + it('shows error notification when downloadBackup fails', async () => { + await renderAndLoad({ backups: defaultBackups }); + vi.mocked(downloadBackup).mockRejectedValue(new Error('Network error')); + const downloadBtn = screen + .getAllByTestId('icon-download')[0] + .closest('button'); + fireEvent.click(downloadBtn); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ) + ); + }); + }); + + // ── Delete backup ─────────────────────────────────────────────────────────── + + describe('delete backup', () => { + const openDeleteDialog = async () => { + await renderAndLoad({ backups: defaultBackups }); + const deleteBtn = screen + .getAllByTestId('icon-delete')[0] + .closest('button'); + fireEvent.click(deleteBtn); + }; + + it('opens delete ConfirmationDialog when delete action is clicked', async () => { + await openDeleteDialog(); + expect(screen.getByTestId('dialog-title')).toHaveTextContent( + 'Delete Backup' + ); + }); + + it('shows the backup filename in the delete dialog message', async () => { + await openDeleteDialog(); + expect(screen.getByTestId('dialog-message')).toHaveTextContent( + defaultBackups[0].name + ); + }); + + it('calls deleteBackup with the filename when confirmed', async () => { + await openDeleteDialog(); + fireEvent.click(screen.getByTestId('dialog-confirm')); + await waitFor(() => + expect(vi.mocked(deleteBackup)).toHaveBeenCalledWith( + defaultBackups[0].name + ) + ); + }); + + it('refreshes backup list after deletion', async () => { + await openDeleteDialog(); + vi.mocked(listBackups).mockClear(); + fireEvent.click(screen.getByTestId('dialog-confirm')); + await waitFor(() => + expect(vi.mocked(listBackups)).toHaveBeenCalledTimes(1) + ); + }); + + it('closes dialog after confirming deletion', async () => { + await openDeleteDialog(); + fireEvent.click(screen.getByTestId('dialog-confirm')); + await waitFor(() => + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument() + ); + }); + + it('closes dialog when Cancel is clicked', async () => { + await openDeleteDialog(); + fireEvent.click(screen.getByTestId('dialog-cancel')); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + + it('shows error notification when deleteBackup fails', async () => { + await openDeleteDialog(); + vi.mocked(deleteBackup).mockRejectedValue(new Error('Server error')); + fireEvent.click(screen.getByTestId('dialog-confirm')); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ) + ); + }); + }); + + // ── Restore backup ────────────────────────────────────────────────────────── + + describe('restore backup', () => { + const openRestoreDialog = async () => { + await renderAndLoad({ backups: defaultBackups }); + const restoreBtn = screen + .getAllByTestId('icon-restore')[0] + .closest('button'); + fireEvent.click(restoreBtn); + }; + + it('opens restore ConfirmationDialog when restore action is clicked', async () => { + await openRestoreDialog(); + expect(screen.getByTestId('dialog-title')).toHaveTextContent( + 'Restore Backup' + ); + }); + + it('shows the backup filename in the restore dialog message', async () => { + await openRestoreDialog(); + expect(screen.getByTestId('dialog-message')).toHaveTextContent( + defaultBackups[0].name + ); + }); + + it('calls restoreBackup with the filename when confirmed', async () => { + await openRestoreDialog(); + fireEvent.click(screen.getByTestId('dialog-confirm')); + await waitFor(() => + expect(vi.mocked(restoreBackup)).toHaveBeenCalledWith( + defaultBackups[0].name + ) + ); + }); + + it('shows "Restore Complete" notification after successful restore', async () => { + await openRestoreDialog(); + fireEvent.click(screen.getByTestId('dialog-confirm')); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Restore Complete', color: 'green' }) + ) + ); + }); + + it('schedules window.location.reload 4 seconds after restore', async () => { + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + await openRestoreDialog(); + fireEvent.click(screen.getByTestId('dialog-confirm')); + await waitFor(() => expect(vi.mocked(restoreBackup)).toHaveBeenCalled()); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 4000); + setTimeoutSpy.mockRestore(); + }); + + it('closes dialog when Cancel is clicked', async () => { + await openRestoreDialog(); + fireEvent.click(screen.getByTestId('dialog-cancel')); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + + it('shows error notification when restoreBackup fails', async () => { + await openRestoreDialog(); + vi.mocked(restoreBackup).mockRejectedValue(new Error('Restore failed')); + fireEvent.click(screen.getByTestId('dialog-confirm')); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ) + ); + }); + }); + + // ── Upload backup ─────────────────────────────────────────────────────────── + + describe('upload backup', () => { + it('opens upload modal when Upload button is clicked', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByText('Upload')); + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Upload Backup' + ); + }); + + it('closes upload modal when × is clicked', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByText('Upload')); + fireEvent.click(screen.getByTestId('modal-close')); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('closes upload modal when Cancel is clicked', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByText('Upload')); + fireEvent.click(screen.getByText('Cancel')); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('submit Upload button is disabled when no file is selected', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByText('Upload')); + // First "Upload" opens the modal; second is the submit button inside the modal + const submitBtn = screen.getAllByText('Upload')[1]; + expect(submitBtn).toBeDisabled(); + }); + + it('calls uploadBackup with the selected file when submitted', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByText('Upload')); + + const file = new File(['backup data'], 'backup.zip', { + type: 'application/zip', + }); + const fileInput = screen.getByTestId('file-input'); + Object.defineProperty(fileInput, 'files', { + value: [file], + configurable: true, + }); + fireEvent.change(fileInput); + + fireEvent.click(screen.getAllByText('Upload')[1]); + await waitFor(() => + expect(vi.mocked(uploadBackup)).toHaveBeenCalledWith(file) + ); + }); + + it('closes modal and refreshes list after successful upload', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByText('Upload')); + + const file = new File(['data'], 'backup.zip', { + type: 'application/zip', + }); + const fileInput = screen.getByTestId('file-input'); + Object.defineProperty(fileInput, 'files', { + value: [file], + configurable: true, + }); + fireEvent.change(fileInput); + + vi.mocked(listBackups).mockClear(); + fireEvent.click(screen.getAllByText('Upload')[1]); + + await waitFor(() => { + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + expect(vi.mocked(listBackups)).toHaveBeenCalledTimes(1); + }); + }); + + it('shows success notification after upload', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByText('Upload')); + + const file = new File(['data'], 'backup.zip', { + type: 'application/zip', + }); + const fileInput = screen.getByTestId('file-input'); + Object.defineProperty(fileInput, 'files', { + value: [file], + configurable: true, + }); + fireEvent.change(fileInput); + fireEvent.click(screen.getAllByText('Upload')[1]); + + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Success', color: 'green' }) + ) + ); + }); + + it('shows error notification when uploadBackup fails', async () => { + await renderAndLoad(); + vi.mocked(uploadBackup).mockRejectedValue(new Error('Upload failed')); + fireEvent.click(screen.getByText('Upload')); + + const file = new File(['data'], 'backup.zip', { + type: 'application/zip', + }); + const fileInput = screen.getByTestId('file-input'); + Object.defineProperty(fileInput, 'files', { + value: [file], + configurable: true, + }); + fireEvent.change(fileInput); + fireEvent.click(screen.getAllByText('Upload')[1]); + + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ) + ); + }); + }); + + // ── Schedule saving ───────────────────────────────────────────────────────── + + describe('schedule saving', () => { + it('Save button is disabled before any schedule field changes', async () => { + await renderAndLoad(); + expect(screen.getByText('Save')).toBeDisabled(); + }); + + it('Save button becomes enabled after a schedule field changes', async () => { + await renderAndLoad(); + fireEvent.change(screen.getByLabelText('Frequency'), { + target: { value: 'weekly' }, + }); + expect(screen.getByText('Save')).not.toBeDisabled(); + }); + + it('calls updateBackupSchedule when Save is clicked', async () => { + await renderAndLoad(); + fireEvent.change(screen.getByLabelText('Frequency'), { + target: { value: 'weekly' }, + }); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => + expect(vi.mocked(updateBackupSchedule)).toHaveBeenCalledTimes(1) + ); + }); + + it('sends schedule with an empty cron_expression in interval mode', async () => { + await renderAndLoad(); + fireEvent.change(screen.getByLabelText('Frequency'), { + target: { value: 'weekly' }, + }); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => + expect(vi.mocked(updateBackupSchedule)).toHaveBeenCalledWith( + expect.objectContaining({ cron_expression: '' }) + ) + ); + }); + + it('sends schedule with cron_expression intact in cron mode', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, cron_expression: '0 3 * * *' }, + }); + fireEvent.change(screen.getByTestId('cron-input'), { + target: { value: '0 4 * * *' }, + }); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => + expect(vi.mocked(updateBackupSchedule)).toHaveBeenCalledWith( + expect.objectContaining({ cron_expression: '0 4 * * *' }) + ) + ); + }); + + it('shows success notification after saving schedule', async () => { + await renderAndLoad(); + fireEvent.change(screen.getByLabelText('Frequency'), { + target: { value: 'weekly' }, + }); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Success', + message: 'Backup schedule saved', + }) + ) + ); + }); + + it('shows error notification when saving schedule fails', async () => { + await renderAndLoad(); + vi.mocked(updateBackupSchedule).mockRejectedValue( + new Error('Save failed') + ); + fireEvent.change(screen.getByLabelText('Frequency'), { + target: { value: 'weekly' }, + }); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ) + ); + }); + + it('Save button is disabled again after a successful save', async () => { + await renderAndLoad(); + fireEvent.change(screen.getByLabelText('Frequency'), { + target: { value: 'weekly' }, + }); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => + expect(vi.mocked(updateBackupSchedule)).toHaveBeenCalled() + ); + await waitFor(() => expect(screen.getByText('Save')).toBeDisabled()); + }); + }); + + // ── Schedule enabled switch ───────────────────────────────────────────────── + + describe('schedule enabled switch', () => { + it('shows "Enabled" label when schedule.enabled is true', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, enabled: true }, + }); + expect(screen.getByText('Enabled')).toBeInTheDocument(); + }); + + it('shows "Disabled" label when schedule.enabled is false', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, enabled: false }, + }); + expect(screen.getByText('Disabled')).toBeInTheDocument(); + }); + + it('toggles label from "Enabled" to "Disabled" when switch is unchecked', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, enabled: true }, + }); + fireEvent.click(screen.getByTestId('schedule-switch')); + await waitFor(() => + expect(screen.getByText('Disabled')).toBeInTheDocument() + ); + }); + + it('toggles label from "Disabled" to "Enabled" when switch is checked', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, enabled: false }, + }); + fireEvent.click(screen.getByTestId('schedule-switch')); + await waitFor(() => + expect(screen.getByText('Enabled')).toBeInTheDocument() + ); + }); + }); + + // ── 12-hour vs 24-hour time display ───────────────────────────────────────── + + describe('time format display', () => { + it('shows Hour, Minute, and Period selects in 12h mode', async () => { + vi.mocked(useDateTimeFormat).mockReturnValue({ + fullDateTimeFormat: 'MM/DD/YYYY, h:mm:ss A', + timeFormatSetting: '12h', + }); + setupMocks(); + render(); + await waitFor(() => expect(vi.mocked(to12Hour)).toHaveBeenCalled()); + + expect(screen.getByLabelText('Hour')).toBeInTheDocument(); + expect(screen.getByLabelText('Minute')).toBeInTheDocument(); + expect(screen.getByLabelText('Period')).toBeInTheDocument(); + }); + + it('does not show a Period select in 24h mode', async () => { + vi.mocked(useDateTimeFormat).mockReturnValue({ + fullDateTimeFormat: 'MM/DD/YYYY, HH:mm:ss', + timeFormatSetting: '24h', + }); + await renderAndLoad(); + expect(screen.queryByLabelText('Period')).not.toBeInTheDocument(); + }); + + it('Hour select in 24h mode contains 24 options (00–23)', async () => { + vi.mocked(useDateTimeFormat).mockReturnValue({ + fullDateTimeFormat: 'MM/DD/YYYY, HH:mm:ss', + timeFormatSetting: '24h', + }); + await renderAndLoad(); + const hourSelect = screen.getByLabelText('Hour'); + expect(hourSelect.querySelectorAll('option')).toHaveLength(24); + }); + + it('Hour select in 12h mode contains 12 options (1–12)', async () => { + vi.mocked(useDateTimeFormat).mockReturnValue({ + fullDateTimeFormat: 'MM/DD/YYYY, h:mm:ss A', + timeFormatSetting: '12h', + }); + setupMocks(); + render(); + await waitFor(() => expect(vi.mocked(to12Hour)).toHaveBeenCalled()); + + const hourSelect = screen.getByLabelText('Hour'); + expect(hourSelect.querySelectorAll('option')).toHaveLength(12); + }); + + it('Minute select contains 60 options (00–59)', async () => { + await renderAndLoad(); + const minuteSelect = screen.getByLabelText('Minute'); + expect(minuteSelect.querySelectorAll('option')).toHaveLength(60); + }); + }); + + // ── Weekly frequency / Day selector ───────────────────────────────────────── + + describe('weekly frequency', () => { + it('shows Day select when frequency is "weekly"', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, frequency: 'weekly' }, + }); + expect(screen.getByLabelText('Day')).toBeInTheDocument(); + }); + + it('does not show Day select when frequency is "daily"', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, frequency: 'daily' }, + }); + expect(screen.queryByLabelText('Day')).not.toBeInTheDocument(); + }); + + it('shows Day select after switching from daily to weekly', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, frequency: 'daily' }, + }); + fireEvent.change(screen.getByLabelText('Frequency'), { + target: { value: 'weekly' }, + }); + expect(screen.getByLabelText('Day')).toBeInTheDocument(); + }); + + it('hides Day select after switching from weekly to daily', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, frequency: 'weekly' }, + }); + fireEvent.change(screen.getByLabelText('Frequency'), { + target: { value: 'daily' }, + }); + expect(screen.queryByLabelText('Day')).not.toBeInTheDocument(); + }); + + it('Day select contains all 7 days of the week', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, frequency: 'weekly' }, + }); + expect( + screen.getByLabelText('Day').querySelectorAll('option') + ).toHaveLength(7); + }); + }); + + // ── Timezone info text ─────────────────────────────────────────────────────── + + describe('timezone info text', () => { + it('shows timezone info when schedule is enabled and in interval mode', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, enabled: true, cron_expression: '' }, + }); + expect(screen.getByText(/System Timezone/)).toBeInTheDocument(); + }); + + it('does not show timezone info when schedule is disabled', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, enabled: false, cron_expression: '' }, + }); + expect(screen.queryByText(/System Timezone/)).not.toBeInTheDocument(); + }); + + it('does not show timezone info in cron mode', async () => { + await renderAndLoad({ + schedule: { + ...defaultSchedule, + enabled: true, + cron_expression: '0 3 * * *', + }, + }); + expect(screen.queryByText(/System Timezone/)).not.toBeInTheDocument(); + }); + + it('includes the user timezone string in the info text', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, enabled: true, cron_expression: '' }, + }); + // useLocalStorage mock returns 'UTC' + expect(screen.getByText(/UTC/)).toBeInTheDocument(); + }); + }); + + // ── Schedule type switching ────────────────────────────────────────────────── + + describe('schedule type switching', () => { + it('switches to cron mode when "Use custom cron schedule" is clicked', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByTestId('switch-to-cron')); + expect(screen.getByTestId('cron-input')).toBeInTheDocument(); + }); + + it('switches back to interval mode when "Use simple schedule" is clicked', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, cron_expression: '0 3 * * *' }, + }); + fireEvent.click(screen.getByTestId('switch-to-interval')); + expect(screen.queryByTestId('cron-input')).not.toBeInTheDocument(); + expect(screen.getByTestId('switch-to-cron')).toBeInTheDocument(); + }); + + it('clears cron_expression when switching back to interval and saving', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, cron_expression: '0 3 * * *' }, + }); + fireEvent.click(screen.getByTestId('switch-to-interval')); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => + expect(vi.mocked(updateBackupSchedule)).toHaveBeenCalledWith( + expect.objectContaining({ cron_expression: '' }) + ) + ); + }); + }); +}); diff --git a/frontend/src/components/cards/__tests__/AvailablePluginCard.test.jsx b/frontend/src/components/cards/__tests__/AvailablePluginCard.test.jsx new file mode 100644 index 00000000..027444ea --- /dev/null +++ b/frontend/src/components/cards/__tests__/AvailablePluginCard.test.jsx @@ -0,0 +1,1324 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import AvailablePluginCard from '../AvailablePluginCard'; + +// ── Router ───────────────────────────────────────────────────────────────────── +vi.mock('react-router-dom', () => ({ + useNavigate: vi.fn(), + useLocation: vi.fn(), +})); + +// ── Plugin store ─────────────────────────────────────────────────────────────── +vi.mock('../../../store/plugins', () => { + const mockUsePluginStore = vi.fn(); + mockUsePluginStore.getState = vi.fn(); + return { usePluginStore: mockUsePluginStore }; +}); + +// ── PluginWarnings ───────────────────────────────────────────────────────────── +vi.mock('../../PluginWarnings.jsx', () => ({ + PluginDowngradeWarning: ({ children }) => ( +
{children}
+ ), + PluginInfoNote: ({ children }) => ( +
{children}
+ ), + PluginSecurityWarning: ({ children }) => ( +
{children}
+ ), + PluginSupportDisclaimer: () =>
, +})); + +// ── PluginDetailPanel ────────────────────────────────────────────────────────── +vi.mock('../../PluginDetailPanel.jsx', () => ({ + default: ({ onInstall, onUninstall }) => ( +
+ + +
+ ), +})); + +// ── pluginUtils ──────────────────────────────────────────────────────────────── +vi.mock('../../../utils/components/pluginUtils.js', () => ({ + buildCompatibilityTooltip: vi.fn(), + compareVersions: vi.fn(), + getInstallInfo: vi.fn(), +})); + +// ── PluginsUtils ─────────────────────────────────────────────────────────────── +vi.mock('../../../utils/pages/PluginsUtils.js', () => ({ + deletePluginByKey: vi.fn(), + getPluginDetailManifest: vi.fn(), + setPluginEnabled: vi.fn(), +})); + +// ── SizedInstallButton ───────────────────────────────────────────────────────── +vi.mock('../../theme/SizedInstallButton.jsx', () => ({ + default: ({ children, onClick, disabled, loading, latest_size }) => ( + + ), +})); + +// ── @mantine/core ────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Avatar: ({ children, src, alt }) => ( +
+ {src ? {alt} : children} +
+ ), + Badge: ({ children, color, component, href, leftSection }) => + component === 'a' ? ( + + {leftSection} + {children} + + ) : ( + + {leftSection} + {children} + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, disabled, loading, color, variant }) => ( + + ), + Card: ({ children, style }) => ( +
+ {children} +
+ ), + Group: ({ children, style }) =>
{children}
, + Loader: () => , + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Stack: ({ children }) =>
{children}
, + Switch: ({ checked, onChange }) => ( + + ), + Text: ({ children, fw, style }) => ( + + {children} + + ), + Tooltip: ({ children, label }) =>
{children}
, +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + AlertTriangle: () => , + Ban: () => , + Check: () => , + Download: () => , + FlaskConical: () => , + Info: () => , + RefreshCw: () => , + RotateCcw: () => , + ShieldAlert: () => , + ShieldCheck: () => , + Trash2: () => , +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import { useNavigate, useLocation } from 'react-router-dom'; +import { usePluginStore } from '../../../store/plugins'; +import { + buildCompatibilityTooltip, + compareVersions, + getInstallInfo, +} from '../../../utils/components/pluginUtils.js'; +import * as PluginsUtils from '../../../utils/pages/PluginsUtils.js'; + +// ────────────────────────────────────────────────────────────────────────────── +// Factories +// ────────────────────────────────────────────────────────────────────────────── +const makePlugin = (overrides = {}) => ({ + slug: 'test-plugin', + name: 'Test Plugin', + author: 'Test Author', + description: 'A test plugin description', + latest_version: '1.0.0', + latest_url: 'https://example.com/plugin.zip', + latest_sha256: 'abc123', + latest_size: 1024, + install_status: 'not_installed', + installed: false, + installed_version: null, + repo_id: 1, + repo_name: 'Test Repo', + is_official_repo: false, + manifest_url: null, + deprecated: false, + license: 'MIT', + last_updated: '2024-01-01T00:00:00Z', + icon_url: null, + signature_verified: null, + key: null, + ...overrides, +}); + +const APP_VERSION = '1.0.0'; + +// ────────────────────────────────────────────────────────────────────────────── +// Mock helpers +// ────────────────────────────────────────────────────────────────────────────── +const setupMocks = ({ pathname = '/available-plugins' } = {}) => { + const mockNavigate = vi.fn(); + vi.mocked(useNavigate).mockReturnValue(mockNavigate); + vi.mocked(useLocation).mockReturnValue({ pathname }); + + const mockInstallPlugin = vi.fn().mockResolvedValue({ + success: true, + plugin: { key: 'test-plugin', enabled: true }, + }); + const mockInvalidatePlugins = vi.fn(); + const mockFetchAvailablePlugins = vi.fn(); + + vi.mocked(usePluginStore).mockImplementation((sel) => + sel({ installPlugin: mockInstallPlugin }) + ); + usePluginStore.getState.mockReturnValue({ + invalidatePlugins: mockInvalidatePlugins, + fetchAvailablePlugins: mockFetchAvailablePlugins, + }); + + // Default: versions are compatible, no downgrade, no bad signature + vi.mocked(compareVersions).mockReturnValue(0); + vi.mocked(buildCompatibilityTooltip).mockReturnValue('1.0.0 or newer'); + vi.mocked(getInstallInfo).mockReturnValue({ + isDowngrade: false, + isUpdate: false, + isBadSig: false, + }); + + vi.mocked(PluginsUtils.getPluginDetailManifest).mockResolvedValue(null); + vi.mocked(PluginsUtils.deletePluginByKey).mockResolvedValue({ + success: true, + }); + vi.mocked(PluginsUtils.setPluginEnabled).mockResolvedValue(undefined); + + return { + mockNavigate, + mockInstallPlugin, + mockInvalidatePlugins, + mockFetchAvailablePlugins, + }; +}; + +/** + * Finds the confirm/action button inside the modal (not the card's SizedInstallButton). + * Both share the same text in many tests, so we check by data-testid absence. + */ +const getModalActionButton = (text) => + screen + .getAllByText(text) + .find((el) => el.tagName === 'BUTTON' && !el.dataset.testid); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('AvailablePluginCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the plugin name', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Test Plugin')).toBeInTheDocument(); + }); + + it('renders the plugin description', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('A test plugin description')).toBeInTheDocument(); + }); + + it('renders the plugin author', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Test Author')).toBeInTheDocument(); + }); + + it('renders the latest version badge', () => { + setupMocks(); + render( + + ); + expect(screen.getByText(/1\.0\.0/)).toBeInTheDocument(); + }); + + it('renders the license badge', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('MIT')).toBeInTheDocument(); + }); + + it('renders a last_updated badge', () => { + setupMocks(); + render( + + ); + // The date badge is rendered from last_updated via toLocaleDateString() + expect( + screen.getByText(new Date('2024-01-01T00:00:00Z').toLocaleDateString()) + ).toBeInTheDocument(); + }); + + it('renders the More Info button', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('More Info')).toBeInTheDocument(); + }); + + it('renders Install button for not_installed plugin', () => { + setupMocks(); + render( + + ); + expect(screen.getByTestId('sized-install-button')).toHaveTextContent( + 'Install' + ); + }); + + it('renders Update button for update_available plugin', () => { + setupMocks(); + render( + + ); + expect(screen.getByTestId('sized-install-button')).toHaveTextContent( + 'Update' + ); + }); + + it('renders Uninstall button for installed plugin', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Uninstall')).toBeInTheDocument(); + }); + + it('renders Overwrite button for unmanaged plugin', () => { + setupMocks(); + render( + + ); + expect(screen.getByTestId('sized-install-button')).toHaveTextContent( + 'Overwrite' + ); + }); + + it('renders Overwrite button for different_repo plugin', () => { + setupMocks(); + render( + + ); + expect(screen.getByTestId('sized-install-button')).toHaveTextContent( + 'Overwrite' + ); + }); + + it('does not render a sized install button when latest_url is null', () => { + setupMocks(); + render( + + ); + expect( + screen.queryByTestId('sized-install-button') + ).not.toBeInTheDocument(); + }); + + it('renders min version badge when min_dispatcharr_version is set', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('0.9.0')).toBeInTheDocument(); + }); + + it('renders max version badge when max_dispatcharr_version is set', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('2.0.0')).toBeInTheDocument(); + }); + + it('renders Downgrade button for update_available when isLatestDowngrade', () => { + setupMocks(); + // compareVersions(latest, installed) < 0 → isLatestDowngrade + vi.mocked(compareVersions).mockReturnValue(-1); + render( + + ); + expect(screen.getByTestId('sized-install-button')).toHaveTextContent( + 'Downgrade' + ); + }); + }); + + // ── RepoBadge ────────────────────────────────────────────────────────────── + + describe('RepoBadge', () => { + it('shows "Official Repo" badge when is_official_repo is true', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Official Repo')).toBeInTheDocument(); + }); + + it('shows community repo name badge when is_official_repo is false', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Test Repo')).toBeInTheDocument(); + }); + + it('shows ShieldCheck icon when signature_verified is true', () => { + setupMocks(); + render( + + ); + expect(screen.getByTestId('icon-shield-check')).toBeInTheDocument(); + }); + + it('shows ShieldAlert icon when signature_verified is false', () => { + setupMocks(); + render( + + ); + expect(screen.getByTestId('icon-shield-alert')).toBeInTheDocument(); + }); + + it('shows no shield icon when signature_verified is null', () => { + setupMocks(); + render( + + ); + expect(screen.queryByTestId('icon-shield-check')).not.toBeInTheDocument(); + expect(screen.queryByTestId('icon-shield-alert')).not.toBeInTheDocument(); + }); + }); + + // ── StatusBadge ──────────────────────────────────────────────────────────── + + describe('StatusBadge', () => { + it('shows Installed badge for installed plugin', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Installed')).toBeInTheDocument(); + }); + + it('shows prerelease Installed badge when installed_version_is_prerelease', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Prerelease')).toBeInTheDocument(); + }); + + it('shows Update Available badge for update_available plugin', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Update Available')).toBeInTheDocument(); + }); + + it('shows Deprecated badge for not-installed deprecated plugin', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Deprecated')).toBeInTheDocument(); + }); + + it('shows Installed badge for unmanaged plugin', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Installed')).toBeInTheDocument(); + }); + }); + + // ── Compatibility ────────────────────────────────────────────────────────── + + describe('version compatibility', () => { + it('shows compatibility warning when min version is not met', () => { + // compareVersions returns negative → appVersion < min → meetsMinVersion = false + vi.mocked(compareVersions).mockReturnValue(-1); + render( + + ); + expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument(); + }); + + it('disables install button when version is incompatible', () => { + vi.mocked(compareVersions).mockReturnValue(-1); + render( + + ); + expect(screen.getByTestId('sized-install-button')).toBeDisabled(); + }); + + it('does not show compatibility warning when version is met', () => { + setupMocks(); + render( + + ); + expect( + screen.queryByTestId('icon-alert-triangle') + ).not.toBeInTheDocument(); + }); + }); + + // ── More Info modal ──────────────────────────────────────────────────────── + + describe('More Info modal', () => { + it('opens detail modal when More Info is clicked', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('More Info')); + expect(screen.getByTestId('plugin-detail-panel')).toBeInTheDocument(); + }); + + it('does not call getPluginDetailManifest when manifest_url is null', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('More Info')); + expect(PluginsUtils.getPluginDetailManifest).not.toHaveBeenCalled(); + }); + + it('calls getPluginDetailManifest with correct args when manifest_url is set', async () => { + setupMocks(); + const plugin = makePlugin({ + manifest_url: 'https://example.com/manifest.json', + }); + render(); + fireEvent.click(screen.getByText('More Info')); + await waitFor(() => { + expect(PluginsUtils.getPluginDetailManifest).toHaveBeenCalledWith( + 1, + 'https://example.com/manifest.json' + ); + }); + }); + + it('closes detail modal when modal close button is clicked', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('More Info')); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('calls onDetailClose when detail modal is closed', () => { + setupMocks(); + const onDetailClose = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('More Info')); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onDetailClose).toHaveBeenCalled(); + }); + + it('triggers install flow from PluginDetailPanel', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('More Info')); + fireEvent.click(screen.getByTestId('detail-install')); + expect(screen.getAllByTestId('modal-title').at(-1)).toHaveTextContent( + 'Confirm Install' + ); + }); + + it('triggers uninstall flow from PluginDetailPanel', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('More Info')); + fireEvent.click(screen.getByTestId('detail-uninstall')); + expect(screen.getAllByTestId('modal-title').at(-1)).toHaveTextContent( + 'Uninstall Plugin' + ); + }); + }); + + // ── autoOpenDetail ───────────────────────────────────────────────────────── + + describe('autoOpenDetail', () => { + it('immediately opens detail modal when autoOpenDetail is true', () => { + setupMocks(); + render( + + ); + expect(screen.getByTestId('plugin-detail-panel')).toBeInTheDocument(); + }); + }); + + // ── Install flow ─────────────────────────────────────────────────────────── + + describe('install flow', () => { + it('opens confirm modal when Install button is clicked', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Confirm Install' + ); + }); + + it('shows plugin name inside confirm modal', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getAllByText(/Test Plugin/).length).toBeGreaterThan(0); + }); + + it('shows security warning in confirm modal', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getByTestId('security-warning')).toBeInTheDocument(); + }); + + it('Cancel closes confirm modal without calling installPlugin', () => { + const { mockInstallPlugin } = setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(screen.getByText('Cancel')); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + expect(mockInstallPlugin).not.toHaveBeenCalled(); + }); + + it('calls installPlugin with correct params after confirmation', async () => { + const { mockInstallPlugin } = setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(mockInstallPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + repo_id: 1, + slug: 'test-plugin', + version: '1.0.0', + download_url: 'https://example.com/plugin.zip', + }) + ); + }); + }); + + it('calls onBeforeInstall with plugin slug before installing', async () => { + setupMocks(); + const onBeforeInstall = vi.fn(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(onBeforeInstall).toHaveBeenCalledWith('test-plugin'); + }); + }); + + it('calls onInstalled with plugin slug after successful install', async () => { + setupMocks(); + const onInstalled = vi.fn(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(onInstalled).toHaveBeenCalledWith('test-plugin'); + }); + }); + + it('shows restart prompt with "Plugin Installed" title after successful install', async () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Plugin Installed' + ); + }); + }); + + it('Done button in restart prompt closes it', async () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(screen.getByText('Done')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText('Done')); + await waitFor(() => { + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + + it('"Go to My Plugins" button navigates to /plugins', async () => { + const { mockNavigate } = setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(screen.getByText('Go to My Plugins')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText('Go to My Plugins')); + expect(mockNavigate).toHaveBeenCalledWith('/plugins'); + }); + + it('does not show "Go to My Plugins" when already on /plugins path', async () => { + setupMocks({ pathname: '/plugins' }); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(screen.getByText('Done')).toBeInTheDocument(); + }); + expect(screen.queryByText('Go to My Plugins')).not.toBeInTheDocument(); + }); + + it('does not open restart prompt when installPlugin returns no success', async () => { + const { mockInstallPlugin } = setupMocks(); + mockInstallPlugin.mockResolvedValue({ success: false }); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(mockInstallPlugin).toHaveBeenCalled(); + }); + expect(screen.queryByText('Plugin Installed')).not.toBeInTheDocument(); + }); + }); + + // ── Enable now ───────────────────────────────────────────────────────────── + + describe('enable now (plugin installed as disabled)', () => { + const installDisabledPlugin = async (mockInstallPlugin) => { + mockInstallPlugin.mockResolvedValue({ + success: true, + plugin: { key: 'test-plugin', enabled: false }, + }); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(screen.getByTestId('enable-switch')).toBeInTheDocument(); + }); + }; + + it('shows enable switch in restart prompt when plugin is installed disabled', async () => { + const { mockInstallPlugin } = setupMocks(); + await installDisabledPlugin(mockInstallPlugin); + expect(screen.getByTestId('enable-switch')).toBeInTheDocument(); + }); + + it('calls setPluginEnabled when Done is clicked with enable switch toggled on', async () => { + const { mockInstallPlugin } = setupMocks(); + await installDisabledPlugin(mockInstallPlugin); + fireEvent.click(screen.getByTestId('enable-switch')); + fireEvent.click(screen.getByText('Done')); + await waitFor(() => { + expect(PluginsUtils.setPluginEnabled).toHaveBeenCalledWith( + 'test-plugin', + true + ); + }); + }); + + it('does not call setPluginEnabled when enable switch is left off', async () => { + const { mockInstallPlugin } = setupMocks(); + await installDisabledPlugin(mockInstallPlugin); + // Do NOT toggle the switch + fireEvent.click(screen.getByText('Done')); + await waitFor(() => { + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + expect(PluginsUtils.setPluginEnabled).not.toHaveBeenCalled(); + }); + }); + + // ── Deprecated plugin ────────────────────────────────────────────────────── + + describe('deprecated plugin', () => { + it('shows deprecation warning modal when Install is clicked', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Deprecated Plugin' + ); + }); + + it('shows plugin name in deprecation warning', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getAllByText(/Test Plugin/).length).toBeGreaterThan(0); + }); + + it('Cancel in deprecation modal closes it without proceeding', () => { + const { mockInstallPlugin } = setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(screen.getByText('Cancel')); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + expect(mockInstallPlugin).not.toHaveBeenCalled(); + }); + + it('"Install Anyway" proceeds to the install confirm modal', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(screen.getByText('Install Anyway')); + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Confirm Install' + ); + }); + + it('after deprecation → confirm → install, onInstalled is called', async () => { + setupMocks(); + const onInstalled = vi.fn(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(screen.getByText('Install Anyway')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(onInstalled).toHaveBeenCalledWith('test-plugin'); + }); + }); + }); + + // ── Downgrade / update confirm modal ────────────────────────────────────── + + describe('downgrade confirm modal', () => { + const makeUpdatePlugin = () => + makePlugin({ + install_status: 'update_available', + installed: true, + installed_version: '1.1.0', + }); + + it('shows Confirm Downgrade title when getInstallInfo returns isDowngrade', () => { + setupMocks(); + vi.mocked(getInstallInfo).mockReturnValue({ + isDowngrade: true, + isUpdate: false, + isBadSig: false, + }); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Confirm Downgrade' + ); + }); + + it('shows downgrade warning when isDowngrade', () => { + setupMocks(); + vi.mocked(getInstallInfo).mockReturnValue({ + isDowngrade: true, + isUpdate: false, + isBadSig: false, + }); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getByTestId('downgrade-warning')).toBeInTheDocument(); + }); + + it('shows Confirm Update title when getInstallInfo returns isUpdate', () => { + setupMocks(); + vi.mocked(getInstallInfo).mockReturnValue({ + isDowngrade: false, + isUpdate: true, + isBadSig: false, + }); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Confirm Update' + ); + }); + + it('shows restart prompt with "Plugin Downgraded" after downgrade confirms', async () => { + setupMocks(); + vi.mocked(getInstallInfo).mockReturnValue({ + isDowngrade: true, + isUpdate: false, + isBadSig: false, + }); + // compareVersions < 0 so wasDowngrade = true in executeInstall + vi.mocked(compareVersions).mockReturnValue(-1); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Downgrade')); + await waitFor(() => { + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Plugin Downgraded' + ); + }); + }); + + it('shows "Plugin Updated" restart prompt after update confirms', async () => { + setupMocks(); + vi.mocked(getInstallInfo).mockReturnValue({ + isDowngrade: false, + isUpdate: true, + isBadSig: false, + }); + // installed_version set → wasInstalled = true, wasDowngrade = false (compareVersions = 0) + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Update')); + await waitFor(() => { + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Plugin Updated' + ); + }); + }); + }); + + // ── Unmanaged / different_repo notes ────────────────────────────────────── + + describe('unmanaged and different_repo notes', () => { + it('shows info note in confirm modal for unmanaged install', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getByTestId('info-note')).toBeInTheDocument(); + }); + + it('shows info note in confirm modal for different_repo install', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getByTestId('info-note')).toBeInTheDocument(); + }); + }); + + // ── Uninstall flow ───────────────────────────────────────────────────────── + + describe('uninstall flow', () => { + const makeInstalled = () => + makePlugin({ + install_status: 'installed', + installed: true, + installed_version: '1.0.0', + key: 'test-plugin', + }); + + it('clicking Uninstall button opens uninstall confirm modal', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('Uninstall')); + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Uninstall Plugin' + ); + }); + + it('Cancel in uninstall confirm modal closes it without deleting', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('Uninstall')); + fireEvent.click(screen.getByText('Cancel')); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + expect(PluginsUtils.deletePluginByKey).not.toHaveBeenCalled(); + }); + + it('confirming calls deletePluginByKey with plugin key', async () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('Uninstall')); + // Second "Uninstall" text = modal confirm button + fireEvent.click(screen.getAllByText('Uninstall')[1]); + await waitFor(() => { + expect(PluginsUtils.deletePluginByKey).toHaveBeenCalledWith( + 'test-plugin' + ); + }); + }); + + it('calls invalidatePlugins and fetchAvailablePlugins after uninstall', async () => { + const { mockInvalidatePlugins, mockFetchAvailablePlugins } = setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('Uninstall')); + fireEvent.click(screen.getAllByText('Uninstall')[1]); + await waitFor(() => { + expect(mockInvalidatePlugins).toHaveBeenCalled(); + expect(mockFetchAvailablePlugins).toHaveBeenCalled(); + }); + }); + + it('calls onUninstalled callback with plugin slug after uninstall', async () => { + setupMocks(); + const onUninstalled = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Uninstall')); + fireEvent.click(screen.getAllByText('Uninstall')[1]); + await waitFor(() => { + expect(onUninstalled).toHaveBeenCalledWith('test-plugin'); + }); + }); + + it('shows "Plugin Uninstalled" done modal after uninstall', async () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('Uninstall')); + fireEvent.click(screen.getAllByText('Uninstall')[1]); + await waitFor(() => { + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Plugin Uninstalled' + ); + }); + }); + + it('Done in uninstall done modal closes it', async () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('Uninstall')); + fireEvent.click(screen.getAllByText('Uninstall')[1]); + await waitFor(() => { + expect(screen.getByText('Done')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText('Done')); + await waitFor(() => { + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + + it('does not call onUninstalled when deletePluginByKey returns no success', async () => { + setupMocks(); + vi.mocked(PluginsUtils.deletePluginByKey).mockResolvedValue({ + success: false, + }); + const onUninstalled = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Uninstall')); + fireEvent.click(screen.getAllByText('Uninstall')[1]); + await waitFor(() => { + expect(PluginsUtils.deletePluginByKey).toHaveBeenCalled(); + }); + expect(onUninstalled).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/components/forms/settings/__tests__/ConnectionSecurityPanel.test.jsx b/frontend/src/components/forms/settings/__tests__/ConnectionSecurityPanel.test.jsx new file mode 100644 index 00000000..96cd3ea7 --- /dev/null +++ b/frontend/src/components/forms/settings/__tests__/ConnectionSecurityPanel.test.jsx @@ -0,0 +1,592 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import ConnectionSecurityPanel from '../ConnectionSecurityPanel'; + +// ── Store mock ───────────────────────────────────────────────────────────────── +vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() })); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Badge: ({ children, color, variant, size }) => ( + + {children} + + ), + Group: ({ children, gap, align }) => ( +
+ {children} +
+ ), + Paper: ({ children, p, withBorder }) => ( +
+ {children} +
+ ), + SimpleGrid: ({ children, spacing }) => ( +
+ {children} +
+ ), + Stack: ({ children, gap }) =>
{children}
, + Text: ({ children, size, c, fw }) => ( + + {children} + + ), + Tooltip: ({ children, label }) => ( +
+ {children} +
+ ), +})); + +import useSettingsStore from '../../../../store/settings.jsx'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const setupStore = (environment = {}) => { + vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ environment })); +}; + +/** Find the service card (Paper) whose subtree contains a Text matching `name` */ +const getServiceCard = (name) => { + const papers = screen.getAllByTestId('paper'); + return papers.find((p) => within(p).queryByText(name)); +}; + +/** Get all badges within a named service card */ +const getBadgesIn = (serviceName) => + within(getServiceCard(serviceName)).getAllByTestId('badge'); + +/** Get all tooltips within a named service card */ +const getTooltipsIn = (serviceName) => + within(getServiceCard(serviceName)).getAllByTestId('tooltip'); + +/** Find a badge by its exact text content within a service card */ +const getBadgeByText = (serviceName, text) => + getBadgesIn(serviceName).find((b) => b.textContent === text); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('ConnectionSecurityPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Top-level rendering ────────────────────────────────────────────────────── + + describe('top-level rendering', () => { + it('renders the introductory description text', () => { + setupStore(); + render(); + expect( + screen.getByText( + /Encrypt connections to Redis and PostgreSQL using environment variables/i + ) + ).toBeInTheDocument(); + }); + + it('renders both Redis and PostgreSQL service cards', () => { + setupStore(); + render(); + expect(getServiceCard('Redis')).toBeTruthy(); + expect(getServiceCard('PostgreSQL')).toBeTruthy(); + }); + }); + + // ── RedisStatus — TLS disabled ─────────────────────────────────────────────── + + describe('RedisStatus — TLS disabled (default)', () => { + beforeEach(() => { + setupStore({ redis_tls: { enabled: false } }); + render(); + }); + + it('renders all three option labels', () => { + const card = getServiceCard('Redis'); + expect(within(card).getByText('Encryption')).toBeInTheDocument(); + expect(within(card).getByText('Server Verification')).toBeInTheDocument(); + expect(within(card).getByText('Mutual TLS')).toBeInTheDocument(); + }); + + it('shows Encryption badge as "Disabled" with gray color', () => { + const badge = getBadgeByText('Redis', 'Disabled'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('gray'); + }); + + it('shows Server Verification badge as "Off" with gray color', () => { + const card = getServiceCard('Redis'); + const offBadge = within(card) + .getAllByTestId('badge') + .find((b) => b.textContent === 'Off'); + expect(offBadge).toBeTruthy(); + expect(offBadge.dataset.color).toBe('gray'); + }); + + it('shows Mutual TLS badge as "Inactive" with gray color', () => { + const badge = getBadgeByText('Redis', 'Inactive'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('gray'); + }); + + it('shows "not encrypted" tooltip for Encryption', () => { + const tooltips = getTooltipsIn('Redis'); + expect( + tooltips.find( + (t) => t.dataset.tooltip === 'The connection is not encrypted' + ) + ).toBeTruthy(); + }); + + it('shows "Verification is not active" tooltip for Server Verification', () => { + const tooltips = getTooltipsIn('Redis'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes('Verification is not active') + ) + ).toBeTruthy(); + }); + + it('shows "Mutual authentication is not active" tooltip for mTLS', () => { + const tooltips = getTooltipsIn('Redis'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes('Mutual authentication is not active') + ) + ).toBeTruthy(); + }); + }); + + // ── RedisStatus — TLS enabled, verify=true, mtls=false ────────────────────── + + describe('RedisStatus — TLS enabled, verify=true, mtls=false', () => { + beforeEach(() => { + setupStore({ redis_tls: { enabled: true, verify: true, mtls: false } }); + render(); + }); + + it('shows Encryption badge as "Enabled" with green color', () => { + const badge = getBadgeByText('Redis', 'Enabled'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('green'); + }); + + it('shows Server Verification badge as "On" with green color', () => { + const card = getServiceCard('Redis'); + const onBadge = within(card) + .getAllByTestId('badge') + .find((b) => b.textContent === 'On'); + expect(onBadge).toBeTruthy(); + expect(onBadge.dataset.color).toBe('green'); + }); + + it('shows Mutual TLS badge as "Inactive" with gray color', () => { + const badge = getBadgeByText('Redis', 'Inactive'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('gray'); + }); + + it('shows "connection is encrypted" tooltip for Encryption', () => { + const tooltips = getTooltipsIn('Redis'); + expect( + tooltips.find( + (t) => t.dataset.tooltip === 'The connection is encrypted' + ) + ).toBeTruthy(); + }); + + it('shows "server identity is verified" tooltip for Server Verification', () => { + const tooltips = getTooltipsIn('Redis'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes("server's identity is verified") + ) + ).toBeTruthy(); + }); + }); + + // ── RedisStatus — TLS enabled, verify=false ────────────────────────────────── + + describe('RedisStatus — TLS enabled, verify=false', () => { + beforeEach(() => { + setupStore({ redis_tls: { enabled: true, verify: false, mtls: false } }); + render(); + }); + + it('shows Server Verification badge as "Off" with yellow color', () => { + const card = getServiceCard('Redis'); + const offBadge = within(card) + .getAllByTestId('badge') + .find((b) => b.textContent === 'Off'); + expect(offBadge).toBeTruthy(); + expect(offBadge.dataset.color).toBe('yellow'); + }); + + it('shows "encrypted but server identity is not verified" tooltip', () => { + const tooltips = getTooltipsIn('Redis'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes('server identity is not verified') + ) + ).toBeTruthy(); + }); + }); + + // ── RedisStatus — TLS enabled, mtls=true ───────────────────────────────────── + + describe('RedisStatus — TLS enabled, mtls=true', () => { + beforeEach(() => { + setupStore({ redis_tls: { enabled: true, verify: true, mtls: true } }); + render(); + }); + + it('shows Mutual TLS badge as "Active" with green color', () => { + const badge = getBadgeByText('Redis', 'Active'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('green'); + }); + + it('shows "Both client and server verify each other" tooltip for mTLS', () => { + const tooltips = getTooltipsIn('Redis'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes( + 'Both client and server verify each other' + ) + ) + ).toBeTruthy(); + }); + }); + + // ── RedisStatus — redis_tls undefined ──────────────────────────────────────── + + describe('RedisStatus — redis_tls undefined', () => { + it('falls back to defaults (Disabled/Off/Inactive, all gray)', () => { + setupStore({}); + render(); + expect(getBadgeByText('Redis', 'Disabled').dataset.color).toBe('gray'); + expect( + within(getServiceCard('Redis')) + .getAllByTestId('badge') + .find((b) => b.textContent === 'Off').dataset.color + ).toBe('gray'); + expect(getBadgeByText('Redis', 'Inactive').dataset.color).toBe('gray'); + }); + }); + + // ── PostgresStatus — TLS disabled ─────────────────────────────────────────── + + describe('PostgresStatus — TLS disabled (default)', () => { + beforeEach(() => { + setupStore({ postgres_tls: { enabled: false } }); + render(); + }); + + it('renders all three option labels', () => { + const card = getServiceCard('PostgreSQL'); + expect(within(card).getByText('Encryption')).toBeInTheDocument(); + expect(within(card).getByText('Verification Mode')).toBeInTheDocument(); + expect(within(card).getByText('Mutual TLS')).toBeInTheDocument(); + }); + + it('shows Encryption badge as "Disabled" with gray color', () => { + const badge = getBadgeByText('PostgreSQL', 'Disabled'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('gray'); + }); + + it('shows Verification Mode badge as "Off" with gray color', () => { + const card = getServiceCard('PostgreSQL'); + const offBadge = within(card) + .getAllByTestId('badge') + .find((b) => b.textContent === 'Off'); + expect(offBadge).toBeTruthy(); + expect(offBadge.dataset.color).toBe('gray'); + }); + + it('shows Mutual TLS badge as "Inactive" with gray color', () => { + const badge = getBadgeByText('PostgreSQL', 'Inactive'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('gray'); + }); + + it('shows "Verification mode is not active" tooltip when TLS is disabled', () => { + const tooltips = getTooltipsIn('PostgreSQL'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes('Verification mode is not active') + ) + ).toBeTruthy(); + }); + }); + + // ── PostgresStatus — ssl_mode=verify-full ─────────────────────────────────── + + describe('PostgresStatus — ssl_mode=verify-full', () => { + beforeEach(() => { + setupStore({ + postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: false }, + }); + render(); + }); + + it('shows Verification Mode badge as "verify-full" with green color', () => { + const badge = getBadgeByText('PostgreSQL', 'verify-full'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('green'); + }); + + it('shows "Server certificate and hostname are both verified" tooltip', () => { + const tooltips = getTooltipsIn('PostgreSQL'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes( + 'Server certificate and hostname are both verified' + ) + ) + ).toBeTruthy(); + }); + }); + + // ── PostgresStatus — ssl_mode=verify-ca ───────────────────────────────────── + + describe('PostgresStatus — ssl_mode=verify-ca', () => { + beforeEach(() => { + setupStore({ + postgres_tls: { enabled: true, ssl_mode: 'verify-ca', mtls: false }, + }); + render(); + }); + + it('shows Verification Mode badge as "verify-ca" with yellow color', () => { + const badge = getBadgeByText('PostgreSQL', 'verify-ca'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('yellow'); + }); + + it('shows "hostname is not checked" tooltip', () => { + const tooltips = getTooltipsIn('PostgreSQL'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes('hostname is not checked') + ) + ).toBeTruthy(); + }); + }); + + // ── PostgresStatus — ssl_mode=require (other/fallback) ────────────────────── + + describe('PostgresStatus — ssl_mode=require (other)', () => { + beforeEach(() => { + setupStore({ + postgres_tls: { enabled: true, ssl_mode: 'require', mtls: false }, + }); + render(); + }); + + it('shows Verification Mode badge as "require" with yellow color', () => { + const badge = getBadgeByText('PostgreSQL', 'require'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('yellow'); + }); + + it('shows "encrypted but the server is not verified" tooltip', () => { + const tooltips = getTooltipsIn('PostgreSQL'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes('server is not verified') + ) + ).toBeTruthy(); + }); + }); + + // ── PostgresStatus — TLS enabled, ssl_mode absent ─────────────────────────── + + describe('PostgresStatus — TLS enabled, ssl_mode absent', () => { + it('shows Verification Mode badge as "Off" with gray color', () => { + setupStore({ postgres_tls: { enabled: true, mtls: false } }); + render(); + const card = getServiceCard('PostgreSQL'); + const offBadge = within(card) + .getAllByTestId('badge') + .find((b) => b.textContent === 'Off'); + expect(offBadge).toBeTruthy(); + expect(offBadge.dataset.color).toBe('gray'); + }); + }); + + // ── PostgresStatus — mtls=true ─────────────────────────────────────────────── + + describe('PostgresStatus — mtls=true', () => { + beforeEach(() => { + setupStore({ + postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: true }, + }); + render(); + }); + + it('shows Mutual TLS badge as "Active" with green color', () => { + const badge = getBadgeByText('PostgreSQL', 'Active'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('green'); + }); + + it('shows "Both client and server verify each other" tooltip for mTLS', () => { + const tooltips = getTooltipsIn('PostgreSQL'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes( + 'Both client and server verify each other' + ) + ) + ).toBeTruthy(); + }); + }); + + // ── PostgresStatus — mtls=false while TLS enabled ─────────────────────────── + + describe('PostgresStatus — mtls=false while TLS enabled', () => { + it('shows Mutual TLS badge as "Inactive" with gray color', () => { + setupStore({ + postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: false }, + }); + render(); + const badge = getBadgeByText('PostgreSQL', 'Inactive'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('gray'); + }); + + it('shows "Mutual authentication is not active" tooltip', () => { + setupStore({ + postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: false }, + }); + render(); + const tooltips = getTooltipsIn('PostgreSQL'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes('Mutual authentication is not active') + ) + ).toBeTruthy(); + }); + }); + + // ── PostgresStatus — postgres_tls undefined ────────────────────────────────── + + describe('PostgresStatus — postgres_tls undefined', () => { + it('falls back to all defaults (Disabled/Off/Inactive, all gray)', () => { + setupStore({}); + render(); + expect(getBadgeByText('PostgreSQL', 'Disabled').dataset.color).toBe( + 'gray' + ); + expect( + within(getServiceCard('PostgreSQL')) + .getAllByTestId('badge') + .find((b) => b.textContent === 'Off').dataset.color + ).toBe('gray'); + expect(getBadgeByText('PostgreSQL', 'Inactive').dataset.color).toBe( + 'gray' + ); + }); + }); + + // ── Both services — independent state ──────────────────────────────────────── + + describe('both services — independent badge state', () => { + it('Redis and PostgreSQL badges reflect their own configs independently', () => { + setupStore({ + redis_tls: { enabled: true, verify: false, mtls: true }, + postgres_tls: { enabled: false }, + }); + render(); + + // Redis: enabled → green Enabled, verify=false → yellow Off, mtls=true → green Active + expect(getBadgeByText('Redis', 'Enabled').dataset.color).toBe('green'); + expect( + within(getServiceCard('Redis')) + .getAllByTestId('badge') + .find((b) => b.textContent === 'Off').dataset.color + ).toBe('yellow'); + expect(getBadgeByText('Redis', 'Active').dataset.color).toBe('green'); + + // Postgres: disabled → all gray/Off/Inactive + expect(getBadgeByText('PostgreSQL', 'Disabled').dataset.color).toBe( + 'gray' + ); + expect( + within(getServiceCard('PostgreSQL')) + .getAllByTestId('badge') + .find((b) => b.textContent === 'Off').dataset.color + ).toBe('gray'); + expect(getBadgeByText('PostgreSQL', 'Inactive').dataset.color).toBe( + 'gray' + ); + }); + }); + + // ── TlsOption description text ──────────────────────────────────────────────── + + describe('TlsOption description text', () => { + beforeEach(() => { + setupStore({ + redis_tls: { enabled: true, verify: true, mtls: true }, + postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: true }, + }); + render(); + }); + + it('renders Redis Encryption description', () => { + expect( + within(getServiceCard('Redis')).getByText( + 'Encrypt traffic between Dispatcharr and Redis.' + ) + ).toBeInTheDocument(); + }); + + it('renders Redis Server Verification description', () => { + expect( + within(getServiceCard('Redis')).getByText( + "Verify the Redis server's identity using a CA certificate." + ) + ).toBeInTheDocument(); + }); + + it('renders Redis Mutual TLS description', () => { + expect( + within(getServiceCard('Redis')).getByText( + 'Authenticate Dispatcharr to Redis using a client certificate.' + ) + ).toBeInTheDocument(); + }); + + it('renders PostgreSQL Encryption description', () => { + expect( + within(getServiceCard('PostgreSQL')).getByText( + 'Encrypt traffic between Dispatcharr and PostgreSQL.' + ) + ).toBeInTheDocument(); + }); + + it('renders PostgreSQL Verification Mode description', () => { + expect( + within(getServiceCard('PostgreSQL')).getByText( + "How strictly to verify the PostgreSQL server's identity." + ) + ).toBeInTheDocument(); + }); + + it('renders PostgreSQL Mutual TLS description', () => { + expect( + within(getServiceCard('PostgreSQL')).getByText( + 'Authenticate Dispatcharr to PostgreSQL using a client certificate.' + ) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/forms/settings/__tests__/EpgSettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/EpgSettingsForm.test.jsx new file mode 100644 index 00000000..88bac9fb --- /dev/null +++ b/frontend/src/components/forms/settings/__tests__/EpgSettingsForm.test.jsx @@ -0,0 +1,417 @@ +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 }) => ( +
+ {title} +
+ ), + Button: ({ children, type, disabled }) => ( + + ), + Flex: ({ children, justify }) =>
{children}
, + NumberInput: ({ label, description, min, max, value, onChange }) => ( +
+ {label && } + {description && ( + {description} + )} + onChange?.(Number(e.target.value))} + /> +
+ ), + Stack: ({ children, gap }) =>
{children}
, + Text: ({ children, size, c }) => ( + + {children} + + ), +})); + +// ── 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(); + expect(screen.getByTestId('number-input')).toBeInTheDocument(); + }); + + it('renders the NumberInput label from EPG_SETTINGS_OPTIONS', () => { + render(); + expect( + screen.getByText(EPG_SETTINGS_OPTIONS.xmltv_prev_days_override.label) + ).toBeInTheDocument(); + }); + + it('renders the NumberInput description from EPG_SETTINGS_OPTIONS', () => { + render(); + expect( + screen.getByText( + EPG_SETTINGS_OPTIONS.xmltv_prev_days_override.description + ) + ).toBeInTheDocument(); + }); + + it('renders the disclaimer text about per-user defaults', () => { + render(); + expect( + screen.getByText( + /Per-user defaults and URL parameters still override this global value/i + ) + ).toBeInTheDocument(); + }); + + it('renders the EPG channel matching hint', () => { + render(); + expect( + screen.getByText( + /EPG channel matching options are configured from the Channels page/i + ) + ).toBeInTheDocument(); + }); + + it('renders a Save button of type="submit"', () => { + render(); + const btn = screen.getByText('Save'); + expect(btn).toBeInTheDocument(); + expect(btn).toHaveAttribute('type', 'submit'); + }); + + it('does not show the "Saved Successfully" alert initially', () => { + render(); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + + it('NumberInput has min=0', () => { + render(); + expect(screen.getByTestId('number-input')).toHaveAttribute('min', '0'); + }); + + it('NumberInput has max=30', () => { + render(); + expect(screen.getByTestId('number-input')).toHaveAttribute('max', '30'); + }); + + it('NumberInput starts with value 0 from initial form values', () => { + render(); + expect(screen.getByTestId('number-input')).toHaveValue(0); + }); + }); + + // ── Initialization ───────────────────────────────────────────────────────── + + describe('initialization', () => { + it('calls getEpgSettingsFormInitialValues to seed useForm', () => { + render(); + expect(getEpgSettingsFormInitialValues).toHaveBeenCalled(); + }); + + it('passes initial values from getEpgSettingsFormInitialValues to useForm', () => { + vi.mocked(getEpgSettingsFormInitialValues).mockReturnValue({ + xmltv_prev_days_override: 5, + }); + render(); + expect(vi.mocked(useForm)).toHaveBeenCalledWith( + expect.objectContaining({ + initialValues: { xmltv_prev_days_override: 5 }, + }) + ); + }); + + it('calls useForm with mode="controlled"', () => { + render(); + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + + it('does not show alert when active starts as false', () => { + render(); + 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(); + // 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(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(SettingsUtils.saveChangedSettings).toHaveBeenCalledWith( + settings, + changed + ); + }); + }); + + it('shows "Saved Successfully" alert after a successful save', async () => { + render(); + 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(); + + // 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(); + 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(); + + 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(); + expect(mockForm.getInputProps).toHaveBeenCalledWith( + 'xmltv_prev_days_override' + ); + }); + }); +}); diff --git a/frontend/src/components/forms/settings/__tests__/UserLimitsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/UserLimitsForm.test.jsx new file mode 100644 index 00000000..642078c5 --- /dev/null +++ b/frontend/src/components/forms/settings/__tests__/UserLimitsForm.test.jsx @@ -0,0 +1,428 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import UserLimitsForm from '../UserLimitsForm'; + +// ── 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 }) => ( +
+ {title} +
+ ), + Button: ({ children, type, disabled, variant, color, onClick }) => ( + + ), + Checkbox: ({ label, description, checked, onChange }) => ( +
+ {label && } + {description && ( + {description} + )} + onChange?.(e.currentTarget.checked)} + /> +
+ ), + Flex: ({ children, mih, gap, justify, align }) => ( +
+ {children} +
+ ), + Stack: ({ children, gap }) =>
{children}
, +})); + +// ── SettingsUtils mock ──────────────────────────────────────────────────────── +vi.mock('../../../../utils/pages/SettingsUtils.js', () => ({ + updateSetting: vi.fn(), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import useSettingsStore from '../../../../store/settings.jsx'; +import { useForm } from '@mantine/form'; +import { updateSetting } from '../../../../utils/pages/SettingsUtils.js'; +import { USER_LIMITS_OPTIONS } from '../../../../constants.js'; + +// ── Derived constants (mirrors the component) ───────────────────────────────── +const USER_LIMIT_DEFAULTS = Object.keys(USER_LIMITS_OPTIONS).reduce( + (acc, key) => { + acc[key] = USER_LIMITS_OPTIONS[key].default; + return acc; + }, + {} +); + +// ── Form mock factory ───────────────────────────────────────────────────────── + +let mockForm; + +const createMockForm = (initialValues = { ...USER_LIMIT_DEFAULTS }) => { + const state = { ...initialValues }; + return { + values: state, + setValues: vi.fn((vals) => { + Object.assign(state, vals); + }), + getInputProps: vi.fn((field, opts) => { + if (opts?.type === 'checkbox') { + return { + checked: state[field], + onChange: vi.fn((val) => { + state[field] = val; + }), + }; + } + return { value: state[field], onChange: vi.fn() }; + }), + getValues: vi.fn(() => ({ ...state })), + onSubmit: vi.fn((handler) => (e) => { + e?.preventDefault?.(); + return handler(); + }), + submitting: false, + }; +}; + +// ── Settings factories ──────────────────────────────────────────────────────── + +const makeSettings = (userLimitValue = {}) => ({ + user_limit_settings: { id: 1, value: userLimitValue }, +}); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('UserLimitsForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockForm = createMockForm(); + vi.mocked(useForm).mockReturnValue(mockForm); + + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: null }) + ); + + vi.mocked(updateSetting).mockResolvedValue({ id: 1 }); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders without crashing', () => { + render(); + expect(screen.getAllByTestId('checkbox-wrapper')).toHaveLength( + Object.keys(USER_LIMITS_OPTIONS).length + ); + }); + + it('renders a Checkbox for every USER_LIMITS_OPTIONS entry', () => { + render(); + Object.values(USER_LIMITS_OPTIONS).forEach((opt) => { + expect(screen.getByText(opt.label)).toBeInTheDocument(); + }); + }); + + it('renders every checkbox description', () => { + render(); + Object.values(USER_LIMITS_OPTIONS).forEach((opt) => { + expect(screen.getByText(opt.description)).toBeInTheDocument(); + }); + }); + + it('renders a Save button of type="submit"', () => { + render(); + const btn = screen.getByText('Save'); + expect(btn).toHaveAttribute('type', 'submit'); + }); + + it('renders a "Reset to Defaults" button', () => { + render(); + expect(screen.getByText('Reset to Defaults')).toBeInTheDocument(); + }); + + it('does not show the "Saved Successfully" alert initially', () => { + render(); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + }); + + // ── Initialization ───────────────────────────────────────────────────────── + + describe('initialization', () => { + it('calls useForm with mode="controlled"', () => { + render(); + expect(vi.mocked(useForm)).toHaveBeenCalledWith( + expect.objectContaining({ mode: 'controlled' }) + ); + }); + + it('seeds useForm with USER_LIMIT_DEFAULTS as initialValues', () => { + render(); + expect(vi.mocked(useForm)).toHaveBeenCalledWith( + expect.objectContaining({ initialValues: USER_LIMIT_DEFAULTS }) + ); + }); + + it('calls getInputProps with type:"checkbox" for each option key', () => { + render(); + Object.keys(USER_LIMITS_OPTIONS).forEach((key) => { + expect(mockForm.getInputProps).toHaveBeenCalledWith(key, { + type: 'checkbox', + }); + }); + }); + }); + + // ── Settings effect ──────────────────────────────────────────────────────── + + describe('settings effect', () => { + it('calls setValues with merged defaults + stored values when settings provided', () => { + const stored = { + terminate_on_limit_exceeded: false, + ignore_same_channel_connections: true, + }; + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: makeSettings(stored) }) + ); + + render(); + + expect(mockForm.setValues).toHaveBeenCalledWith({ + ...USER_LIMIT_DEFAULTS, + ...stored, + }); + }); + + it('does not call setValues when settings is null', () => { + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: null }) + ); + + render(); + + expect(mockForm.setValues).not.toHaveBeenCalled(); + }); + + it('does not call setValues when user_limit_settings.value is absent', () => { + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: { user_limit_settings: { id: 1 } } }) + ); + + render(); + + expect(mockForm.setValues).not.toHaveBeenCalled(); + }); + }); + + // ── active prop effect ───────────────────────────────────────────────────── + + describe('active prop effect', () => { + it('resets the saved alert when active changes to false', async () => { + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: makeSettings({}) }) + ); + + const { rerender } = render(); + + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + }); + + rerender(); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + + it('does not show alert when active starts as false', () => { + render(); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + }); + + // ── Reset to Defaults ────────────────────────────────────────────────────── + + describe('"Reset to Defaults" button', () => { + it('calls form.setValues with USER_LIMIT_DEFAULTS when clicked', () => { + render(); + fireEvent.click(screen.getByText('Reset to Defaults')); + expect(mockForm.setValues).toHaveBeenCalledWith(USER_LIMIT_DEFAULTS); + }); + + it('can be clicked multiple times without error', () => { + render(); + fireEvent.click(screen.getByText('Reset to Defaults')); + fireEvent.click(screen.getByText('Reset to Defaults')); + expect(mockForm.setValues).toHaveBeenCalledTimes(2); + }); + }); + + // ── Submit — success ─────────────────────────────────────────────────────── + + describe('submit — success', () => { + it('calls updateSetting with merged user_limit_settings + current values', async () => { + const stored = { id: 42, key: 'user_limit_settings', value: {} }; + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: { user_limit_settings: stored } }) + ); + + render(); + // Set values after render to avoid settings effect overwriting them + Object.assign(mockForm.values, { terminate_on_limit_exceeded: false }); + + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(updateSetting).toHaveBeenCalledWith({ + ...stored, + value: expect.objectContaining({ + terminate_on_limit_exceeded: false, + }), + }); + }); + }); + + it('shows "Saved Successfully" alert when updateSetting returns a truthy result', async () => { + vi.mocked(updateSetting).mockResolvedValue({ id: 1 }); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: makeSettings({}) }) + ); + + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByText('Saved Successfully')).toBeInTheDocument(); + }); + }); + + it('does NOT show alert when updateSetting returns null/falsy', async () => { + vi.mocked(updateSetting).mockResolvedValue(null); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: makeSettings({}) }) + ); + + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(updateSetting).toHaveBeenCalled(); + }); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + + it('passes getValues() result as the value to updateSetting', async () => { + const currentValues = { + terminate_on_limit_exceeded: false, + prioritize_single_client_channels: true, + ignore_same_channel_connections: true, + terminate_oldest: false, + }; + mockForm.getValues.mockReturnValue(currentValues); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: makeSettings({}) }) + ); + + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(updateSetting).toHaveBeenCalledWith( + expect.objectContaining({ value: currentValues }) + ); + }); + }); + + it('clears saved=false at start of each submit before setting it true', async () => { + vi.mocked(updateSetting).mockResolvedValue({ id: 1 }); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: makeSettings({}) }) + ); + + render(); + + // First save + fireEvent.click(screen.getByText('Save')); + await waitFor(() => + expect(screen.getByTestId('alert')).toBeInTheDocument() + ); + + // Second save — alert should still appear after re-submit + fireEvent.click(screen.getByText('Save')); + await waitFor(() => + expect(screen.getByTestId('alert')).toBeInTheDocument() + ); + }); + }); + + // ── Submit — error ───────────────────────────────────────────────────────── + + describe('submit — error', () => { + it('does not show alert when updateSetting throws', async () => { + vi.mocked(updateSetting).mockRejectedValue(new Error('network error')); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: makeSettings({}) }) + ); + + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(updateSetting).toHaveBeenCalled(); + }); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + + it('does not throw to the caller when updateSetting rejects', async () => { + vi.mocked(updateSetting).mockRejectedValue(new Error('fail')); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: makeSettings({}) }) + ); + + render(); + + await expect( + waitFor(() => fireEvent.click(screen.getByText('Save'))) + ).resolves.not.toThrow(); + }); + }); + + // ── Submit disabled state ────────────────────────────────────────────────── + + describe('Save button disabled state', () => { + it('is disabled when form.submitting is true', () => { + mockForm.submitting = true; + render(); + expect(screen.getByText('Save')).toBeDisabled(); + }); + + it('is not disabled when form.submitting is false', () => { + mockForm.submitting = false; + render(); + expect(screen.getByText('Save')).not.toBeDisabled(); + }); + }); +}); From 65fcaa885ef5f3ed43d5450267f32234140d7877 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:57:21 -0700 Subject: [PATCH 6/7] Syntax formatting changes --- .../__tests__/CatchupIndicator.test.jsx | 20 +- .../__tests__/ConfirmationDialog.test.jsx | 26 ++- .../src/components/__tests__/Field.test.jsx | 9 +- .../components/__tests__/LazyLogo.test.jsx | 14 +- .../__tests__/ProgramDetailModal.test.jsx | 4 +- .../__tests__/ProgramPreview.test.jsx | 52 +---- .../__tests__/RecordingSynopsis.test.jsx | 11 +- .../__tests__/SystemEvents.test.jsx | 8 +- frontend/src/components/cards/PluginCard.jsx | 217 ++++++++++++++---- .../components/cards/StreamConnectionCard.jsx | 2 - .../cards/__tests__/PluginCard.test.jsx | 44 +++- .../forms/settings/EpgSettingsForm.jsx | 11 +- .../__tests__/ProxySettingsForm.test.jsx | 4 +- 13 files changed, 273 insertions(+), 149 deletions(-) diff --git a/frontend/src/components/__tests__/CatchupIndicator.test.jsx b/frontend/src/components/__tests__/CatchupIndicator.test.jsx index 057ffd2a..c2c9985c 100644 --- a/frontend/src/components/__tests__/CatchupIndicator.test.jsx +++ b/frontend/src/components/__tests__/CatchupIndicator.test.jsx @@ -10,9 +10,7 @@ vi.mock('@mantine/core', () => ({
), Box: ({ children, ...props }) => {children}, - Tooltip: ({ children, label }) => ( -
{children}
- ), + Tooltip: ({ children, label }) =>
{children}
, })); vi.mock('lucide-react', () => ({ @@ -21,12 +19,8 @@ vi.mock('lucide-react', () => ({ describe('formatCatchupTooltip', () => { it('includes archive days when known', () => { - expect(formatCatchupTooltip(7)).toBe( - 'Catch-up enabled (7 days archive)' - ); - expect(formatCatchupTooltip(1)).toBe( - 'Catch-up enabled (1 day archive)' - ); + expect(formatCatchupTooltip(7)).toBe('Catch-up enabled (7 days archive)'); + expect(formatCatchupTooltip(1)).toBe('Catch-up enabled (1 day archive)'); }); it('falls back when days are zero', () => { @@ -45,13 +39,13 @@ describe('CatchupIndicator', () => { it('renders a history icon in table rows', () => { render(); expect(screen.getByTestId('icon-history')).toBeInTheDocument(); - expect(screen.getByLabelText('Catch-up enabled (3 days archive)')).toBeInTheDocument(); + expect( + screen.getByLabelText('Catch-up enabled (3 days archive)') + ).toBeInTheDocument(); }); it('renders a badge in expanded stream rows', () => { - render( - - ); + render(); expect(screen.getByTestId('catchup-badge')).toHaveTextContent('Catch-up'); expect(screen.getByTestId('icon-history')).toBeInTheDocument(); }); diff --git a/frontend/src/components/__tests__/ConfirmationDialog.test.jsx b/frontend/src/components/__tests__/ConfirmationDialog.test.jsx index 741f2f16..851bb2dd 100644 --- a/frontend/src/components/__tests__/ConfirmationDialog.test.jsx +++ b/frontend/src/components/__tests__/ConfirmationDialog.test.jsx @@ -24,11 +24,7 @@ vi.mock('@mantine/core', async () => { ), Checkbox: ({ label, checked, onChange }) => ( ), @@ -64,7 +60,9 @@ describe('ConfirmationDialog', () => { ); expect(screen.getByTestId('modal')).toBeInTheDocument(); - expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument(); + expect( + screen.getByText('Are you sure you want to proceed?') + ).toBeInTheDocument(); }); it('should not render when closed', () => { @@ -91,7 +89,9 @@ describe('ConfirmationDialog', () => { ); expect(screen.getByTestId('modal-title')).toHaveTextContent('Delete Item'); - expect(screen.getByText('This action cannot be undone')).toBeInTheDocument(); + expect( + screen.getByText('This action cannot be undone') + ).toBeInTheDocument(); }); it('should call onConfirm when confirm button is clicked', () => { @@ -144,7 +144,9 @@ describe('ConfirmationDialog', () => { /> ); - expect(screen.queryByLabelText("Don't ask me again")).not.toBeInTheDocument(); + expect( + screen.queryByLabelText("Don't ask me again") + ).not.toBeInTheDocument(); }); it('should call suppressWarning when suppress is checked and confirmed', () => { @@ -188,7 +190,9 @@ describe('ConfirmationDialog', () => { /> ); - expect(screen.getByLabelText('Also delete files from disk')).toBeInTheDocument(); + expect( + screen.getByLabelText('Also delete files from disk') + ).toBeInTheDocument(); }); it('should pass deleteFiles state to onConfirm when delete option is checked', () => { @@ -229,7 +233,9 @@ describe('ConfirmationDialog', () => { /> ); - expect(screen.getByLabelText('Also delete files from disk')).not.toBeChecked(); + expect( + screen.getByLabelText('Also delete files from disk') + ).not.toBeChecked(); }); it('should show loading state on confirm button', () => { diff --git a/frontend/src/components/__tests__/Field.test.jsx b/frontend/src/components/__tests__/Field.test.jsx index 092871f1..9a2c33c8 100644 --- a/frontend/src/components/__tests__/Field.test.jsx +++ b/frontend/src/components/__tests__/Field.test.jsx @@ -520,7 +520,9 @@ describe('Field', () => { render(); expect(screen.getByText('Help text takes priority')).toBeInTheDocument(); - expect(screen.queryByText('This should not appear')).not.toBeInTheDocument(); + expect( + screen.queryByText('This should not appear') + ).not.toBeInTheDocument(); }); it('should use field.value if no help_text or description', () => { @@ -562,7 +564,10 @@ describe('Field', () => { render(); - expect(screen.getByLabelText('Password')).toHaveAttribute('type', 'password'); + expect(screen.getByLabelText('Password')).toHaveAttribute( + 'type', + 'password' + ); }); it('should render text input when input_type is not password', () => { diff --git a/frontend/src/components/__tests__/LazyLogo.test.jsx b/frontend/src/components/__tests__/LazyLogo.test.jsx index dfd9d27c..16076067 100644 --- a/frontend/src/components/__tests__/LazyLogo.test.jsx +++ b/frontend/src/components/__tests__/LazyLogo.test.jsx @@ -18,7 +18,13 @@ vi.mock('../../images/logo.png', () => ({ vi.mock('@mantine/core', async () => { return { Skeleton: ({ height, width, style, ...props }) => { - return
; + return ( +
+ ); }, }; }); @@ -103,7 +109,11 @@ describe('LazyLogo', () => { }; render( - + ); const img = screen.getByTestId('custom-logo'); diff --git a/frontend/src/components/__tests__/ProgramDetailModal.test.jsx b/frontend/src/components/__tests__/ProgramDetailModal.test.jsx index 0127d8f9..526b99a2 100644 --- a/frontend/src/components/__tests__/ProgramDetailModal.test.jsx +++ b/frontend/src/components/__tests__/ProgramDetailModal.test.jsx @@ -12,7 +12,9 @@ vi.mock('../../utils/cards/RecordingCardUtils.js', () => ({ getShowVideoUrl: vi.fn(() => 'http://video.test'), })); -vi.mock('../../images/logo.png', () => ({ default: 'default-logo.png' })); +vi.mock('../../images/logo.png', () => ({ + default: 'default-logo.png', +})); vi.mock('../../utils/dateTimeUtils.js', () => ({ format: vi.fn(() => '12:00 PM'), diff --git a/frontend/src/components/__tests__/ProgramPreview.test.jsx b/frontend/src/components/__tests__/ProgramPreview.test.jsx index 46b744cb..058ce78d 100644 --- a/frontend/src/components/__tests__/ProgramPreview.test.jsx +++ b/frontend/src/components/__tests__/ProgramPreview.test.jsx @@ -12,9 +12,7 @@ vi.mock('@mantine/core', () => { ), Box: ({ children, ...props }) =>
{children}
, Group: ({ children }) =>
{children}
, - Progress: ({ value }) => ( -
- ), + Progress: ({ value }) =>
, Stack: ({ children }) =>
{children}
, Text: ({ children }) => {children}, Tooltip: ({ children }) => <>{children}, @@ -61,13 +59,7 @@ describe('ProgramPreview', () => { start_time: new Date(now - 1800000).toISOString(), end_time: new Date(now + 1800000).toISOString(), }; - render( - - ); + render(); expect(screen.getByText('Test Show')).toBeTruthy(); }); @@ -86,13 +78,7 @@ describe('ProgramPreview', () => { it('shows default label "Now Playing:"', () => { const program = { title: 'Show', start_time: null, end_time: null }; - render( - - ); + render(); expect(screen.getByText('Now Playing:')).toBeTruthy(); }); @@ -107,13 +93,7 @@ describe('ProgramPreview', () => { start_time: new Date(now - 3600000).toISOString(), end_time: new Date(now + 3600000).toISOString(), }; - render( - - ); + render(); // Description not visible initially expect(screen.queryByText('A detailed description')).toBeNull(); @@ -149,13 +129,7 @@ describe('ProgramPreview', () => { start_time: new Date(now - 3600000).toISOString(), end_time: new Date(now + 3600000).toISOString(), }; - render( - - ); + render(); // Expand const expandButton = screen.getByTestId('chevron-right').closest('button'); @@ -181,13 +155,7 @@ describe('formatProgramTime', () => { start_time: new Date(now - 2 * 3600000 - 30 * 60000).toISOString(), // 2h30m ago end_time: new Date(now + 30 * 60000).toISOString(), // 30m from now }; - render( - - ); + render(); // Expand to see time const expandButton = screen.getByTestId('chevron-right').closest('button'); @@ -209,13 +177,7 @@ describe('formatProgramTime', () => { start_time: new Date(now - 5 * 60000).toISOString(), // 5m ago end_time: new Date(now + 25 * 60000).toISOString(), // 25m from now }; - render( - - ); + render(); const expandButton = screen.getByTestId('chevron-right').closest('button'); fireEvent.click(expandButton); diff --git a/frontend/src/components/__tests__/RecordingSynopsis.test.jsx b/frontend/src/components/__tests__/RecordingSynopsis.test.jsx index 482d7e41..5d840788 100644 --- a/frontend/src/components/__tests__/RecordingSynopsis.test.jsx +++ b/frontend/src/components/__tests__/RecordingSynopsis.test.jsx @@ -6,7 +6,16 @@ import RecordingSynopsis from '../RecordingSynopsis'; // Mock Mantine components vi.mock('@mantine/core', async () => { return { - Text: ({ children, size, c, lineClamp, onClick, title, style, ...props }) => { + Text: ({ + children, + size, + c, + lineClamp, + onClick, + title, + style, + ...props + }) => { return (
({ @@ -158,7 +158,7 @@ describe('SystemEvents', () => { fireEvent.click(refreshButton); await waitFor(() => { - expect(API.getSystemEvents).toHaveBeenCalledTimes(3) + expect(API.getSystemEvents).toHaveBeenCalledTimes(3); }); }); @@ -203,7 +203,9 @@ describe('SystemEvents', () => { }); it('should handle API errors gracefully', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); API.getSystemEvents.mockRejectedValue(new Error('API Error')); render(); diff --git a/frontend/src/components/cards/PluginCard.jsx b/frontend/src/components/cards/PluginCard.jsx index 7aa71a46..76b9da26 100644 --- a/frontend/src/components/cards/PluginCard.jsx +++ b/frontend/src/components/cards/PluginCard.jsx @@ -17,7 +17,17 @@ import { Text, Tooltip, } from '@mantine/core'; -import { Ban, Check, Download, FlaskConical, Info, RefreshCw, Settings, Trash2, Zap } from 'lucide-react'; +import { + Ban, + Check, + Download, + FlaskConical, + Info, + RefreshCw, + Settings, + Trash2, + Zap, +} from 'lucide-react'; import { getConfirmationDetails } from '../../utils/cards/PluginCardUtils.js'; import { SUBSCRIPTION_EVENTS } from '../../constants.js'; import useSettingsStore from '../../store/settings.jsx'; @@ -65,7 +75,12 @@ const PluginActionList = ({ Event Triggers {events.map((event) => ( - + {SUBSCRIPTION_EVENTS[event] || event} ))} @@ -154,19 +169,28 @@ const PluginCard = ({ const fetchDetail = async () => { if (detailLoading || !isManaged) return; // Find the available plugin entry for manifest_url - let avail = usePluginStore.getState().availablePlugins.find( - (ap) => ap.slug === plugin.slug && ap.repo_id === plugin.source_repo - ); + let avail = usePluginStore + .getState() + .availablePlugins.find( + (ap) => ap.slug === plugin.slug && ap.repo_id === plugin.source_repo + ); if (!avail) { setDetailLoading(true); try { await usePluginStore.getState().fetchAvailablePlugins(); - avail = usePluginStore.getState().availablePlugins.find( - (ap) => ap.slug === plugin.slug && ap.repo_id === plugin.source_repo - ); - } catch { /* ignore */ } + avail = usePluginStore + .getState() + .availablePlugins.find( + (ap) => ap.slug === plugin.slug && ap.repo_id === plugin.source_repo + ); + } catch { + /* ignore */ + } + } + if (!avail) { + setDetailLoading(false); + return; } - if (!avail) { setDetailLoading(false); return; } if (!avail.manifest_url) { // Synthesize from top-level entry setDetail({ @@ -177,16 +201,22 @@ const PluginCard = ({ repo_url: avail.repo_url, discord_thread: avail.discord_thread, registry_url: avail.registry_url, - versions: avail.latest_version ? [{ - version: avail.latest_version, - url: avail.latest_url, - checksum_sha256: avail.latest_sha256, - min_dispatcharr_version: avail.min_dispatcharr_version, - max_dispatcharr_version: avail.max_dispatcharr_version, - build_timestamp: avail.last_updated, - size: avail.latest_size, - }] : [], - latest: avail.latest_version ? { version: avail.latest_version } : null, + versions: avail.latest_version + ? [ + { + version: avail.latest_version, + url: avail.latest_url, + checksum_sha256: avail.latest_sha256, + min_dispatcharr_version: avail.min_dispatcharr_version, + max_dispatcharr_version: avail.max_dispatcharr_version, + build_timestamp: avail.last_updated, + size: avail.latest_size, + }, + ] + : [], + latest: avail.latest_version + ? { version: avail.latest_version } + : null, }, signature_verified: avail.signature_verified ?? null, _avail: avail, @@ -197,7 +227,10 @@ const PluginCard = ({ } setDetailLoading(true); try { - const result = await API.getPluginDetailManifest(avail.repo_id, avail.manifest_url); + const result = await API.getPluginDetailManifest( + avail.repo_id, + avail.manifest_url + ); if (result) { setDetail({ ...result, _avail: avail }); if (result.manifest?.versions?.length) { @@ -326,7 +359,8 @@ const PluginCard = ({ if (!pendingInstallParams) return; const params = pendingInstallParams; const selVer = params.version; - const isDown = plugin.version && compareVersions(selVer, plugin.version) < 0; + const isDown = + plugin.version && compareVersions(selVer, plugin.version) < 0; const action = isDown ? 'downgrade' : 'update'; setInstallConfirmOpen(false); setPendingInstallParams(null); @@ -367,7 +401,12 @@ const PluginCard = ({ > {/* Header: avatar, name/author, badges, toggle */} - + openModal('details') : undefined} - style={{ minWidth: 0, maxWidth: '100%', ...(isManaged ? { cursor: 'pointer' } : {}) }} + style={{ + minWidth: 0, + maxWidth: '100%', + ...(isManaged ? { cursor: 'pointer' } : {}), + }} > {plugin.author} @@ -415,12 +458,26 @@ const PluginCard = ({ {plugin.is_managed && plugin.installed_version_is_prerelease ? ( - + : plugin.deprecated ? : } + leftSection={ + detailLoading ? ( + + ) : plugin.deprecated ? ( + + ) : ( + + ) + } style={{ cursor: 'pointer' }} onClick={() => openModal('details')} > @@ -428,12 +485,26 @@ const PluginCard = ({ ) : plugin.update_available ? ( - + : plugin.deprecated ? : } + leftSection={ + detailLoading ? ( + + ) : plugin.deprecated ? ( + + ) : ( + + ) + } style={{ cursor: 'pointer' }} onClick={() => openModal('details')} > @@ -441,12 +512,26 @@ const PluginCard = ({ ) : plugin.is_managed ? ( - + : plugin.deprecated ? : } + leftSection={ + detailLoading ? ( + + ) : plugin.deprecated ? ( + + ) : ( + + ) + } style={{ cursor: 'pointer' }} onClick={() => openModal('details')} > @@ -489,8 +574,8 @@ const PluginCard = ({ - VERSION - v{plugin.version || '1.0.0'} + VERSIONv + {plugin.version || '1.0.0'} {plugin.is_managed && plugin.source_repo_name && ( @@ -542,7 +627,13 @@ const PluginCard = ({ onClose={() => setModalOpen(false)} title={ - + {plugin.name?.[0]?.toUpperCase()} {plugin.name} @@ -550,11 +641,29 @@ const PluginCard = ({ } size="lg" > - { setModalTab(tab); if (tab === 'details') fetchDetail(); }}> + { + setModalTab(tab); + if (tab === 'details') fetchDetail(); + }} + > - {isManaged && }>Details} - {hasFields && }>Settings} - {hasActions && }>Actions} + {isManaged && ( + }> + Details + + )} + {hasFields && ( + }> + Settings + + )} + {hasActions && ( + }> + Actions + + )} {isManaged && ( @@ -565,7 +674,9 @@ const PluginCard = ({ selectedVersion={selectedVersion} onVersionChange={setSelectedVersion} installedVersion={plugin.version} - installedVersionIsPrerelease={!!plugin.installed_version_is_prerelease} + installedVersionIsPrerelease={ + !!plugin.installed_version_is_prerelease + } appVersion={appVersion} installing={installing} uninstalling={uninstalling} @@ -631,7 +742,10 @@ const PluginCard = ({ {/* Install confirmation modal */} {(() => { const selVer = pendingInstallParams?.version; - const isDown = plugin.version && selVer && compareVersions(selVer, plugin.version) < 0; + const isDown = + plugin.version && + selVer && + compareVersions(selVer, plugin.version) < 0; const actionLabel = isDown ? 'Downgrade' : 'Update'; return ( - {isDown - ? - : } + {isDown ? ( + + ) : ( + + )} Confirm {actionLabel} } @@ -653,14 +769,15 @@ const PluginCard = ({ > - You are about to {actionLabel.toLowerCase()} {plugin.name}{' '} - from v{plugin.version} to v{selVer}. + You are about to {actionLabel.toLowerCase()}{' '} + {plugin.name} from v{plugin.version} to{' '} + v{selVer}. - Plugins run server-side code with full access to your Dispatcharr - instance and its data. Only install plugins from developers you - trust. Malicious plugins could read or modify data, call internal - APIs, or perform unwanted actions. + Plugins run server-side code with full access to your + Dispatcharr instance and its data. Only install plugins from + developers you trust. Malicious plugins could read or modify + data, call internal APIs, or perform unwanted actions. {isDown && ( @@ -668,7 +785,9 @@ const PluginCard = ({ Downgrading may cause issues with saved settings or data. )} - Are you sure you want to proceed? + + Are you sure you want to proceed? +