mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
509 lines
21 KiB
JavaScript
509 lines
21 KiB
JavaScript
// Configuration System Tests
|
|
// Purpose: Verifies strict defaults, immutable revisions, secret handling, explicit setup-file import, and administrator safety.
|
|
// Scope: Uses isolated temporary databases and never opens the development server's data store.
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { execFileSync } = require('child_process');
|
|
const Database = require('better-sqlite3');
|
|
const { defaultConfig, normalizeConfig, assertValidConfig } = require('./validation');
|
|
const { definitions, rootSchema, secretPaths, featureDefinitions } = require('./definition');
|
|
const { migrations } = require('./migrations');
|
|
const { getFeatureFlags } = require('./index');
|
|
const { createConfigurationDatabase } = require('./database');
|
|
const {
|
|
parseConfigurationFile,
|
|
buildSecretOperationsForImport,
|
|
importConfigurationFile,
|
|
} = require('./configurationFileImporter');
|
|
|
|
const temporaryRoots = [];
|
|
|
|
function createTestDatabase() {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-configuration-'));
|
|
temporaryRoots.push(root);
|
|
return createConfigurationDatabase({ databasePath: path.join(root, 'configuration.sqlite') });
|
|
}
|
|
|
|
function collectUndocumentedSchemaPaths(schema, pathLabel = '$') {
|
|
/*
|
|
The admin editor is entirely schema-generated, so missing schema prose is
|
|
missing operator documentation. Walk objects, arrays, array item schemas,
|
|
and scalar leaves instead of checking only named service definitions; this
|
|
makes every visible level of the hierarchy uphold the same contract.
|
|
*/
|
|
if (!schema || typeof schema !== 'object') return [];
|
|
const missing = typeof schema.description === 'string' && schema.description.trim()
|
|
? []
|
|
: [pathLabel];
|
|
|
|
if (schema.properties) {
|
|
Object.entries(schema.properties).forEach(([key, childSchema]) => {
|
|
missing.push(...collectUndocumentedSchemaPaths(childSchema, `${pathLabel}.${key}`));
|
|
});
|
|
}
|
|
if (schema.items) {
|
|
missing.push(...collectUndocumentedSchemaPaths(schema.items, `${pathLabel}[]`));
|
|
}
|
|
return missing;
|
|
}
|
|
|
|
function collectSchemaPathsMissingInputExamples(schema, value, pathLabel = '$', insideArray = false) {
|
|
/*
|
|
Universal defaults such as timeouts and modes are real saved values. Empty
|
|
strings and newly-created array items are different: they require an
|
|
installation-specific value, so the admin form must show an example without
|
|
persisting a fake hostname, credential, or hardware ID. This walk enforces
|
|
that distinction across both the current default document and array shapes.
|
|
*/
|
|
if (!schema || typeof schema !== 'object') return [];
|
|
|
|
if (schema.type === 'array') {
|
|
return collectSchemaPathsMissingInputExamples(schema.items, undefined, `${pathLabel}[]`, true);
|
|
}
|
|
|
|
if (schema.type === 'object') {
|
|
return Object.entries(schema.properties || {}).flatMap(([key, childSchema]) => (
|
|
collectSchemaPathsMissingInputExamples(childSchema, value?.[key], `${pathLabel}.${key}`, insideArray)
|
|
));
|
|
}
|
|
|
|
// Enumerations and checkboxes already communicate their accepted shape
|
|
// through their controls, so placeholder examples are only required for
|
|
// otherwise free-form empty scalar inputs.
|
|
const needsExample = (value === '' || insideArray)
|
|
&& !Array.isArray(schema.enum)
|
|
&& schema.type !== 'boolean';
|
|
if (!needsExample) return [];
|
|
return Array.isArray(schema.examples) && schema.examples.length ? [] : [pathLabel];
|
|
}
|
|
|
|
function collectEmptyStringPaths(value, pathLabel = '$') {
|
|
/*
|
|
Empty-string policy is intentionally tested by path because these four
|
|
fields are exceptional for security or visible behavior, not omissions in
|
|
the legacy-style default document. Walking the complete value also catches
|
|
an accidentally blank field inside a pre-populated example collection.
|
|
*/
|
|
if (Array.isArray(value)) {
|
|
return value.flatMap((item, index) => collectEmptyStringPaths(item, `${pathLabel}[${index}]`));
|
|
}
|
|
if (value && typeof value === 'object') {
|
|
return Object.entries(value).flatMap(([key, childValue]) => (
|
|
collectEmptyStringPaths(childValue, `${pathLabel}.${key}`)
|
|
));
|
|
}
|
|
return value === '' ? [pathLabel] : [];
|
|
}
|
|
|
|
test.after(() => {
|
|
temporaryRoots.forEach((root) => fs.rmSync(root, { recursive: true, force: true }));
|
|
});
|
|
|
|
test('safe defaults form a complete valid configuration with integrations disabled', () => {
|
|
assert.doesNotThrow(() => assertValidConfig(defaultConfig));
|
|
assert.equal(defaultConfig.discord.enabled, false);
|
|
assert.equal(defaultConfig.homeAssistant.enabled, false);
|
|
assert.equal(defaultConfig.ptzCamera.enabled, false);
|
|
assert.equal(defaultConfig.balanceBoard.enabled, false);
|
|
});
|
|
|
|
test('legacy-style defaults populate every non-secret and inactive-content value', () => {
|
|
/*
|
|
Credentials must not masquerade as configured, and driver HTML would be
|
|
immediately visible without an enable switch. Every other free-form value
|
|
should match the populated template behavior operators had with YAML.
|
|
*/
|
|
assert.deepEqual(collectEmptyStringPaths(defaultConfig), [
|
|
'$.homeAssistant.token',
|
|
'$.ptzCamera.password',
|
|
'$.discord.token',
|
|
'$.driverAd.html',
|
|
]);
|
|
assert.ok(defaultConfig.interInstance.directoryUrls.length > 0);
|
|
assert.ok(defaultConfig.homeAssistant.entities.length > 0);
|
|
assert.ok(defaultConfig.homeAssistant.buttons.length > 0);
|
|
assert.ok(defaultConfig.roomCameras.cameras.length > 0);
|
|
assert.ok(defaultConfig.socials.links.length > 0);
|
|
});
|
|
|
|
test('service definitions determine document order and write-only secret handling', () => {
|
|
/*
|
|
The generic browser form and backend persistence both consume this one
|
|
assembled schema. Guarding composition order and derived secret paths here
|
|
prevents either consumer from needing its own parallel registry.
|
|
*/
|
|
assert.deepEqual(Object.keys(defaultConfig), definitions.map(({ key }) => key));
|
|
assert.deepEqual(Object.keys(rootSchema.properties), Object.keys(defaultConfig));
|
|
assert.deepEqual(secretPaths, ['homeAssistant.token', 'ptzCamera.password', 'discord.token']);
|
|
assert.equal(rootSchema.properties.homeAssistant.properties.token.writeOnly, true);
|
|
assert.equal(rootSchema.properties.ptzCamera.properties.password.writeOnly, true);
|
|
assert.equal(rootSchema.properties.discord.properties.token.writeOnly, true);
|
|
});
|
|
|
|
test('every configuration section, collection, item, and option has an operator description', () => {
|
|
/*
|
|
New configuration remains self-documenting by default. Reporting every
|
|
dotted path in one assertion gives a contributor an exact repair list and
|
|
avoids recreating a separately maintained documentation registry.
|
|
*/
|
|
assert.deepEqual(collectUndocumentedSchemaPaths(rootSchema), []);
|
|
});
|
|
|
|
test('empty installation-specific fields and array item inputs provide schema-owned examples', () => {
|
|
/*
|
|
The frontend derives placeholders from these examples generically. Keeping
|
|
this assertion beside schema composition prevents an empty, unexplained box
|
|
from returning when a service adds configuration in the future.
|
|
*/
|
|
assert.deepEqual(collectSchemaPathsMissingInputExamples(rootSchema, defaultConfig), []);
|
|
});
|
|
|
|
test('service definitions generate public feature paths without a separate registry', () => {
|
|
/*
|
|
This order follows the one configuration document, including nested Neato
|
|
and lift definitions beneath Home Assistant. The assertion makes duplicate,
|
|
omitted, or centrally reintroduced feature names visible during review.
|
|
*/
|
|
assert.deepEqual(featureDefinitions, [
|
|
{ key: 'interInstance', path: ['interInstance', 'enabled'] },
|
|
{ key: 'barcodeGames', path: ['barcodeGames', 'enabled'] },
|
|
{ key: 'homeAssistant', path: ['homeAssistant', 'enabled'] },
|
|
{ key: 'neato', path: ['homeAssistant', 'neato', 'enabled'] },
|
|
{ key: 'lift', path: ['homeAssistant', 'lift', 'enabled'] },
|
|
{ key: 'roomCameras', path: ['roomCameras', 'enabled'] },
|
|
{ key: 'ptzCamera', path: ['ptzCamera', 'enabled'] },
|
|
{ key: 'kinect', path: ['kinect', 'enabled'] },
|
|
{ key: 'balanceBoard', path: ['balanceBoard', 'enabled'] },
|
|
{ key: 'buttonBox', path: ['buttonBox', 'enabled'] },
|
|
{ key: 'barcodeScanner', path: ['barcodeScanner', 'enabled'] },
|
|
{ key: 'discord', path: ['discord', 'enabled'] },
|
|
{ key: 'socials', path: ['socials', 'enabled'] },
|
|
{ key: 'fleetReports', path: ['fleetReports', 'enabled'] },
|
|
]);
|
|
});
|
|
|
|
test('generated feature flags use only each declared enabled switch', () => {
|
|
/*
|
|
This deliberately describes services without usable credentials, devices,
|
|
or enabled parents. Readiness belongs to runtime health, so the generated
|
|
public flags must still preserve each operator-selected switch exactly.
|
|
*/
|
|
const flags = getFeatureFlags({
|
|
homeAssistant: {
|
|
enabled: false,
|
|
lift: { enabled: true },
|
|
neato: { enabled: true },
|
|
},
|
|
roomCameras: { enabled: true, cameras: [] },
|
|
barcodeScanner: { enabled: false },
|
|
barcodeGames: { enabled: true },
|
|
socials: { enabled: true, links: [] },
|
|
ptzCamera: { enabled: true, host: '', username: '', password: '' },
|
|
discord: { enabled: true, token: '' },
|
|
});
|
|
|
|
assert.equal(flags.homeAssistant, false);
|
|
assert.equal(flags.lift, true);
|
|
assert.equal(flags.neato, true);
|
|
assert.equal(flags.roomCameras, true);
|
|
assert.equal(flags.barcodeScanner, false);
|
|
assert.equal(flags.barcodeGames, true);
|
|
assert.equal(flags.socials, true);
|
|
assert.equal(flags.ptzCamera, true);
|
|
assert.equal(flags.discord, true);
|
|
});
|
|
|
|
test('normalization fills missing legacy fields but strict validation rejects unknown fields', () => {
|
|
const normalized = normalizeConfig({ media: { additionalHosts: [] } });
|
|
assert.equal(normalized.publicUrl, 'https://rover.example.com');
|
|
assert.deepEqual(normalized.media.additionalHosts, []);
|
|
assert.doesNotThrow(() => assertValidConfig(normalized));
|
|
|
|
const invalid = normalizeConfig({ media: { additionalHosts: [], misspelledHost: 'x' } });
|
|
assert.throws(() => assertValidConfig(invalid), (error) => {
|
|
assert.equal(error.code, 'CONFIG_VALIDATION_FAILED');
|
|
assert.ok(error.validationErrors.some((entry) => entry.path.includes('misspelledHost')));
|
|
return true;
|
|
});
|
|
});
|
|
|
|
test('database migration consolidates existing public URLs and removes obsolete media addressing', () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-public-url-migration-'));
|
|
temporaryRoots.push(root);
|
|
const databasePath = path.join(root, 'configuration.sqlite');
|
|
const legacyDatabase = new Database(databasePath);
|
|
legacyDatabase.exec(`
|
|
CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);
|
|
${migrations[0].sql}
|
|
`);
|
|
legacyDatabase.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (1, ?)').run(Date.now());
|
|
|
|
const legacyConfig = structuredClone(defaultConfig);
|
|
delete legacyConfig.publicUrl;
|
|
legacyConfig.interInstance.profile.publicUrl = 'https://rover.example.com';
|
|
legacyConfig.discord.enabled = true;
|
|
legacyConfig.discord.siteUrl = 'https://canonical.example.com';
|
|
legacyConfig.media.whepBaseUrl = 'http://127.0.0.1:8889/video';
|
|
const inserted = legacyDatabase.prepare(`
|
|
INSERT INTO configuration_revisions (config_json, created_at, actor, source)
|
|
VALUES (?, ?, 'test', 'legacy-shape')
|
|
`).run(JSON.stringify(legacyConfig), Date.now());
|
|
legacyDatabase.prepare('INSERT INTO configuration_state (singleton, active_revision_id) VALUES (1, ?)')
|
|
.run(inserted.lastInsertRowid);
|
|
legacyDatabase.close();
|
|
|
|
const migrated = createConfigurationDatabase({ databasePath });
|
|
const active = migrated.getActiveConfigurationRecord().config;
|
|
assert.equal(active.publicUrl, 'https://canonical.example.com');
|
|
assert.equal(Object.hasOwn(active.interInstance.profile, 'publicUrl'), false);
|
|
assert.equal(Object.hasOwn(active.discord, 'siteUrl'), false);
|
|
assert.equal(Object.hasOwn(active.media, 'whepBaseUrl'), false);
|
|
migrated.close();
|
|
});
|
|
|
|
test('full-document updates preserve secrets and reject a stale browser revision', () => {
|
|
const database = createTestDatabase();
|
|
const initial = database.getActiveConfigurationRecord();
|
|
const tokenRevision = database.updateConfiguration({
|
|
value: database.getClientConfiguration().config,
|
|
expectedRevision: initial.revision,
|
|
actor: 'test',
|
|
secretOperations: {
|
|
'discord.token': { action: 'replace', value: 'super-secret-token' },
|
|
},
|
|
});
|
|
|
|
const client = database.getClientConfiguration();
|
|
assert.equal(client.config.discord.token, '');
|
|
assert.equal(client.configuredSecrets['discord.token'], true);
|
|
const editedConfiguration = structuredClone(client.config);
|
|
editedConfiguration.discord.enabled = true;
|
|
const nextRevision = database.updateConfiguration({
|
|
value: editedConfiguration,
|
|
expectedRevision: tokenRevision,
|
|
actor: 'test',
|
|
});
|
|
assert.equal(database.getActiveConfigurationRecord().config.discord.token, 'super-secret-token');
|
|
assert.throws(() => database.updateConfiguration({
|
|
value: editedConfiguration,
|
|
expectedRevision: tokenRevision,
|
|
actor: 'stale-test',
|
|
}), (error) => error.code === 'CONFIG_REVISION_CONFLICT' && error.currentRevision === nextRevision);
|
|
|
|
const rollbackRevision = database.restoreConfigurationRevision({
|
|
revision: tokenRevision,
|
|
expectedRevision: nextRevision,
|
|
actor: 'rollback-test',
|
|
});
|
|
assert.ok(rollbackRevision > nextRevision);
|
|
assert.equal(database.getActiveConfigurationRecord().config.discord.enabled, false);
|
|
database.close();
|
|
});
|
|
|
|
test('administrator storage never exposes hashes or removes the final lockdown administrator', () => {
|
|
const database = createTestDatabase();
|
|
const lockdown = database.createAdministrator({
|
|
username: 'owner',
|
|
passwordHash: '$2b$10$example',
|
|
role: 'lockdown',
|
|
});
|
|
const listed = database.listAdministrators();
|
|
assert.equal(listed.length, 1);
|
|
assert.equal(Object.hasOwn(listed[0], 'passwordHash'), false);
|
|
assert.throws(() => database.deleteAdministrator(lockdown.id, 'test'), /final lockdown administrator/);
|
|
assert.throws(() => database.updateAdministrator(lockdown.id, { role: 'admin' }, 'test'), /final lockdown administrator/);
|
|
database.close();
|
|
});
|
|
|
|
test('an explicitly uploaded YAML imports current fields, ignores obsolete keys, and preserves bcrypt hashes exactly once', () => {
|
|
const yamlText = `
|
|
admins:
|
|
- username: owner
|
|
password_hash: "$2b$10$preservedHash"
|
|
discord_id: "1234"
|
|
lockdown: true
|
|
publicUrl: https://production.example.com
|
|
timezone: America/Chicago
|
|
media:
|
|
whepBaseUrl: http://localhost:8889/video
|
|
overseerControl:
|
|
enabled: false
|
|
heartbeatMs: 30000
|
|
alwaysRunModel: false
|
|
homeAssistant:
|
|
neato:
|
|
enabled: false
|
|
brainslugHost: neato-vacuum.local
|
|
brainslugKey: retired-secret
|
|
brainslugLogFile: /tmp/retired.log
|
|
roomCameras:
|
|
enabled: true
|
|
cameras:
|
|
- id: stream-only
|
|
name: Stream-only camera
|
|
streamUrl: http://camera.local/stream.mjpg
|
|
discord:
|
|
channels:
|
|
chatBridge: "123456789012345678"
|
|
roles:
|
|
stalker: "123456789012345678"
|
|
fleetReports:
|
|
discord:
|
|
immediateCriticalAlerts: true
|
|
`;
|
|
const parsed = parseConfigurationFile(yamlText);
|
|
assert.equal(parsed.config.publicUrl, 'https://production.example.com');
|
|
assert.equal(parsed.config.timezone, 'America/Chicago');
|
|
assert.equal(parsed.administrators[0].passwordHash, '$2b$10$preservedHash');
|
|
assert.equal(Object.hasOwn(parsed.config.overseerControl, 'heartbeatMs'), false);
|
|
assert.equal(Object.hasOwn(parsed.config.overseerControl, 'alwaysRunModel'), false);
|
|
assert.equal(Object.hasOwn(parsed.config.homeAssistant.neato, 'brainslugHost'), false);
|
|
assert.equal(Object.hasOwn(parsed.config.discord.channels, 'chatBridge'), false);
|
|
assert.equal(Object.hasOwn(parsed.config.discord.roles, 'stalker'), false);
|
|
assert.equal(Object.hasOwn(parsed.config.fleetReports.discord, 'immediateCriticalAlerts'), false);
|
|
assert.deepEqual(parsed.config.roomCameras.cameras, [{
|
|
id: 'stream-only',
|
|
name: 'Stream-only camera',
|
|
streamUrl: 'http://camera.local/stream.mjpg',
|
|
}]);
|
|
|
|
const database = createTestDatabase();
|
|
const result = importConfigurationFile({ text: yamlText, database });
|
|
assert.equal(result.administratorCount, 1);
|
|
assert.equal(database.findAdministratorForAuthentication('OWNER').passwordHash, '$2b$10$preservedHash');
|
|
assert.throws(() => importConfigurationFile({ text: yamlText, database }), /cannot replace an initialized installation/);
|
|
database.close();
|
|
});
|
|
|
|
test('uploaded YAML still rejects invalid values for fields in the current schema', () => {
|
|
const yamlText = `
|
|
admins:
|
|
- username: owner
|
|
password_hash: "$2b$10$preservedHash"
|
|
lockdown: true
|
|
bandwidthSavings:
|
|
multiTabProtection: unsupported-mode
|
|
`;
|
|
|
|
assert.throws(() => parseConfigurationFile(yamlText), (error) => {
|
|
assert.equal(error.code, 'CONFIG_VALIDATION_FAILED');
|
|
assert.ok(error.validationErrors.some((entry) => entry.path === '/bandwidthSavings/multiTabProtection'));
|
|
return true;
|
|
});
|
|
});
|
|
|
|
test('an administrative YAML replacement ignores accounts and only changes secrets present in the file', () => {
|
|
const database = createTestDatabase();
|
|
const initial = database.getClientConfiguration();
|
|
const seededRevision = database.updateConfiguration({
|
|
value: initial.config,
|
|
expectedRevision: initial.revision,
|
|
actor: 'secret-seed',
|
|
secretOperations: {
|
|
'homeAssistant.token': { action: 'replace', value: 'preserve-this-token' },
|
|
'ptzCamera.password': { action: 'replace', value: 'clear-this-password' },
|
|
'discord.token': { action: 'replace', value: 'replace-this-token' },
|
|
},
|
|
});
|
|
const yamlText = `
|
|
admins:
|
|
- this obsolete account entry is deliberately malformed
|
|
timezone: America/Chicago
|
|
ptzCamera:
|
|
password:
|
|
discord:
|
|
token: new-discord-token
|
|
`;
|
|
|
|
/*
|
|
An initialized installation treats the YAML as configuration data only.
|
|
Even malformed account data is ignored, while presence-aware secret
|
|
operations preserve an omitted credential, clear an explicit empty value,
|
|
and replace an explicit non-empty value.
|
|
*/
|
|
const parsed = parseConfigurationFile(yamlText, { includeAdministrators: false });
|
|
assert.deepEqual(parsed.administrators, []);
|
|
assert.equal(parsed.uploadedAdministratorCount, 1);
|
|
assert.deepEqual(parsed.providedSecretPaths, ['ptzCamera.password', 'discord.token']);
|
|
const revision = database.updateConfiguration({
|
|
value: parsed.config,
|
|
expectedRevision: seededRevision,
|
|
secretOperations: buildSecretOperationsForImport(parsed),
|
|
actor: 'admin-import-test',
|
|
source: 'admin-yaml:production.yaml',
|
|
});
|
|
|
|
const active = database.getActiveConfigurationRecord();
|
|
assert.equal(active.revision, revision);
|
|
assert.equal(active.source, 'admin-yaml:production.yaml');
|
|
assert.equal(active.config.homeAssistant.token, 'preserve-this-token');
|
|
assert.equal(active.config.ptzCamera.password, '');
|
|
assert.equal(active.config.discord.token, 'new-discord-token');
|
|
assert.equal(active.config.timezone, 'America/Chicago');
|
|
assert.equal(database.listAdministrators().length, 0);
|
|
database.close();
|
|
});
|
|
|
|
test('committed revisions replace the live snapshot and isolate service reload failures', () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-live-configuration-'));
|
|
temporaryRoots.push(root);
|
|
const serverRoot = path.resolve(__dirname, '../..');
|
|
const script = `
|
|
const configuration = require('./src/configuration');
|
|
const applied = [];
|
|
configuration.registerConfigurationHandler('timezone', (next, previous) => {
|
|
applied.push({ section: 'timezone', next, previous });
|
|
});
|
|
configuration.registerConfigurationHandler('media', () => {
|
|
throw new Error('simulated media reload failure');
|
|
});
|
|
const database = configuration.getConfigurationDatabase();
|
|
const record = database.getClientConfiguration();
|
|
const next = structuredClone(record.config);
|
|
next.timezone = 'America/Chicago';
|
|
next.media.additionalHosts = ['media.example.test'];
|
|
database.updateConfiguration({
|
|
value: next,
|
|
expectedRevision: record.revision,
|
|
actor: 'live-configuration-test',
|
|
});
|
|
configuration.applyCommittedConfiguration().then((application) => {
|
|
console.log(JSON.stringify({
|
|
application,
|
|
applied,
|
|
liveTimezone: configuration.loadConfig().timezone,
|
|
liveRevision: configuration.getRuntimeConfigurationRevision(),
|
|
}));
|
|
database.close();
|
|
});
|
|
`;
|
|
const output = execFileSync(process.execPath, ['-e', script], {
|
|
cwd: serverRoot,
|
|
env: { ...process.env, SERVER_DATA_DIR: root },
|
|
encoding: 'utf8',
|
|
});
|
|
const result = JSON.parse(output.trim());
|
|
|
|
/*
|
|
A failing integration remains visible in application status but cannot
|
|
roll back the valid revision or prevent an unrelated service from seeing
|
|
it. This is the central guarantee that makes live application usable on a
|
|
server where optional hardware may be offline during an ordinary edit.
|
|
*/
|
|
assert.equal(result.liveTimezone, 'America/Chicago');
|
|
assert.equal(result.liveRevision, result.application.revision);
|
|
assert.deepEqual(result.application.changedSections, ['timezone', 'media']);
|
|
assert.deepEqual(result.applied, [{
|
|
section: 'timezone',
|
|
next: 'America/Chicago',
|
|
previous: defaultConfig.timezone,
|
|
}]);
|
|
assert.deepEqual(result.application.services, [
|
|
{ section: 'timezone', status: 'applied' },
|
|
{ section: 'media', status: 'failed', error: 'simulated media reload failure' },
|
|
]);
|
|
});
|