refactor: simplify configuration endpoint with JSON serialization (#4159)

* refactor(config): reorganize configuration handling

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(aboutUtils): improve array formatting and handling in TOML conversion

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(aboutUtils): add escapeTomlKey function to handle special characters in TOML keys

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(test): remove unused getNestedValue function

* fix(ui): apply prettier formatting

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2025-05-31 19:37:23 -04:00
committed by GitHub
parent 8e32eeae93
commit 36ed2f2f58
6 changed files with 598 additions and 248 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ import { INSIGHTS_DOC_URL } from '../consts.js'
import subsonic from '../subsonic/index.js'
import { Typography } from '@material-ui/core'
import TableHead from '@material-ui/core/TableHead'
import { configToToml, separateAndSortConfigs } from '../utils/toml'
import { configToToml, separateAndSortConfigs } from './aboutUtils'
const useStyles = makeStyles((theme) => ({
configNameColumn: {
+278
View File
@@ -0,0 +1,278 @@
/**
* TOML utility functions for configuration export
*/
/**
* Flattens nested configuration object and generates environment variable names
* @param {Object} config - The nested configuration object from the backend
* @param {string} prefix - The current prefix for nested keys
* @returns {Array} - Array of config objects with key, envVar, and value properties
*/
export const flattenConfig = (config, prefix = '') => {
const result = []
if (!config || typeof config !== 'object') {
return result
}
Object.keys(config).forEach((key) => {
const value = config[key]
const currentKey = prefix ? `${prefix}.${key}` : key
if (value && typeof value === 'object' && !Array.isArray(value)) {
// Recursively flatten nested objects
result.push(...flattenConfig(value, currentKey))
} else {
// Generate environment variable name: ND_ + uppercase with dots replaced by underscores
const envVar = 'ND_' + currentKey.toUpperCase().replace(/\./g, '_')
// Convert value to string for display
let displayValue = value
if (
Array.isArray(value) ||
(typeof value === 'object' && value !== null)
) {
displayValue = JSON.stringify(value)
} else {
displayValue = String(value)
}
result.push({
key: currentKey,
envVar: envVar,
value: displayValue,
})
}
})
return result
}
/**
* Separates and sorts configuration entries into regular and dev configs
* @param {Array|Object} configEntries - Array of config objects with key and value, or nested config object
* @returns {Object} - Object with regularConfigs and devConfigs arrays, both sorted
*/
export const separateAndSortConfigs = (configEntries) => {
const regularConfigs = []
const devConfigs = []
// Handle both the old array format and new nested object format
let flattenedConfigs
if (Array.isArray(configEntries)) {
// Old format - already flattened
flattenedConfigs = configEntries
} else {
// New format - need to flatten
flattenedConfigs = flattenConfig(configEntries)
}
flattenedConfigs?.forEach((config) => {
// Skip configFile as it's displayed separately
if (config.key === 'ConfigFile') {
return
}
if (config.key.startsWith('Dev')) {
devConfigs.push(config)
} else {
regularConfigs.push(config)
}
})
// Sort configurations alphabetically
regularConfigs.sort((a, b) => a.key.localeCompare(b.key))
devConfigs.sort((a, b) => a.key.localeCompare(b.key))
return { regularConfigs, devConfigs }
}
/**
* Escapes TOML keys that contain special characters
* @param {string} key - The key to potentially escape
* @returns {string} - The escaped key if needed, or the original key
*/
export const escapeTomlKey = (key) => {
// Convert to string first to handle null/undefined
const keyStr = String(key)
// Empty strings always need quotes
if (keyStr === '') {
return '""'
}
// TOML bare keys can only contain letters, numbers, underscores, and hyphens
// If the key contains other characters, it needs to be quoted
if (/^[a-zA-Z0-9_-]+$/.test(keyStr)) {
return keyStr
}
// Escape quotes in the key and wrap in quotes
return `"${keyStr.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
/**
* Converts a value to proper TOML format
* @param {*} value - The value to format
* @returns {string} - The TOML-formatted value
*/
export const formatTomlValue = (value) => {
if (value === null || value === undefined) {
return '""'
}
const str = String(value)
// Boolean values
if (str === 'true' || str === 'false') {
return str
}
// Numbers (integers and floats)
if (/^-?\d+$/.test(str)) {
return str // Integer
}
if (/^-?\d*\.\d+$/.test(str)) {
return str // Float
}
// Duration values (like "300ms", "1s", "5m")
if (/^\d+(\.\d+)?(ns|us|µs|ms|s|m|h)$/.test(str)) {
return `"${str}"`
}
// Handle arrays and objects
if (str.startsWith('[') || str.startsWith('{')) {
try {
const parsed = JSON.parse(str)
// If it's an array, format as TOML array
if (Array.isArray(parsed)) {
const formattedItems = parsed.map((item) => {
if (typeof item === 'string') {
return `"${item.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
} else if (typeof item === 'number' || typeof item === 'boolean') {
return String(item)
} else {
return `"${String(item).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
})
if (formattedItems.length === 0) {
return '[ ]'
}
return `[ ${formattedItems.join(', ')} ]`
}
// For objects, keep the JSON string format with triple quotes
return `"""${str}"""`
} catch {
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
}
// String values (escape backslashes and quotes)
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
/**
* Converts nested keys to TOML sections
* @param {Array} configs - Array of config objects with key and value
* @returns {Object} - Object with sections and rootKeys
*/
export const buildTomlSections = (configs) => {
const sections = {}
const rootKeys = []
configs.forEach(({ key, value }) => {
if (key.includes('.')) {
const parts = key.split('.')
const sectionName = parts[0]
const keyName = parts.slice(1).join('.')
if (!sections[sectionName]) {
sections[sectionName] = []
}
sections[sectionName].push({ key: keyName, value })
} else {
rootKeys.push({ key, value })
}
})
return { sections, rootKeys }
}
/**
* Converts configuration data to TOML format
* @param {Object} configData - The configuration data object
* @param {Function} translate - Translation function for internationalization
* @returns {string} - The TOML-formatted configuration
*/
export const configToToml = (configData, translate = (key) => key) => {
let tomlContent = `# Navidrome Configuration\n# Generated on ${new Date().toISOString()}\n\n`
// Handle both old array format (configData.config is array) and new nested format (configData.config is object)
let configs
if (Array.isArray(configData.config)) {
// Old format - already flattened
configs = configData.config
} else {
// New format - need to flatten
configs = flattenConfig(configData.config)
}
const { regularConfigs, devConfigs } = separateAndSortConfigs(configs)
// Process regular configs
const { sections: regularSections, rootKeys: regularRootKeys } =
buildTomlSections(regularConfigs)
// Add root-level keys first
if (regularRootKeys.length > 0) {
regularRootKeys.forEach(({ key, value }) => {
tomlContent += `${key} = ${formatTomlValue(value)}\n`
})
tomlContent += '\n'
}
// Add dev configs if any
if (devConfigs.length > 0) {
tomlContent += `# ${translate('about.config.devFlagsHeader')}\n`
tomlContent += `# ${translate('about.config.devFlagsComment')}\n\n`
const { sections: devSections, rootKeys: devRootKeys } =
buildTomlSections(devConfigs)
// Add dev root-level keys
devRootKeys.forEach(({ key, value }) => {
tomlContent += `${key} = ${formatTomlValue(value)}\n`
})
if (devRootKeys.length > 0) {
tomlContent += '\n'
}
// Add dev sections
Object.keys(devSections)
.sort()
.forEach((sectionName) => {
tomlContent += `[${sectionName}]\n`
devSections[sectionName].forEach(({ key, value }) => {
tomlContent += `${escapeTomlKey(key)} = ${formatTomlValue(value)}\n`
})
tomlContent += '\n'
})
}
// Add sections
Object.keys(regularSections)
.sort()
.forEach((sectionName) => {
tomlContent += `[${sectionName}]\n`
regularSections[sectionName].forEach(({ key, value }) => {
tomlContent += `${escapeTomlKey(key)} = ${formatTomlValue(value)}\n`
})
tomlContent += '\n'
})
return tomlContent
}
+737
View File
@@ -0,0 +1,737 @@
import { describe, it, expect } from 'vitest'
import {
formatTomlValue,
buildTomlSections,
configToToml,
separateAndSortConfigs,
flattenConfig,
escapeTomlKey,
} from './aboutUtils'
describe('formatTomlValue', () => {
it('handles null and undefined values', () => {
expect(formatTomlValue(null)).toBe('""')
expect(formatTomlValue(undefined)).toBe('""')
})
it('handles boolean values', () => {
expect(formatTomlValue('true')).toBe('true')
expect(formatTomlValue('false')).toBe('false')
expect(formatTomlValue(true)).toBe('true')
expect(formatTomlValue(false)).toBe('false')
})
it('handles integer values', () => {
expect(formatTomlValue('123')).toBe('123')
expect(formatTomlValue('-456')).toBe('-456')
expect(formatTomlValue('0')).toBe('0')
expect(formatTomlValue(789)).toBe('789')
})
it('handles float values', () => {
expect(formatTomlValue('123.45')).toBe('123.45')
expect(formatTomlValue('-67.89')).toBe('-67.89')
expect(formatTomlValue('0.0')).toBe('0.0')
expect(formatTomlValue(12.34)).toBe('12.34')
})
it('handles duration values', () => {
expect(formatTomlValue('300ms')).toBe('"300ms"')
expect(formatTomlValue('5s')).toBe('"5s"')
expect(formatTomlValue('10m')).toBe('"10m"')
expect(formatTomlValue('2h')).toBe('"2h"')
expect(formatTomlValue('1.5s')).toBe('"1.5s"')
})
it('handles JSON arrays and objects', () => {
expect(formatTomlValue('["item1", "item2"]')).toBe('[ "item1", "item2" ]')
expect(formatTomlValue('{"key": "value"}')).toBe('"""{"key": "value"}"""')
})
it('formats different types of arrays correctly', () => {
// String array
expect(formatTomlValue('["genre", "tcon", "©gen"]')).toBe(
'[ "genre", "tcon", "©gen" ]',
)
// Mixed array with numbers and strings
expect(formatTomlValue('[42, "test", true]')).toBe('[ 42, "test", true ]')
// Empty array
expect(formatTomlValue('[]')).toBe('[ ]')
// Array with special characters in strings
expect(
formatTomlValue('["item with spaces", "item\\"with\\"quotes"]'),
).toBe('[ "item with spaces", "item\\"with\\"quotes" ]')
})
it('handles invalid JSON as regular strings', () => {
expect(formatTomlValue('[invalid json')).toBe('"[invalid json"')
expect(formatTomlValue('{broken')).toBe('"{broken"')
})
it('handles regular strings with quote escaping', () => {
expect(formatTomlValue('simple string')).toBe('"simple string"')
expect(formatTomlValue('string with "quotes"')).toBe(
'"string with \\"quotes\\""',
)
expect(formatTomlValue('/path/to/file')).toBe('"/path/to/file"')
})
it('handles strings with backslashes and quotes', () => {
expect(formatTomlValue('C:\\Program Files\\app')).toBe(
'"C:\\\\Program Files\\\\app"',
)
expect(formatTomlValue('path\\to"file')).toBe('"path\\\\to\\"file"')
expect(formatTomlValue('backslash\\ and "quote"')).toBe(
'"backslash\\\\ and \\"quote\\""',
)
expect(formatTomlValue('single\\backslash')).toBe('"single\\\\backslash"')
})
it('handles empty strings', () => {
expect(formatTomlValue('')).toBe('""')
})
})
describe('buildTomlSections', () => {
it('separates root keys from nested keys', () => {
const configs = [
{ key: 'RootKey1', value: 'value1' },
{ key: 'Section.NestedKey', value: 'value2' },
{ key: 'RootKey2', value: 'value3' },
{ key: 'Section.AnotherKey', value: 'value4' },
{ key: 'AnotherSection.Key', value: 'value5' },
]
const result = buildTomlSections(configs)
expect(result.rootKeys).toEqual([
{ key: 'RootKey1', value: 'value1' },
{ key: 'RootKey2', value: 'value3' },
])
expect(result.sections).toEqual({
Section: [
{ key: 'NestedKey', value: 'value2' },
{ key: 'AnotherKey', value: 'value4' },
],
AnotherSection: [{ key: 'Key', value: 'value5' }],
})
})
it('handles deeply nested keys', () => {
const configs = [{ key: 'Section.SubSection.DeepKey', value: 'deepValue' }]
const result = buildTomlSections(configs)
expect(result.rootKeys).toEqual([])
expect(result.sections).toEqual({
Section: [{ key: 'SubSection.DeepKey', value: 'deepValue' }],
})
})
it('handles empty input', () => {
const result = buildTomlSections([])
expect(result.rootKeys).toEqual([])
expect(result.sections).toEqual({})
})
})
describe('configToToml', () => {
const mockTranslate = (key) => {
const translations = {
'about.config.devFlagsHeader':
'Development Flags (subject to change/removal)',
'about.config.devFlagsComment':
'These are experimental settings and may be removed in future versions',
}
return translations[key] || key
}
it('generates TOML with header and timestamp', () => {
const configData = {
config: [{ key: 'TestKey', value: 'testValue' }],
}
const result = configToToml(configData, mockTranslate)
expect(result).toContain('# Navidrome Configuration')
expect(result).toContain('# Generated on')
expect(result).toContain('TestKey = "testValue"')
})
it('separates and sorts regular and dev configs', () => {
const configData = {
config: [
{ key: 'ZRegularKey', value: 'regularValue' },
{ key: 'DevTestFlag', value: 'true' },
{ key: 'ARegularKey', value: 'anotherValue' },
{ key: 'DevAnotherFlag', value: 'false' },
],
}
const result = configToToml(configData, mockTranslate)
// Check that regular configs come first and are sorted
const lines = result.split('\n')
const aRegularIndex = lines.findIndex((line) =>
line.includes('ARegularKey'),
)
const zRegularIndex = lines.findIndex((line) =>
line.includes('ZRegularKey'),
)
const devHeaderIndex = lines.findIndex((line) =>
line.includes('Development Flags'),
)
const devAnotherIndex = lines.findIndex((line) =>
line.includes('DevAnotherFlag'),
)
const devTestIndex = lines.findIndex((line) => line.includes('DevTestFlag'))
expect(aRegularIndex).toBeLessThan(zRegularIndex)
expect(zRegularIndex).toBeLessThan(devHeaderIndex)
expect(devHeaderIndex).toBeLessThan(devAnotherIndex)
expect(devAnotherIndex).toBeLessThan(devTestIndex)
})
it('skips ConfigFile entries', () => {
const configData = {
config: [
{ key: 'ConfigFile', value: '/path/to/config.toml' },
{ key: 'TestKey', value: 'testValue' },
],
}
const result = configToToml(configData, mockTranslate)
expect(result).not.toContain('ConfigFile =')
expect(result).toContain('TestKey = "testValue"')
})
it('handles sections correctly', () => {
const configData = {
config: [
{ key: 'RootKey', value: 'rootValue' },
{ key: 'Section.NestedKey', value: 'nestedValue' },
{ key: 'Section.AnotherKey', value: 'anotherValue' },
{ key: 'DevA', value: 'DevValue' },
],
}
const result = configToToml(configData, mockTranslate)
// Fields in a section are sorted alphabetically
const fields = [
'RootKey = "rootValue"',
'DevA = "DevValue"',
'[Section]',
'AnotherKey = "anotherValue"',
'NestedKey = "nestedValue"',
]
for (let idx = 0; idx < fields.length - 1; idx++) {
expect(result).toContain(fields[idx])
const idxA = result.indexOf(fields[idx])
const idxB = result.indexOf(fields[idx + 1])
expect(idxA).toBeLessThan(idxB)
}
expect(result).toContain(fields[fields.length - 1])
})
it('includes dev flags header when dev configs exist', () => {
const configData = {
config: [
{ key: 'RegularKey', value: 'regularValue' },
{ key: 'DevTestFlag', value: 'true' },
],
}
const result = configToToml(configData, mockTranslate)
expect(result).toContain('# Development Flags (subject to change/removal)')
expect(result).toContain(
'# These are experimental settings and may be removed in future versions',
)
expect(result).toContain('DevTestFlag = true')
})
it('does not include dev flags header when no dev configs exist', () => {
const configData = {
config: [{ key: 'RegularKey', value: 'regularValue' }],
}
const result = configToToml(configData, mockTranslate)
expect(result).not.toContain('Development Flags')
expect(result).toContain('RegularKey = "regularValue"')
})
it('handles empty config data', () => {
const configData = { config: [] }
const result = configToToml(configData, mockTranslate)
expect(result).toContain('# Navidrome Configuration')
expect(result).not.toContain('Development Flags')
})
it('handles missing config array', () => {
const configData = {}
const result = configToToml(configData, mockTranslate)
expect(result).toContain('# Navidrome Configuration')
expect(result).not.toContain('Development Flags')
})
it('works without translate function', () => {
const configData = {
config: [{ key: 'DevTestFlag', value: 'true' }],
}
const result = configToToml(configData)
expect(result).toContain('# about.config.devFlagsHeader')
expect(result).toContain('# about.config.devFlagsComment')
expect(result).toContain('DevTestFlag = true')
})
it('handles various data types correctly', () => {
const configData = {
config: [
{ key: 'StringValue', value: 'test string' },
{ key: 'BooleanValue', value: 'true' },
{ key: 'IntegerValue', value: '42' },
{ key: 'FloatValue', value: '3.14' },
{ key: 'DurationValue', value: '5s' },
{ key: 'ArrayValue', value: '["item1", "item2"]' },
],
}
const result = configToToml(configData, mockTranslate)
expect(result).toContain('StringValue = "test string"')
expect(result).toContain('BooleanValue = true')
expect(result).toContain('IntegerValue = 42')
expect(result).toContain('FloatValue = 3.14')
expect(result).toContain('DurationValue = "5s"')
expect(result).toContain('ArrayValue = [ "item1", "item2" ]')
})
it('handles nested config object format correctly', () => {
const configData = {
config: {
Address: '127.0.0.1',
Port: 4533,
EnableDownloads: true,
DevLogSourceLine: false,
LastFM: {
Enabled: true,
ApiKey: 'secret123',
Language: 'en',
},
Scanner: {
Schedule: 'daily',
Enabled: true,
},
},
}
const result = configToToml(configData, mockTranslate)
// Should contain regular configs
expect(result).toContain('Address = "127.0.0.1"')
expect(result).toContain('Port = 4533')
expect(result).toContain('EnableDownloads = true')
// Should contain dev configs with header
expect(result).toContain('# Development Flags (subject to change/removal)')
expect(result).toContain('DevLogSourceLine = false')
// Should contain sections
expect(result).toContain('[LastFM]')
expect(result).toContain('Enabled = true')
expect(result).toContain('ApiKey = "secret123"')
expect(result).toContain('Language = "en"')
expect(result).toContain('[Scanner]')
expect(result).toContain('Schedule = "daily"')
})
it('handles mixed nested and flat structure', () => {
const configData = {
config: {
MusicFolder: '/music',
DevAutoLoginUsername: 'testuser',
Jukebox: {
Enabled: false,
AdminOnly: true,
},
},
}
const result = configToToml(configData, mockTranslate)
expect(result).toContain('MusicFolder = "/music"')
expect(result).toContain('DevAutoLoginUsername = "testuser"')
expect(result).toContain('[Jukebox]')
expect(result).toContain('Enabled = false')
expect(result).toContain('AdminOnly = true')
})
it('properly escapes keys with special characters in sections', () => {
const configData = {
config: [
{ key: 'DevLogLevels.persistence/sql_base_repository', value: 'trace' },
{ key: 'DevLogLevels.core/scanner', value: 'debug' },
{ key: 'DevLogLevels.regular_key', value: 'info' },
{ key: 'Tags.genre.Aliases', value: '["tcon","genre","©gen"]' },
],
}
const result = configToToml(configData, mockTranslate)
// Keys with forward slashes should be quoted
expect(result).toContain('"persistence/sql_base_repository" = "trace"')
expect(result).toContain('"core/scanner" = "debug"')
// Regular keys should not be quoted
expect(result).toContain('regular_key = "info"')
// Arrays should be formatted correctly
expect(result).toContain('"genre.Aliases" = [ "tcon", "genre", "©gen" ]')
// Should contain proper sections
expect(result).toContain('[DevLogLevels]')
expect(result).toContain('[Tags]')
})
})
describe('flattenConfig', () => {
it('flattens simple nested objects correctly', () => {
const config = {
Address: '0.0.0.0',
Port: 4533,
EnableDownloads: true,
LastFM: {
Enabled: true,
ApiKey: 'secret123',
Language: 'en',
},
}
const result = flattenConfig(config)
expect(result).toContainEqual({
key: 'Address',
envVar: 'ND_ADDRESS',
value: '0.0.0.0',
})
expect(result).toContainEqual({
key: 'Port',
envVar: 'ND_PORT',
value: '4533',
})
expect(result).toContainEqual({
key: 'EnableDownloads',
envVar: 'ND_ENABLEDOWNLOADS',
value: 'true',
})
expect(result).toContainEqual({
key: 'LastFM.Enabled',
envVar: 'ND_LASTFM_ENABLED',
value: 'true',
})
expect(result).toContainEqual({
key: 'LastFM.ApiKey',
envVar: 'ND_LASTFM_APIKEY',
value: 'secret123',
})
expect(result).toContainEqual({
key: 'LastFM.Language',
envVar: 'ND_LASTFM_LANGUAGE',
value: 'en',
})
})
it('handles deeply nested objects', () => {
const config = {
Scanner: {
Schedule: 'daily',
Options: {
ExtractorType: 'taglib',
ArtworkPriority: 'cover.jpg',
},
},
}
const result = flattenConfig(config)
expect(result).toContainEqual({
key: 'Scanner.Schedule',
envVar: 'ND_SCANNER_SCHEDULE',
value: 'daily',
})
expect(result).toContainEqual({
key: 'Scanner.Options.ExtractorType',
envVar: 'ND_SCANNER_OPTIONS_EXTRACTORTYPE',
value: 'taglib',
})
expect(result).toContainEqual({
key: 'Scanner.Options.ArtworkPriority',
envVar: 'ND_SCANNER_OPTIONS_ARTWORKPRIORITY',
value: 'cover.jpg',
})
})
it('handles arrays correctly', () => {
const config = {
DeviceList: ['device1', 'device2'],
Settings: {
EnabledFormats: ['mp3', 'flac', 'ogg'],
},
}
const result = flattenConfig(config)
expect(result).toContainEqual({
key: 'DeviceList',
envVar: 'ND_DEVICELIST',
value: '["device1","device2"]',
})
expect(result).toContainEqual({
key: 'Settings.EnabledFormats',
envVar: 'ND_SETTINGS_ENABLEDFORMATS',
value: '["mp3","flac","ogg"]',
})
})
it('handles null and undefined values', () => {
const config = {
NullValue: null,
UndefinedValue: undefined,
EmptyString: '',
ZeroValue: 0,
}
const result = flattenConfig(config)
expect(result).toContainEqual({
key: 'NullValue',
envVar: 'ND_NULLVALUE',
value: 'null',
})
expect(result).toContainEqual({
key: 'UndefinedValue',
envVar: 'ND_UNDEFINEDVALUE',
value: 'undefined',
})
expect(result).toContainEqual({
key: 'EmptyString',
envVar: 'ND_EMPTYSTRING',
value: '',
})
expect(result).toContainEqual({
key: 'ZeroValue',
envVar: 'ND_ZEROVALUE',
value: '0',
})
})
it('handles empty object', () => {
const result = flattenConfig({})
expect(result).toEqual([])
})
it('handles null/undefined input', () => {
expect(flattenConfig(null)).toEqual([])
expect(flattenConfig(undefined)).toEqual([])
})
it('handles non-object input', () => {
expect(flattenConfig('string')).toEqual([])
expect(flattenConfig(123)).toEqual([])
expect(flattenConfig(true)).toEqual([])
})
})
describe('separateAndSortConfigs', () => {
it('separates regular and dev configs correctly with array input', () => {
const configs = [
{ key: 'RegularKey1', value: 'value1' },
{ key: 'DevTestFlag', value: 'true' },
{ key: 'AnotherRegular', value: 'value2' },
{ key: 'DevAnotherFlag', value: 'false' },
]
const result = separateAndSortConfigs(configs)
expect(result.regularConfigs).toEqual([
{ key: 'AnotherRegular', value: 'value2' },
{ key: 'RegularKey1', value: 'value1' },
])
expect(result.devConfigs).toEqual([
{ key: 'DevAnotherFlag', value: 'false' },
{ key: 'DevTestFlag', value: 'true' },
])
})
it('separates regular and dev configs correctly with nested object input', () => {
const config = {
Address: '127.0.0.1',
Port: 4533,
DevAutoLoginUsername: 'testuser',
DevLogSourceLine: true,
LastFM: {
Enabled: true,
ApiKey: 'secret123',
},
}
const result = separateAndSortConfigs(config)
expect(result.regularConfigs).toEqual([
{ key: 'Address', envVar: 'ND_ADDRESS', value: '127.0.0.1' },
{ key: 'LastFM.ApiKey', envVar: 'ND_LASTFM_APIKEY', value: 'secret123' },
{ key: 'LastFM.Enabled', envVar: 'ND_LASTFM_ENABLED', value: 'true' },
{ key: 'Port', envVar: 'ND_PORT', value: '4533' },
])
expect(result.devConfigs).toEqual([
{
key: 'DevAutoLoginUsername',
envVar: 'ND_DEVAUTOLOGINUSERNAME',
value: 'testuser',
},
{ key: 'DevLogSourceLine', envVar: 'ND_DEVLOGSOURCELINE', value: 'true' },
])
})
it('skips ConfigFile entries', () => {
const configs = [
{ key: 'ConfigFile', value: '/path/to/config.toml' },
{ key: 'RegularKey', value: 'value' },
{ key: 'DevFlag', value: 'true' },
]
const result = separateAndSortConfigs(configs)
expect(result.regularConfigs).toEqual([
{ key: 'RegularKey', value: 'value' },
])
expect(result.devConfigs).toEqual([{ key: 'DevFlag', value: 'true' }])
})
it('skips ConfigFile entries with nested object input', () => {
const config = {
ConfigFile: '/path/to/config.toml',
RegularKey: 'value',
DevFlag: true,
}
const result = separateAndSortConfigs(config)
expect(result.regularConfigs).toEqual([
{ key: 'RegularKey', envVar: 'ND_REGULARKEY', value: 'value' },
])
expect(result.devConfigs).toEqual([
{ key: 'DevFlag', envVar: 'ND_DEVFLAG', value: 'true' },
])
})
it('handles empty input', () => {
const result = separateAndSortConfigs([])
expect(result.regularConfigs).toEqual([])
expect(result.devConfigs).toEqual([])
})
it('handles null/undefined input', () => {
const result1 = separateAndSortConfigs(null)
const result2 = separateAndSortConfigs(undefined)
expect(result1.regularConfigs).toEqual([])
expect(result1.devConfigs).toEqual([])
expect(result2.regularConfigs).toEqual([])
expect(result2.devConfigs).toEqual([])
})
it('sorts configs alphabetically', () => {
const configs = [
{ key: 'ZRegular', value: 'z' },
{ key: 'ARegular', value: 'a' },
{ key: 'DevZ', value: 'z' },
{ key: 'DevA', value: 'a' },
]
const result = separateAndSortConfigs(configs)
expect(result.regularConfigs[0].key).toBe('ARegular')
expect(result.regularConfigs[1].key).toBe('ZRegular')
expect(result.devConfigs[0].key).toBe('DevA')
expect(result.devConfigs[1].key).toBe('DevZ')
})
})
describe('escapeTomlKey', () => {
it('does not escape valid bare keys', () => {
expect(escapeTomlKey('RegularKey')).toBe('RegularKey')
expect(escapeTomlKey('regular_key')).toBe('regular_key')
expect(escapeTomlKey('regular-key')).toBe('regular-key')
expect(escapeTomlKey('key123')).toBe('key123')
expect(escapeTomlKey('Key_with_underscores')).toBe('Key_with_underscores')
expect(escapeTomlKey('Key-with-hyphens')).toBe('Key-with-hyphens')
})
it('escapes keys with special characters', () => {
// Keys with forward slashes (like DevLogLevels keys)
expect(escapeTomlKey('persistence/sql_base_repository')).toBe(
'"persistence/sql_base_repository"',
)
expect(escapeTomlKey('core/scanner')).toBe('"core/scanner"')
// Keys with dots
expect(escapeTomlKey('Section.NestedKey')).toBe('"Section.NestedKey"')
// Keys with spaces
expect(escapeTomlKey('key with spaces')).toBe('"key with spaces"')
// Keys with other special characters
expect(escapeTomlKey('key@with@symbols')).toBe('"key@with@symbols"')
expect(escapeTomlKey('key+with+plus')).toBe('"key+with+plus"')
})
it('escapes quotes in keys', () => {
expect(escapeTomlKey('key"with"quotes')).toBe('"key\\"with\\"quotes"')
expect(escapeTomlKey('key with "quotes" inside')).toBe(
'"key with \\"quotes\\" inside"',
)
})
it('escapes backslashes in keys', () => {
expect(escapeTomlKey('key\\with\\backslashes')).toBe(
'"key\\\\with\\\\backslashes"',
)
expect(escapeTomlKey('path\\to\\file')).toBe('"path\\\\to\\\\file"')
})
it('handles empty and null keys', () => {
expect(escapeTomlKey('')).toBe('""')
expect(escapeTomlKey(null)).toBe('null')
expect(escapeTomlKey(undefined)).toBe('undefined')
})
})