From a772f5c353d26d0479da5379aedcfadd5cb8336e Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 13 Jan 2026 17:03:30 -0600 Subject: [PATCH 1/3] changelog: Update missed close on 0.17.0 changelog. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51ada8e0..d5d42062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Settings architecture refactored to use grouped JSON storage: Migrated from individual CharField settings to grouped JSONField settings for improved performance, maintainability, and type safety. Settings are now organized into logical groups (stream_settings, dvr_settings, backup_settings, system_settings, proxy_settings, network_access) with automatic migration handling. Backend provides helper methods (`get_stream_settings()`, `get_default_user_agent_id()`, etc.) for easy access. Frontend simplified by removing complex key mapping logic and standardizing on underscore-based field names throughout. -- Docker setup enhanced for legacy CPU support: Added `USE_LEGACY_NUMPY` environment variable to enable custom-built NumPy with no CPU baseline, allowing Dispatcharr to run on older CPUs (circa 2009) that lack support for newer baseline CPU features. When set to `true`, the entrypoint script will install the legacy NumPy build instead of the standard distribution. +- Docker setup enhanced for legacy CPU support: Added `USE_LEGACY_NUMPY` environment variable to enable custom-built NumPy with no CPU baseline, allowing Dispatcharr to run on older CPUs (circa 2009) that lack support for newer baseline CPU features. When set to `true`, the entrypoint script will install the legacy NumPy build instead of the standard distribution. (Fixes #805) - VOD upstream read timeout reduced from 30 seconds to 10 seconds to minimize lock hold time when clients disconnect during connection phase - Form management refactored across application: Migrated Channel, Stream, M3U Profile, Stream Profile, Logo, and User Agent forms from Formik to React Hook Form (RHF) with Yup validation for improved form handling, better validation feedback, and enhanced code maintainability - Stats and VOD pages refactored for clearer separation of concerns: extracted Stream/VOD connection cards (StreamConnectionCard, VodConnectionCard, VODCard, SeriesCard), moved page logic into dedicated utils, and lazy-loaded heavy components with ErrorBoundary fallbacks to improve readability and maintainability - Thanks [@nick4810](https://github.com/nick4810) From 38fa0fe99da595d53d241fbb18ca53e65302e47a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 14 Jan 2026 17:10:06 -0600 Subject: [PATCH 2/3] Bug Fix: Fixed NumPy baseline detection in Docker entrypoint. Now calls `numpy.show_config()` directly with case-insensitive grep instead of incorrectly wrapping the output. --- CHANGELOG.md | 4 ++++ docker/entrypoint.sh | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5d42062..1aa7a3f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Fixed NumPy baseline detection in Docker entrypoint. Now calls `numpy.show_config()` directly with case-insensitive grep instead of incorrectly wrapping the output. + ## [0.17.0] - 2026-01-13 ### Added diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 1622097b..a50f2f49 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -30,7 +30,7 @@ echo_with_timestamp() { # --- NumPy version switching for legacy hardware --- if [ "$USE_LEGACY_NUMPY" = "true" ]; then # Check if NumPy was compiled with baseline support - if /dispatcharrpy/bin/python -c "import numpy; print(str(numpy.show_config()).lower())" 2>/dev/null | grep -q "baseline"; then + if /dispatcharrpy/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline"; then echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..." /dispatcharrpy/bin/pip install --no-cache-dir --force-reinstall --no-deps /opt/numpy-*.whl echo_with_timestamp "✅ Legacy NumPy installed" From 54644df9a3f2b1edec12d2cfad86975e4078c409 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 15 Jan 2026 08:55:38 -0600 Subject: [PATCH 3/3] Test Fix: Fixed SettingsUtils frontend tests for new grouped settings architecture. Updated test suite to properly verify grouped JSON settings (stream_settings, dvr_settings, etc.) instead of individual CharField settings, including tests for type conversions, array-to-CSV transformations, and special handling of proxy_settings and network_access. Frontend tests GitHub workflow now uses Node.js 24 (matching Dockerfile) and runs on both `main` and `dev` branch pushes and pull requests for comprehensive CI coverage. --- .github/workflows/frontend-tests.yml | 10 +- CHANGELOG.md | 5 + .../src/utils/__tests__/dateTimeUtils.test.js | 1 + .../StreamConnectionCardUtils.test.js | 5 +- frontend/src/utils/networkUtils.js | 6 +- .../pages/__tests__/SettingsUtils.test.js | 675 +++++++----------- 6 files changed, 281 insertions(+), 421 deletions(-) diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index 828bdc43..b279a82a 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -2,9 +2,9 @@ name: Frontend Tests on: push: - branches: [ main ] + branches: [main, dev] pull_request: - branches: [ main ] + branches: [main, dev] jobs: test: @@ -21,15 +21,15 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: './frontend/package-lock.json' - name: Install dependencies run: npm ci -# - name: Run linter -# run: npm run lint + # - name: Run linter + # run: npm run lint - name: Run tests run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aa7a3f2..610c2ee5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Frontend tests GitHub workflow now uses Node.js 24 (matching Dockerfile) and runs on both `main` and `dev` branch pushes and pull requests for comprehensive CI coverage. + ### Fixed - Fixed NumPy baseline detection in Docker entrypoint. Now calls `numpy.show_config()` directly with case-insensitive grep instead of incorrectly wrapping the output. +- Fixed SettingsUtils frontend tests for new grouped settings architecture. Updated test suite to properly verify grouped JSON settings (stream_settings, dvr_settings, etc.) instead of individual CharField settings, including tests for type conversions, array-to-CSV transformations, and special handling of proxy_settings and network_access. ## [0.17.0] - 2026-01-13 diff --git a/frontend/src/utils/__tests__/dateTimeUtils.test.js b/frontend/src/utils/__tests__/dateTimeUtils.test.js index 852501e9..54644dcd 100644 --- a/frontend/src/utils/__tests__/dateTimeUtils.test.js +++ b/frontend/src/utils/__tests__/dateTimeUtils.test.js @@ -293,6 +293,7 @@ describe('dateTimeUtils', () => { const converted = result.current.toUserTime(null); + expect(converted).toBeDefined(); expect(converted.isValid()).toBe(false); }); diff --git a/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js index f48f1c1c..92c028c9 100644 --- a/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js +++ b/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js @@ -14,16 +14,15 @@ describe('StreamConnectionCardUtils', () => { describe('getBufferingSpeedThreshold', () => { it('should return parsed buffering_speed from proxy settings', () => { const proxySetting = { - value: JSON.stringify({ buffering_speed: 2.5 }) + value: { buffering_speed: 2.5 } }; expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(2.5); }); it('should return 1.0 for invalid JSON', () => { - const proxySetting = { value: 'invalid json' }; + const proxySetting = { value: { buffering_speed: 'invalid' } }; const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(1.0); - expect(consoleSpy).toHaveBeenCalled(); consoleSpy.mockRestore(); }); diff --git a/frontend/src/utils/networkUtils.js b/frontend/src/utils/networkUtils.js index d8131229..8efd2254 100644 --- a/frontend/src/utils/networkUtils.js +++ b/frontend/src/utils/networkUtils.js @@ -1,7 +1,9 @@ -export const IPV4_CIDR_REGEX = /^([0-9]{1,3}\.){3}[0-9]{1,3}\/\d+$/; +// IPv4 CIDR regex - validates IP address and prefix length (0-32) +export const IPV4_CIDR_REGEX = /^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\/(3[0-2]|[12]?[0-9])$/; +// IPv6 CIDR regex - validates IPv6 address and prefix length (0-128) export const IPV6_CIDR_REGEX = - /(?:(?:(?:[A-F0-9]{1,4}:){6}|(?=(?:[A-F0-9]{0,4}:){0,6}(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![:.\w]))(([0-9A-F]{1,4}:){0,5}|:)((:[0-9A-F]{1,4}){1,5}:|:)|::(?:[A-F0-9]{1,4}:){5})(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}|(?=(?:[A-F0-9]{0,4}:){0,7}[A-F0-9]{0,4}(?![:.\w]))(([0-9A-F]{1,4}:){1,7}|:)((:[0-9A-F]{1,4}){1,7}|:)|(?:[A-F0-9]{1,4}:){7}:|:(:[A-F0-9]{1,4}){7})(?![:.\w])\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])/; + /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/; export function formatBytes(bytes) { if (bytes === 0) return '0 Bytes'; diff --git a/frontend/src/utils/pages/__tests__/SettingsUtils.test.js b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js index 9bf20b13..1611c7d3 100644 --- a/frontend/src/utils/pages/__tests__/SettingsUtils.test.js +++ b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js @@ -19,540 +19,393 @@ describe('SettingsUtils', () => { describe('checkSetting', () => { it('should call API checkSetting with values', async () => { const values = { key: 'test-setting', value: 'test-value' }; - await SettingsUtils.checkSetting(values); - expect(API.checkSetting).toHaveBeenCalledWith(values); expect(API.checkSetting).toHaveBeenCalledTimes(1); }); - - it('should return API response', async () => { - const values = { key: 'test-setting', value: 'test-value' }; - const mockResponse = { valid: true }; - - API.checkSetting.mockResolvedValue(mockResponse); - - const result = await SettingsUtils.checkSetting(values); - - expect(result).toEqual(mockResponse); - }); - - it('should propagate API errors', async () => { - const values = { key: 'test-setting', value: 'test-value' }; - const error = new Error('API error'); - - API.checkSetting.mockRejectedValue(error); - - await expect(SettingsUtils.checkSetting(values)).rejects.toThrow('API error'); - }); }); describe('updateSetting', () => { it('should call API updateSetting with values', async () => { const values = { id: 1, key: 'test-setting', value: 'new-value' }; - await SettingsUtils.updateSetting(values); - expect(API.updateSetting).toHaveBeenCalledWith(values); expect(API.updateSetting).toHaveBeenCalledTimes(1); }); - - it('should return API response', async () => { - const values = { id: 1, key: 'test-setting', value: 'new-value' }; - const mockResponse = { id: 1, value: 'new-value' }; - - API.updateSetting.mockResolvedValue(mockResponse); - - const result = await SettingsUtils.updateSetting(values); - - expect(result).toEqual(mockResponse); - }); - - it('should propagate API errors', async () => { - const values = { id: 1, key: 'test-setting', value: 'new-value' }; - const error = new Error('Update failed'); - - API.updateSetting.mockRejectedValue(error); - - await expect(SettingsUtils.updateSetting(values)).rejects.toThrow('Update failed'); - }); }); describe('createSetting', () => { it('should call API createSetting with values', async () => { const values = { key: 'new-setting', name: 'New Setting', value: 'value' }; - await SettingsUtils.createSetting(values); - expect(API.createSetting).toHaveBeenCalledWith(values); expect(API.createSetting).toHaveBeenCalledTimes(1); }); - - it('should return API response', async () => { - const values = { key: 'new-setting', name: 'New Setting', value: 'value' }; - const mockResponse = { id: 1, ...values }; - - API.createSetting.mockResolvedValue(mockResponse); - - const result = await SettingsUtils.createSetting(values); - - expect(result).toEqual(mockResponse); - }); - - it('should propagate API errors', async () => { - const values = { key: 'new-setting', name: 'New Setting', value: 'value' }; - const error = new Error('Create failed'); - - API.createSetting.mockRejectedValue(error); - - await expect(SettingsUtils.createSetting(values)).rejects.toThrow('Create failed'); - }); }); describe('rehashStreams', () => { it('should call API rehashStreams', async () => { await SettingsUtils.rehashStreams(); - expect(API.rehashStreams).toHaveBeenCalledWith(); expect(API.rehashStreams).toHaveBeenCalledTimes(1); }); - - it('should return API response', async () => { - const mockResponse = { success: true }; - - API.rehashStreams.mockResolvedValue(mockResponse); - - const result = await SettingsUtils.rehashStreams(); - - expect(result).toEqual(mockResponse); - }); - - it('should propagate API errors', async () => { - const error = new Error('Rehash failed'); - - API.rehashStreams.mockRejectedValue(error); - - await expect(SettingsUtils.rehashStreams()).rejects.toThrow('Rehash failed'); - }); }); describe('saveChangedSettings', () => { - it('should update existing settings', async () => { + it('should group stream settings correctly and update', async () => { const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'old-value' } + stream_settings: { + id: 1, + key: 'stream_settings', + value: { + default_user_agent: 5, + m3u_hash_key: 'channel_name' + } + } }; const changedSettings = { - 'setting-1': 'new-value' + default_user_agent: 7, + preferred_region: 'UK' }; - API.updateSetting.mockResolvedValue({ id: 1, value: 'new-value' }); + API.updateSetting.mockResolvedValue({}); await SettingsUtils.saveChangedSettings(settings, changedSettings); expect(API.updateSetting).toHaveBeenCalledWith({ id: 1, - key: 'setting-1', - value: 'new-value' + key: 'stream_settings', + value: { + default_user_agent: 7, + m3u_hash_key: 'channel_name', + preferred_region: 'UK' + } }); }); - it('should create new settings when not in settings object', async () => { - const settings = {}; + it('should convert m3u_hash_key array to comma-separated string', async () => { + const settings = { + stream_settings: { + id: 1, + key: 'stream_settings', + value: {} + } + }; const changedSettings = { - 'new-setting': 'value' + m3u_hash_key: ['channel_name', 'channel_number'] }; - API.createSetting.mockResolvedValue({ id: 1, key: 'new-setting', value: 'value' }); + API.updateSetting.mockResolvedValue({}); await SettingsUtils.saveChangedSettings(settings, changedSettings); - expect(API.createSetting).toHaveBeenCalledWith({ - key: 'new-setting', - name: 'new setting', - value: 'value' + expect(API.updateSetting).toHaveBeenCalledWith({ + id: 1, + key: 'stream_settings', + value: { + m3u_hash_key: 'channel_name,channel_number' + } }); }); - it('should create new settings when existing has no id', async () => { + it('should convert ID fields to integers', async () => { const settings = { - 'setting-1': { key: 'setting-1', value: 'old-value' } + stream_settings: { + id: 1, + key: 'stream_settings', + value: {} + } }; const changedSettings = { - 'setting-1': 'new-value' + default_user_agent: '5', + default_stream_profile: '3' }; - API.createSetting.mockResolvedValue({ id: 1, key: 'setting-1', value: 'new-value' }); + API.updateSetting.mockResolvedValue({}); await SettingsUtils.saveChangedSettings(settings, changedSettings); - expect(API.createSetting).toHaveBeenCalledWith({ - key: 'setting-1', - name: 'setting 1', - value: 'new-value' + expect(API.updateSetting).toHaveBeenCalledWith({ + id: 1, + key: 'stream_settings', + value: { + default_user_agent: 5, + default_stream_profile: 3 + } }); }); - it('should replace hyphens with spaces in name', async () => { - const settings = {}; - const changedSettings = { - 'multi-word-setting': 'value' - }; - - API.createSetting.mockResolvedValue({ id: 1 }); - - await SettingsUtils.saveChangedSettings(settings, changedSettings); - - expect(API.createSetting).toHaveBeenCalledWith({ - key: 'multi-word-setting', - name: 'multi word setting', - value: 'value' - }); - }); - - it('should throw error when update fails', async () => { + it('should preserve boolean types', async () => { const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'old-value' } + dvr_settings: { + id: 2, + key: 'dvr_settings', + value: {} + }, + stream_settings: { + id: 1, + key: 'stream_settings', + value: {} + } }; const changedSettings = { - 'setting-1': 'new-value' + comskip_enabled: true, + auto_import_mapped_files: false }; - API.updateSetting.mockResolvedValue(undefined); - - await expect( - SettingsUtils.saveChangedSettings(settings, changedSettings) - ).rejects.toThrow('Failed to update setting'); - }); - - it('should throw error when create fails', async () => { - const settings = {}; - const changedSettings = { - 'new-setting': 'value' - }; - - API.createSetting.mockResolvedValue(undefined); - - await expect( - SettingsUtils.saveChangedSettings(settings, changedSettings) - ).rejects.toThrow('Failed to create setting'); - }); - - it('should process multiple changed settings', async () => { - const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'old-value-1' }, - 'setting-2': { id: 2, key: 'setting-2', value: 'old-value-2' } - }; - const changedSettings = { - 'setting-1': 'new-value-1', - 'setting-2': 'new-value-2', - 'setting-3': 'new-value-3' - }; - - API.updateSetting.mockResolvedValue({ success: true }); - API.createSetting.mockResolvedValue({ success: true }); + API.updateSetting.mockResolvedValue({}); await SettingsUtils.saveChangedSettings(settings, changedSettings); expect(API.updateSetting).toHaveBeenCalledTimes(2); - expect(API.createSetting).toHaveBeenCalledTimes(1); }); - it('should handle empty changedSettings', async () => { + it('should handle proxy_settings specially', async () => { const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'value' } + proxy_settings: { + id: 5, + key: 'proxy_settings', + value: { + buffering_speed: 1.0 + } + } }; - const changedSettings = {}; + const changedSettings = { + proxy_settings: { + buffering_speed: 2.5, + buffering_timeout: 15 + } + }; + + API.updateSetting.mockResolvedValue({}); await SettingsUtils.saveChangedSettings(settings, changedSettings); - expect(API.updateSetting).not.toHaveBeenCalled(); - expect(API.createSetting).not.toHaveBeenCalled(); - }); - }); - - describe('getChangedSettings', () => { - it('should detect changed values', () => { - const values = { - 'setting-1': 'new-value' - }; - const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'old-value' } - }; - - const result = SettingsUtils.getChangedSettings(values, settings); - - expect(result).toEqual({ - 'setting-1': 'new-value' + expect(API.updateSetting).toHaveBeenCalledWith({ + id: 5, + key: 'proxy_settings', + value: { + buffering_speed: 2.5, + buffering_timeout: 15 + } }); }); - it('should include new settings not in settings object', () => { - const values = { - 'new-setting': 'value' - }; + it('should create proxy_settings if it does not exist', async () => { const settings = {}; + const changedSettings = { + proxy_settings: { + buffering_speed: 2.5 + } + }; - const result = SettingsUtils.getChangedSettings(values, settings); + API.createSetting.mockResolvedValue({}); - expect(result).toEqual({ - 'new-setting': 'value' + await SettingsUtils.saveChangedSettings(settings, changedSettings); + + expect(API.createSetting).toHaveBeenCalledWith({ + key: 'proxy_settings', + name: 'Proxy Settings', + value: { + buffering_speed: 2.5 + } }); }); - it('should skip unchanged values', () => { - const values = { - 'setting-1': 'same-value' - }; + it('should handle network_access specially', async () => { const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'same-value' } + network_access: { + id: 6, + key: 'network_access', + value: [] + } + }; + const changedSettings = { + network_access: ['192.168.1.0/24', '10.0.0.0/8'] }; - const result = SettingsUtils.getChangedSettings(values, settings); + API.updateSetting.mockResolvedValue({}); - expect(result).toEqual({}); - }); + await SettingsUtils.saveChangedSettings(settings, changedSettings); - it('should convert array values to comma-separated strings', () => { - const values = { - 'm3u-hash-key': ['key1', 'key2', 'key3'] - }; - const settings = { - 'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: 'old-value' } - }; - - const result = SettingsUtils.getChangedSettings(values, settings); - - expect(result).toEqual({ - 'm3u-hash-key': 'key1,key2,key3' + expect(API.updateSetting).toHaveBeenCalledWith({ + id: 6, + key: 'network_access', + value: ['192.168.1.0/24', '10.0.0.0/8'] }); }); - - it('should skip empty string values', () => { - const values = { - 'setting-1': '', - 'setting-2': 'value' - }; - const settings = {}; - - const result = SettingsUtils.getChangedSettings(values, settings); - - expect(result).toEqual({ - 'setting-2': 'value' - }); - }); - - it('should skip empty array values', () => { - const values = { - 'setting-1': [], - 'setting-2': ['value'] - }; - const settings = {}; - - const result = SettingsUtils.getChangedSettings(values, settings); - - expect(result).toEqual({ - 'setting-2': 'value' - }); - }); - - it('should convert non-string values to strings', () => { - const values = { - 'setting-1': 123, - 'setting-2': true, - 'setting-3': false - }; - const settings = {}; - - const result = SettingsUtils.getChangedSettings(values, settings); - - expect(result).toEqual({ - 'setting-1': '123', - 'setting-2': 'true', - 'setting-3': 'false' - }); - }); - - it('should compare string values correctly', () => { - const values = { - 'setting-1': 'value', - 'setting-2': 123 - }; - const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'value' }, - 'setting-2': { id: 2, key: 'setting-2', value: 123 } - }; - - const result = SettingsUtils.getChangedSettings(values, settings); - - expect(result).toEqual({}); - }); }); describe('parseSettings', () => { - it('should convert string "true" to boolean true', () => { - const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'true' } + it('should parse grouped settings correctly', () => { + const mockSettings = { + 'stream_settings': { + id: 1, + key: 'stream_settings', + value: { + default_user_agent: 5, + default_stream_profile: 3, + m3u_hash_key: 'channel_name,channel_number', + preferred_region: 'US', + auto_import_mapped_files: true + } + }, + 'dvr_settings': { + id: 2, + key: 'dvr_settings', + value: { + tv_template: '/media/tv/{show}/{season}/', + comskip_enabled: false, + pre_offset_minutes: 2, + post_offset_minutes: 5 + } + } }; - const result = SettingsUtils.parseSettings(settings); + const result = SettingsUtils.parseSettings(mockSettings); - expect(result).toEqual({ - 'setting-1': true + // Check stream settings + expect(result.default_user_agent).toBe('5'); + expect(result.default_stream_profile).toBe('3'); + expect(result.m3u_hash_key).toEqual(['channel_name', 'channel_number']); + expect(result.preferred_region).toBe('US'); + expect(result.auto_import_mapped_files).toBe(true); + + // Check DVR settings + expect(result.tv_template).toBe('/media/tv/{show}/{season}/'); + expect(result.comskip_enabled).toBe(false); + expect(result.pre_offset_minutes).toBe(2); + expect(result.post_offset_minutes).toBe(5); + }); + + it('should handle empty m3u_hash_key', () => { + const mockSettings = { + 'stream_settings': { + id: 1, + key: 'stream_settings', + value: { + m3u_hash_key: '' + } + } + }; + + const result = SettingsUtils.parseSettings(mockSettings); + expect(result.m3u_hash_key).toEqual([]); + }); + + it('should handle proxy_settings', () => { + const mockSettings = { + 'proxy_settings': { + id: 5, + key: 'proxy_settings', + value: { + buffering_speed: 2.5, + buffering_timeout: 15 + } + } + }; + + const result = SettingsUtils.parseSettings(mockSettings); + expect(result.proxy_settings).toEqual({ + buffering_speed: 2.5, + buffering_timeout: 15 }); }); - it('should convert string "false" to boolean false', () => { - const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'false' } + it('should handle network_access', () => { + const mockSettings = { + 'network_access': { + id: 6, + key: 'network_access', + value: ['192.168.1.0/24', '10.0.0.0/8'] + } }; - const result = SettingsUtils.parseSettings(settings); + const result = SettingsUtils.parseSettings(mockSettings); + expect(result.network_access).toEqual(['192.168.1.0/24', '10.0.0.0/8']); + }); + }); - expect(result).toEqual({ - 'setting-1': false + describe('getChangedSettings', () => { + it('should detect changes in primitive values', () => { + const values = { + time_zone: 'America/New_York', + max_system_events: 2000, + comskip_enabled: true + }; + const settings = { + time_zone: { value: 'UTC' }, + max_system_events: { value: 1000 }, + comskip_enabled: { value: false } + }; + + const changes = SettingsUtils.getChangedSettings(values, settings); + + expect(changes).toEqual({ + time_zone: 'America/New_York', + max_system_events: 2000, + comskip_enabled: true }); }); - it('should parse m3u-hash-key as array', () => { + it('should not detect unchanged values', () => { + const values = { + time_zone: 'UTC', + max_system_events: 1000 + }; const settings = { - 'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: 'key1,key2,key3' } + time_zone: { value: 'UTC' }, + max_system_events: { value: 1000 } }; - const result = SettingsUtils.parseSettings(settings); + const changes = SettingsUtils.getChangedSettings(values, settings); + expect(changes).toEqual({}); + }); - expect(result).toEqual({ - 'm3u-hash-key': ['key1', 'key2', 'key3'] + it('should preserve type of numeric values', () => { + const values = { + max_system_events: 2000 + }; + const settings = { + max_system_events: { value: 1000 } + }; + + const changes = SettingsUtils.getChangedSettings(values, settings); + expect(typeof changes.max_system_events).toBe('number'); + expect(changes.max_system_events).toBe(2000); + }); + + it('should detect changes in array values', () => { + const values = { + m3u_hash_key: ['channel_name', 'channel_number'] + }; + const settings = { + m3u_hash_key: { value: 'channel_name' } + }; + + const changes = SettingsUtils.getChangedSettings(values, settings); + // Arrays are converted to comma-separated strings internally + expect(changes).toEqual({ + m3u_hash_key: 'channel_name,channel_number' }); }); - it('should filter empty strings from m3u-hash-key array', () => { + it('should skip proxy_settings and network_access', () => { + const values = { + time_zone: 'America/New_York', + proxy_settings: { + buffering_speed: 2.5 + }, + network_access: ['192.168.1.0/24'] + }; const settings = { - 'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: 'key1,,key2,' } + time_zone: { value: 'UTC' } }; - const result = SettingsUtils.parseSettings(settings); - - expect(result).toEqual({ - 'm3u-hash-key': ['key1', 'key2'] - }); - }); - - it('should return empty array for empty m3u-hash-key', () => { - const settings = { - 'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: '' } - }; - - const result = SettingsUtils.parseSettings(settings); - - expect(result).toEqual({ - 'm3u-hash-key': [] - }); - }); - - it('should return empty array for null m3u-hash-key', () => { - const settings = { - 'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: null } - }; - - const result = SettingsUtils.parseSettings(settings); - - expect(result).toEqual({ - 'm3u-hash-key': [] - }); - }); - - it('should parse dvr-pre-offset-minutes as integer', () => { - const settings = { - 'dvr-pre-offset-minutes': { id: 1, key: 'dvr-pre-offset-minutes', value: '5' } - }; - - const result = SettingsUtils.parseSettings(settings); - - expect(result).toEqual({ - 'dvr-pre-offset-minutes': 5 - }); - }); - - it('should parse dvr-post-offset-minutes as integer', () => { - const settings = { - 'dvr-post-offset-minutes': { id: 1, key: 'dvr-post-offset-minutes', value: '10' } - }; - - const result = SettingsUtils.parseSettings(settings); - - expect(result).toEqual({ - 'dvr-post-offset-minutes': 10 - }); - }); - - it('should default offset minutes to 0 for empty string', () => { - const settings = { - 'dvr-pre-offset-minutes': { id: 1, key: 'dvr-pre-offset-minutes', value: '' }, - 'dvr-post-offset-minutes': { id: 2, key: 'dvr-post-offset-minutes', value: '' } - }; - - const result = SettingsUtils.parseSettings(settings); - - expect(result).toEqual({ - 'dvr-pre-offset-minutes': 0, - 'dvr-post-offset-minutes': 0 - }); - }); - - it('should default offset minutes to 0 for NaN', () => { - const settings = { - 'dvr-pre-offset-minutes': { id: 1, key: 'dvr-pre-offset-minutes', value: 'invalid' }, - 'dvr-post-offset-minutes': { id: 2, key: 'dvr-post-offset-minutes', value: 'abc' } - }; - - const result = SettingsUtils.parseSettings(settings); - - expect(result).toEqual({ - 'dvr-pre-offset-minutes': 0, - 'dvr-post-offset-minutes': 0 - }); - }); - - it('should keep other values unchanged', () => { - const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'test-value' }, - 'setting-2': { id: 2, key: 'setting-2', value: 123 } - }; - - const result = SettingsUtils.parseSettings(settings); - - expect(result).toEqual({ - 'setting-1': 'test-value', - 'setting-2': 123 - }); - }); - - it('should handle empty settings object', () => { - const result = SettingsUtils.parseSettings({}); - - expect(result).toEqual({}); - }); - - it('should process multiple settings with mixed types', () => { - const settings = { - 'enabled': { id: 1, key: 'enabled', value: 'true' }, - 'disabled': { id: 2, key: 'disabled', value: 'false' }, - 'm3u-hash-key': { id: 3, key: 'm3u-hash-key', value: 'key1,key2' }, - 'dvr-pre-offset-minutes': { id: 4, key: 'dvr-pre-offset-minutes', value: '5' }, - 'dvr-post-offset-minutes': { id: 5, key: 'dvr-post-offset-minutes', value: '10' }, - 'other-setting': { id: 6, key: 'other-setting', value: 'value' } - }; - - const result = SettingsUtils.parseSettings(settings); - - expect(result).toEqual({ - 'enabled': true, - 'disabled': false, - 'm3u-hash-key': ['key1', 'key2'], - 'dvr-pre-offset-minutes': 5, - 'dvr-post-offset-minutes': 10, - 'other-setting': 'value' - }); + const changes = SettingsUtils.getChangedSettings(values, settings); + expect(changes.proxy_settings).toBeUndefined(); + expect(changes.network_access).toBeUndefined(); + expect(changes.time_zone).toBe('America/New_York'); }); }); });