mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Cover settings system state and infrastructure operations hooks
useSystemSettingsState.branchcov0722pm covers the fifteen accessors and change handlers that no test reached: the six env-override lock gates, the backup interval select value and summary, the four settings toggle handlers, and the update check and install path. Zero-hit functions on the module go from 15 to 0 and uncovered branches from 23 to 20. useInfrastructureOperationsState.branchcov0722pm is the first test for that module, which shipped untested today. It drives the privilege-escalation rewrite, the insecure and custom-CA flag builders for both shell and PowerShell, the resolved command token, and the canonical uninstall and connection identity helpers, reaching 65 of 77 branches and 19 of 24 functions. A third candidate covering useSettingsNavigation was written and dropped: it added no branch coverage and its handleSelectAgent cases only round-tripped a one-line setter. Both files are new; no source or existing test was touched.
This commit is contained in:
parent
984ca06918
commit
2c3c188fc8
2 changed files with 1241 additions and 0 deletions
|
|
@ -0,0 +1,525 @@
|
|||
import { createRoot } from 'solid-js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Connection } from '@/api/connections';
|
||||
import { useInfrastructureOperationsState } from '../useInfrastructureOperationsState';
|
||||
import type { AgentUninstallIdentity } from '../useInfrastructureOperationsState';
|
||||
import type { AgentPlatform, UnifiedAgentRow } from '../infrastructureOperationsModel';
|
||||
|
||||
// The named sibling tests (InfrastructureOperationsModel.test.tsx,
|
||||
// infrastructureOperationsModel.branchcov.test.ts) exercise pure exported
|
||||
// helpers and never mount the hook, so there is no createRoot precedent to copy
|
||||
// for this module. The createRoot/dispose shape, the hoisted vi.mock factories,
|
||||
// and the single-static-import (no vi.resetModules) rule are lifted from the
|
||||
// closest hook-mounting sibling, useSystemSettingsState.branchcov0722pm.test.ts.
|
||||
// The child hook useInfrastructureInstallState is composed for real (not mocked)
|
||||
// so the operations-state closures below are exercised as live code; its
|
||||
// install-state signals are driven through the public setters the composed hook
|
||||
// re-exposes (setInsecureMode, setCustomCaPath, setCustomAgentUrl,
|
||||
// setEnableCommands), and requiresToken/currentToken are driven through the
|
||||
// SecurityAPI.getStatus and NodesAPI.createHostAgentInstallToken mocks.
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
securityGetStatus: vi.fn(),
|
||||
createHostAgentInstallToken: vi.fn(),
|
||||
monitoringGetState: vi.fn(),
|
||||
monitoringLookupAgent: vi.fn(),
|
||||
securityDeleteToken: vi.fn(),
|
||||
notificationSuccess: vi.fn(),
|
||||
notificationError: vi.fn(),
|
||||
notificationInfo: vi.fn(),
|
||||
loggerError: vi.fn(),
|
||||
loggerWarn: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@solidjs/router', async () => {
|
||||
const actual = await vi.importActual<typeof import('@solidjs/router')>('@solidjs/router');
|
||||
return {
|
||||
...actual,
|
||||
useLocation: () => ({ pathname: '/settings/infrastructure', search: '' }),
|
||||
useNavigate: () => mocks.navigate,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/api/security', () => ({
|
||||
SecurityAPI: {
|
||||
getStatus: mocks.securityGetStatus,
|
||||
deleteToken: mocks.securityDeleteToken,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/api/nodes', () => ({
|
||||
NodesAPI: {
|
||||
createHostAgentInstallToken: mocks.createHostAgentInstallToken,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/api/monitoring', () => ({
|
||||
MonitoringAPI: {
|
||||
getState: mocks.monitoringGetState,
|
||||
lookupAgent: mocks.monitoringLookupAgent,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/notifications', () => ({
|
||||
notificationStore: {
|
||||
success: mocks.notificationSuccess,
|
||||
error: mocks.notificationError,
|
||||
info: mocks.notificationInfo,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
logger: {
|
||||
error: mocks.loggerError,
|
||||
warn: mocks.loggerWarn,
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/clipboard', () => ({
|
||||
copyToClipboard: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
type OpsState = ReturnType<typeof useInfrastructureOperationsState>;
|
||||
|
||||
const flushAsync = async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
const mountHook = (): { dispose: () => void; state: OpsState } => {
|
||||
let dispose = () => {};
|
||||
let state!: OpsState;
|
||||
createRoot((d) => {
|
||||
dispose = d;
|
||||
state = useInfrastructureOperationsState({});
|
||||
});
|
||||
return { dispose, state: state! };
|
||||
};
|
||||
|
||||
const HTTPS_URL = 'https://pulse.test';
|
||||
const HTTP_URL = 'http://pulse.test';
|
||||
|
||||
const baseConnection: Connection = {
|
||||
id: 'agent:agent-1',
|
||||
type: 'agent',
|
||||
name: 'conn-1',
|
||||
address: '10.0.0.1',
|
||||
state: 'active',
|
||||
stateReason: '',
|
||||
enabled: true,
|
||||
surfaces: [],
|
||||
scope: {},
|
||||
lastSeen: null,
|
||||
lastError: null,
|
||||
source: 'agent',
|
||||
capabilities: { supportsPause: false, supportsScope: false, supportsTest: false },
|
||||
};
|
||||
|
||||
const baseRow: UnifiedAgentRow = {
|
||||
rowKey: 'row-1',
|
||||
id: 'id-1',
|
||||
name: 'node-1',
|
||||
capabilities: [],
|
||||
status: 'active',
|
||||
upgradePlatform: 'windows',
|
||||
scope: { label: 'Default', category: 'default' },
|
||||
installFlags: [],
|
||||
searchText: '',
|
||||
surfaces: [],
|
||||
};
|
||||
|
||||
describe('useInfrastructureOperationsState command-building closures', () => {
|
||||
beforeEach(() => {
|
||||
// requiresToken defaults to true (requiresAuth set), no token present.
|
||||
mocks.securityGetStatus.mockResolvedValue({ requiresAuth: true, hasAuthentication: true });
|
||||
mocks.createHostAgentInstallToken.mockResolvedValue({
|
||||
token: 'tok-1',
|
||||
record: {
|
||||
id: 'rec-1',
|
||||
name: 'Agent',
|
||||
prefix: 'pul',
|
||||
suffix: 'abcd',
|
||||
createdAt: '2026-01-01',
|
||||
},
|
||||
});
|
||||
mocks.monitoringGetState.mockResolvedValue({ connectedInfrastructure: [] });
|
||||
mocks.monitoringLookupAgent.mockResolvedValue(null);
|
||||
mocks.securityDeleteToken.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('withPrivilegeEscalation (via getUninstallCommand)', () => {
|
||||
it('wraps a "| bash -s --" command in the root/sudo escalation block verbatim', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
|
||||
const cmd = state.getUninstallCommand();
|
||||
|
||||
expect(cmd).toContain('| { if [ "$(id -u)" -eq 0 ]; then bash -s -- --uninstall --url');
|
||||
expect(cmd).toContain(
|
||||
'; elif command -v sudo >/dev/null 2>&1; then sudo bash -s -- --uninstall --url',
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
'; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }',
|
||||
);
|
||||
// The original pipe form is consumed by the replacement.
|
||||
expect(cmd).not.toMatch(/^\S+ \| bash -s --/);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvedCommandToken (via getUninstallCommand)', () => {
|
||||
it('substitutes the api-token placeholder when a token is required but absent', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
|
||||
expect(state.getUninstallCommand()).toContain("--token '<api-token>'");
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('uses the minted token verbatim when one is present and required', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
await state.handleGenerateToken();
|
||||
|
||||
const cmd = state.getUninstallCommand();
|
||||
expect(cmd).toContain("--token 'tok-1'");
|
||||
expect(cmd).not.toContain('<api-token>');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('omits --token entirely when no token is required and none is present', async () => {
|
||||
mocks.securityGetStatus.mockResolvedValue({
|
||||
requiresAuth: false,
|
||||
hasAuthentication: false,
|
||||
apiTokenConfigured: false,
|
||||
});
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
|
||||
const cmd = state.getUninstallCommand();
|
||||
expect(cmd).not.toContain('--token');
|
||||
expect(cmd).not.toContain('<api-token>');
|
||||
expect(cmd).toContain("--uninstall --url 'https://pulse.test'");
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('insecure / custom-CA flag helpers (via getUninstallCommand)', () => {
|
||||
it('uses safe curl flags and no insecure/cacert tokens over https with secure mode', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
|
||||
const cmd = state.getUninstallCommand();
|
||||
expect(cmd).toContain('curl -fsSL ');
|
||||
expect(cmd).not.toContain(' --insecure');
|
||||
expect(cmd).not.toContain('--cacert');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('switches curl to -kfsSL and appends --insecure when insecure mode is on', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
state.setInsecureMode(true);
|
||||
|
||||
const cmd = state.getUninstallCommand();
|
||||
expect(cmd).toContain('curl -kfsSL ');
|
||||
expect(cmd).toContain(' --insecure');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('appends --insecure for an http:// url even in secure mode (url-driven arm)', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTP_URL);
|
||||
|
||||
const cmd = state.getUninstallCommand();
|
||||
// insecureMode is off, so curl stays -fsSL, but the http url forces --insecure.
|
||||
expect(cmd).toContain('curl -fsSL ');
|
||||
expect(cmd).toContain(' --insecure');
|
||||
expect(cmd).toContain("'http://pulse.test/install.sh'");
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('emits --cacert for both curl and installer when a custom CA path is set', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
state.setCustomCaPath(' /etc/ssl/ca.pem ');
|
||||
|
||||
const cmd = state.getUninstallCommand();
|
||||
// selectedCustomCaPath trims surrounding whitespace before quoting.
|
||||
expect(cmd).toContain(
|
||||
"curl -fsSL --cacert '/etc/ssl/ca.pem' 'https://pulse.test/install.sh'",
|
||||
);
|
||||
expect(cmd).toContain("--cacert '/etc/ssl/ca.pem'");
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCanonicalUninstallAgentId / getCanonicalUninstallHostname (via getUninstallCommand)', () => {
|
||||
it('prefers agentActionId and emits both identity flags when present', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
const row: AgentUninstallIdentity = {
|
||||
agentActionId: 'act-9',
|
||||
agentId: 'agent-9',
|
||||
hostname: 'host-9',
|
||||
};
|
||||
|
||||
const cmd = state.getUninstallCommand(row);
|
||||
expect(cmd).toContain("--agent-id 'act-9'");
|
||||
expect(cmd).toContain("--hostname 'host-9'");
|
||||
expect(cmd).not.toContain('agent-9');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('falls back to agentId when agentActionId is absent', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
const row: AgentUninstallIdentity = { agentId: 'agent-7', hostname: 'host-7' };
|
||||
|
||||
const cmd = state.getUninstallCommand(row);
|
||||
expect(cmd).toContain("--agent-id 'agent-7'");
|
||||
expect(cmd).toContain("--hostname 'host-7'");
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('omits both identity flags when the row carries neither id nor hostname', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
|
||||
const cmd = state.getUninstallCommand({});
|
||||
expect(cmd).not.toContain('--agent-id');
|
||||
expect(cmd).not.toContain('--hostname');
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPowerShellTransportEnv (via getWindowsUninstallCommand)', () => {
|
||||
it('produces no transport prefix when insecure mode is off and no CA is set', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
|
||||
const cmd = state.getWindowsUninstallCommand();
|
||||
expect(cmd.startsWith('$env:PULSE_URL=')).toBe(true);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('emits only the insecure-skip-verify env when insecure mode is on', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
state.setInsecureMode(true);
|
||||
|
||||
const cmd = state.getWindowsUninstallCommand();
|
||||
expect(cmd).toContain('$env:PULSE_INSECURE_SKIP_VERIFY="true"; $env:PULSE_URL=');
|
||||
// The bootstrap always references $env:PULSE_CACERT in a guard, so assert
|
||||
// the assignment form (`="`) is absent — only transportEnv produces that.
|
||||
expect(cmd).not.toContain('$env:PULSE_CACERT="');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('emits only the CA cert env when a custom CA path is set', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
state.setCustomCaPath('/etc/ssl/ca.pem');
|
||||
|
||||
const cmd = state.getWindowsUninstallCommand();
|
||||
expect(cmd).toContain('$env:PULSE_CACERT="/etc/ssl/ca.pem"; $env:PULSE_URL=');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('emits both env assignments when insecure mode and a CA path are set', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
state.setInsecureMode(true);
|
||||
state.setCustomCaPath('/etc/ssl/ca.pem');
|
||||
|
||||
const cmd = state.getWindowsUninstallCommand();
|
||||
expect(cmd).toContain(
|
||||
'$env:PULSE_INSECURE_SKIP_VERIFY="true"; $env:PULSE_CACERT="/etc/ssl/ca.pem"; ',
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('omits PULSE_TOKEN when no token resolves, and includes it after one is minted', async () => {
|
||||
// resolvedCommandToken only yields a falsy value (no PULSE_TOKEN line)
|
||||
// when no token is required and none has been minted; the placeholder arm
|
||||
// is covered in the resolvedCommandToken group above.
|
||||
mocks.securityGetStatus.mockResolvedValue({
|
||||
requiresAuth: false,
|
||||
hasAuthentication: false,
|
||||
apiTokenConfigured: false,
|
||||
});
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
|
||||
expect(state.getWindowsUninstallCommand()).not.toContain('PULSE_TOKEN');
|
||||
await state.handleGenerateToken();
|
||||
expect(state.getWindowsUninstallCommand()).toContain('$env:PULSE_TOKEN="tok-1"');
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPowerShellModeEnv (via getUpgradeCommand windows arm)', () => {
|
||||
it('does not add the commands env when enableCommands is off', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
|
||||
const cmd = state.getUpgradeCommand(baseRow);
|
||||
expect(cmd).not.toContain('PULSE_ENABLE_COMMANDS');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('adds the PULSE_ENABLE_COMMANDS env when enableCommands is on', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
state.setEnableCommands(true);
|
||||
|
||||
const cmd = state.getUpgradeCommand(baseRow);
|
||||
expect(cmd).toContain('$env:PULSE_ENABLE_COMMANDS="true"');
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCanonicalConnectionAgentId (via getAgentConnectionUpgradeCommand windows arm)', () => {
|
||||
it('resolves to empty for a non-agent connection (no PULSE_AGENT_ID env)', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
const connection: Connection = {
|
||||
...baseConnection,
|
||||
type: 'pve',
|
||||
id: 'pve:node-1',
|
||||
agentIdentity: undefined,
|
||||
};
|
||||
|
||||
const cmd = state.getAgentConnectionUpgradeCommand(connection, [], 'windows');
|
||||
expect(cmd).not.toContain('PULSE_AGENT_ID');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('strips the "agent:" prefix from the connection id', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
const connection: Connection = {
|
||||
...baseConnection,
|
||||
type: 'agent',
|
||||
id: 'agent:agent-42',
|
||||
agentIdentity: { hostname: 'host-42' },
|
||||
};
|
||||
|
||||
const cmd = state.getAgentConnectionUpgradeCommand(connection, [], 'windows');
|
||||
expect(cmd).toContain('$env:PULSE_AGENT_ID="agent-42"');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('keeps the id unchanged when it has no "agent:" prefix', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
const connection: Connection = {
|
||||
...baseConnection,
|
||||
type: 'agent',
|
||||
id: 'agent-99',
|
||||
agentIdentity: { hostname: 'host-99' },
|
||||
};
|
||||
|
||||
const cmd = state.getAgentConnectionUpgradeCommand(connection, [], 'windows');
|
||||
expect(cmd).toContain('$env:PULSE_AGENT_ID="agent-99"');
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCanonicalConnectionHostname (via getAgentConnectionUpgradeCommand windows arm)', () => {
|
||||
const platform = 'windows' as AgentPlatform;
|
||||
|
||||
it('uses the reported agent hostname when present', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
const connection: Connection = {
|
||||
...baseConnection,
|
||||
agentIdentity: { hostname: 'node-1.internal' },
|
||||
};
|
||||
|
||||
expect(state.getAgentConnectionUpgradeCommand(connection, [], platform)).toContain(
|
||||
'$env:PULSE_HOSTNAME="node-1.internal"',
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('falls back to a scheme-less address when no hostname is reported', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
const connection: Connection = {
|
||||
...baseConnection,
|
||||
address: '10.0.0.9',
|
||||
agentIdentity: undefined,
|
||||
};
|
||||
|
||||
expect(state.getAgentConnectionUpgradeCommand(connection, [], platform)).toContain(
|
||||
'$env:PULSE_HOSTNAME="10.0.0.9"',
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('skips an address that carries a scheme and falls back to the connection name', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
const connection: Connection = {
|
||||
...baseConnection,
|
||||
name: 'NamedBox',
|
||||
address: 'https://10.0.0.5',
|
||||
agentIdentity: undefined,
|
||||
};
|
||||
|
||||
expect(state.getAgentConnectionUpgradeCommand(connection, [], platform)).toContain(
|
||||
'$env:PULSE_HOSTNAME="NamedBox"',
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('falls back to the connection name when hostname and address are both empty', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
const connection: Connection = {
|
||||
...baseConnection,
|
||||
name: 'NameOnly',
|
||||
address: '',
|
||||
agentIdentity: undefined,
|
||||
};
|
||||
|
||||
expect(state.getAgentConnectionUpgradeCommand(connection, [], platform)).toContain(
|
||||
'$env:PULSE_HOSTNAME="NameOnly"',
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,716 @@
|
|||
import { createRoot, createSignal } from 'solid-js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SettingsTab } from '../settingsNavigationModel';
|
||||
import { useSystemSettingsState } from '../useSystemSettingsState';
|
||||
|
||||
// The sibling test drives every dependency through vi.doMock + vi.resetModules +
|
||||
// a dynamic re-import of the target. That re-imports a second instrumented copy
|
||||
// of the hook and makes its coverage unmeasurable, so this file instead uses
|
||||
// hoisted vi.mock factories and a single static import of the target. The
|
||||
// createRoot/dispose invocation pattern and the mock object shapes are otherwise
|
||||
// identical to the sibling test.
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getSystemSettingsMock: vi.fn(),
|
||||
updateSystemSettingsMock: vi.fn(),
|
||||
getTelemetryPreviewMock: vi.fn(),
|
||||
resetTelemetryInstallIDMock: vi.fn(),
|
||||
getVersionMock: vi.fn(),
|
||||
getUpdatePlanMock: vi.fn(),
|
||||
copyToClipboardMock: vi.fn(),
|
||||
notificationSuccessMock: vi.fn(),
|
||||
notificationErrorMock: vi.fn(),
|
||||
notificationInfoMock: vi.fn(),
|
||||
loggerErrorMock: vi.fn(),
|
||||
loggerWarnMock: vi.fn(),
|
||||
updateStoreCheckForUpdatesMock: vi.fn(),
|
||||
updateStoreApplyUpdateMock: vi.fn(),
|
||||
updateStoreUpdateInfoMock: vi.fn(),
|
||||
updateStoreVersionInfoMock: vi.fn(),
|
||||
updateStoreIsDismissedMock: vi.fn(),
|
||||
updateStoreClearDismissedMock: vi.fn(),
|
||||
updateDockerUpdateActionsSettingMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/settings', () => ({
|
||||
SettingsAPI: {
|
||||
getSystemSettings: mocks.getSystemSettingsMock,
|
||||
getTelemetryPreview: mocks.getTelemetryPreviewMock,
|
||||
resetTelemetryInstallID: mocks.resetTelemetryInstallIDMock,
|
||||
updateSystemSettings: mocks.updateSystemSettingsMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/api/updates', () => ({
|
||||
UpdatesAPI: {
|
||||
getVersion: mocks.getVersionMock,
|
||||
getUpdatePlan: mocks.getUpdatePlanMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/notifications', () => ({
|
||||
notificationStore: {
|
||||
success: mocks.notificationSuccessMock,
|
||||
error: mocks.notificationErrorMock,
|
||||
info: mocks.notificationInfoMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
logger: {
|
||||
error: mocks.loggerErrorMock,
|
||||
warn: mocks.loggerWarnMock,
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/clipboard', () => ({
|
||||
copyToClipboard: mocks.copyToClipboardMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/apiClient', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
apiFetchJSON: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/updates', () => ({
|
||||
updateStore: {
|
||||
checkForUpdates: mocks.updateStoreCheckForUpdatesMock,
|
||||
applyUpdate: mocks.updateStoreApplyUpdateMock,
|
||||
updateInfo: mocks.updateStoreUpdateInfoMock,
|
||||
versionInfo: mocks.updateStoreVersionInfoMock,
|
||||
isDismissed: mocks.updateStoreIsDismissedMock,
|
||||
clearDismissed: mocks.updateStoreClearDismissedMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/systemSettings', () => ({
|
||||
updateDockerUpdateActionsSetting: mocks.updateDockerUpdateActionsSettingMock,
|
||||
}));
|
||||
|
||||
type HookState = ReturnType<typeof useSystemSettingsState>;
|
||||
|
||||
const flushAsync = async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
describe('useSystemSettingsState branch coverage', () => {
|
||||
beforeEach(() => {
|
||||
mocks.getSystemSettingsMock.mockResolvedValue({});
|
||||
mocks.updateSystemSettingsMock.mockResolvedValue(undefined);
|
||||
mocks.getUpdatePlanMock.mockResolvedValue({
|
||||
canAutoUpdate: false,
|
||||
requiresRoot: false,
|
||||
rollbackSupport: false,
|
||||
});
|
||||
mocks.updateStoreCheckForUpdatesMock.mockResolvedValue(undefined);
|
||||
mocks.updateStoreApplyUpdateMock.mockResolvedValue(true);
|
||||
mocks.updateStoreUpdateInfoMock.mockReturnValue(null);
|
||||
mocks.updateStoreVersionInfoMock.mockReturnValue(null);
|
||||
mocks.updateStoreIsDismissedMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// resetAllMocks (not just clearAllMocks) so any one-shot value queues from
|
||||
// mockRejectedValueOnce/mockResolvedValueOnce cannot leak across tests; the
|
||||
// single static import of the target is unaffected.
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const mountHook = (options?: {
|
||||
activeTab?: SettingsTab;
|
||||
loadSecurityStatus?: () => Promise<void>;
|
||||
}): { dispose: () => void; hookState: HookState } => {
|
||||
let dispose = () => {};
|
||||
let hookState: HookState;
|
||||
|
||||
createRoot((d) => {
|
||||
dispose = d;
|
||||
const [, setDiscoveryEnabled] = createSignal(false);
|
||||
hookState = useSystemSettingsState({
|
||||
activeTab: () => options?.activeTab ?? ('system-updates' as SettingsTab),
|
||||
loadSecurityStatus: options?.loadSecurityStatus ?? (async () => {}),
|
||||
setDiscoveryEnabled,
|
||||
applySavedDiscoverySubnet: () => {},
|
||||
});
|
||||
});
|
||||
|
||||
return { dispose, hookState: hookState! };
|
||||
};
|
||||
|
||||
// envOverrides is an internal signal with no exposed setter, so the only way
|
||||
// to drive the *Locked accessors is to seed it through initializeSystemSettingsState.
|
||||
const mountWithEnvOverrides = async (
|
||||
envOverrides: Record<string, boolean>,
|
||||
): Promise<{ dispose: () => void; hookState: HookState }> => {
|
||||
mocks.getSystemSettingsMock.mockResolvedValue({ envOverrides });
|
||||
const mounted = mountHook();
|
||||
await mounted.hookState.initializeSystemSettingsState();
|
||||
await flushAsync();
|
||||
return mounted;
|
||||
};
|
||||
|
||||
describe.each([
|
||||
{
|
||||
name: 'temperatureMonitoringLocked',
|
||||
accessor: (h: HookState) => h.temperatureMonitoringLocked(),
|
||||
firstKey: 'temperatureMonitoringEnabled',
|
||||
secondKey: 'ENABLE_TEMPERATURE_MONITORING',
|
||||
},
|
||||
{
|
||||
name: 'hideLocalLoginLocked',
|
||||
accessor: (h: HookState) => h.hideLocalLoginLocked(),
|
||||
firstKey: 'hideLocalLogin',
|
||||
secondKey: 'PULSE_AUTH_HIDE_LOCAL_LOGIN',
|
||||
},
|
||||
{
|
||||
name: 'disableDockerUpdateActionsLocked',
|
||||
accessor: (h: HookState) => h.disableDockerUpdateActionsLocked(),
|
||||
firstKey: 'disableDockerUpdateActions',
|
||||
secondKey: 'PULSE_DISABLE_DOCKER_UPDATE_ACTIONS',
|
||||
},
|
||||
{
|
||||
name: 'telemetryEnabledLocked',
|
||||
accessor: (h: HookState) => h.telemetryEnabledLocked(),
|
||||
firstKey: 'telemetryEnabled',
|
||||
secondKey: 'PULSE_TELEMETRY',
|
||||
},
|
||||
{
|
||||
name: 'pvePollingEnvLocked',
|
||||
accessor: (h: HookState) => h.pvePollingEnvLocked(),
|
||||
firstKey: 'pvePollingInterval',
|
||||
secondKey: 'PVE_POLLING_INTERVAL',
|
||||
},
|
||||
{
|
||||
name: 'backupPollingEnvLocked',
|
||||
accessor: (h: HookState) => h.backupPollingEnvLocked(),
|
||||
firstKey: 'ENABLE_BACKUP_POLLING',
|
||||
secondKey: 'BACKUP_POLLING_INTERVAL',
|
||||
},
|
||||
])('$name', ({ accessor, firstKey, secondKey }) => {
|
||||
it('reports unlocked when no env overrides are present', () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
expect(accessor(hookState)).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('locks when the primary override key is set', async () => {
|
||||
const { hookState, dispose } = await mountWithEnvOverrides({ [firstKey]: true });
|
||||
expect(accessor(hookState)).toBe(true);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('locks when the secondary ENV override key is set', async () => {
|
||||
const { hookState, dispose } = await mountWithEnvOverrides({ [secondKey]: true });
|
||||
expect(accessor(hookState)).toBe(true);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupIntervalSelectValue', () => {
|
||||
it('returns "custom" when custom polling is selected', () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
hookState.setBackupPollingUseCustom(true);
|
||||
hookState.setBackupPollingInterval(0);
|
||||
expect(hookState.backupIntervalSelectValue()).toBe('custom');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('returns the numeric preset value for a known preset interval', () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
hookState.setBackupPollingUseCustom(false);
|
||||
hookState.setBackupPollingInterval(900);
|
||||
expect(hookState.backupIntervalSelectValue()).toBe('900');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('returns "0" for the default preset interval', () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
hookState.setBackupPollingUseCustom(false);
|
||||
hookState.setBackupPollingInterval(0);
|
||||
expect(hookState.backupIntervalSelectValue()).toBe('0');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('falls back to "custom" for an out-of-range interval', () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
hookState.setBackupPollingUseCustom(false);
|
||||
hookState.setBackupPollingInterval(12345);
|
||||
expect(hookState.backupIntervalSelectValue()).toBe('custom');
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupIntervalSummary', () => {
|
||||
it('reports disabled when backup polling is off', () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
hookState.setBackupPollingEnabled(false);
|
||||
hookState.setBackupPollingInterval(3600);
|
||||
expect(hookState.backupIntervalSummary()).toBe('Backup polling is disabled.');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('reports the default cadence when the interval is zero', () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
hookState.setBackupPollingEnabled(true);
|
||||
hookState.setBackupPollingInterval(0);
|
||||
expect(hookState.backupIntervalSummary()).toBe(
|
||||
'Pulse checks backups and snapshots at the default cadence (~every 90 seconds).',
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('renders a single day cadence', () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
hookState.setBackupPollingEnabled(true);
|
||||
hookState.setBackupPollingInterval(86400);
|
||||
expect(hookState.backupIntervalSummary()).toBe('Pulse checks backups every day.');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('renders a multi-day cadence', () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
hookState.setBackupPollingEnabled(true);
|
||||
hookState.setBackupPollingInterval(172800);
|
||||
expect(hookState.backupIntervalSummary()).toBe('Pulse checks backups every 2 days.');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('renders a single hour cadence', () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
hookState.setBackupPollingEnabled(true);
|
||||
hookState.setBackupPollingInterval(3600);
|
||||
expect(hookState.backupIntervalSummary()).toBe('Pulse checks backups every hour.');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('renders a multi-hour cadence', () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
hookState.setBackupPollingEnabled(true);
|
||||
hookState.setBackupPollingInterval(7200);
|
||||
expect(hookState.backupIntervalSummary()).toBe('Pulse checks backups every 2 hours.');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('renders a single minute cadence', () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
hookState.setBackupPollingEnabled(true);
|
||||
hookState.setBackupPollingInterval(60);
|
||||
expect(hookState.backupIntervalSummary()).toBe('Pulse checks backups every minute.');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('renders a multi-minute cadence', () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
hookState.setBackupPollingEnabled(true);
|
||||
hookState.setBackupPollingInterval(300);
|
||||
expect(hookState.backupIntervalSummary()).toBe('Pulse checks backups every 5 minutes.');
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleHideLocalLoginChange', () => {
|
||||
it('is a no-op when the setting is locked by an env override', async () => {
|
||||
const { hookState, dispose } = await mountWithEnvOverrides({
|
||||
PULSE_AUTH_HIDE_LOCAL_LOGIN: true,
|
||||
});
|
||||
await hookState.handleHideLocalLoginChange(true);
|
||||
await flushAsync();
|
||||
expect(mocks.updateSystemSettingsMock).not.toHaveBeenCalled();
|
||||
expect(hookState.hideLocalLogin()).toBe(false);
|
||||
expect(hookState.savingHideLocalLogin()).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('hides local login, toasts success, and refreshes security status', async () => {
|
||||
const loadSecurityStatus = vi.fn().mockResolvedValue(undefined);
|
||||
const { hookState, dispose } = mountHook({ loadSecurityStatus });
|
||||
await hookState.handleHideLocalLoginChange(true);
|
||||
await flushAsync();
|
||||
expect(mocks.updateSystemSettingsMock).toHaveBeenCalledWith({ hideLocalLogin: true });
|
||||
expect(hookState.hideLocalLogin()).toBe(true);
|
||||
expect(mocks.notificationSuccessMock).toHaveBeenCalledWith('Local login hidden', 2000);
|
||||
expect(loadSecurityStatus).toHaveBeenCalledTimes(1);
|
||||
expect(hookState.savingHideLocalLogin()).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('uses the info toast and posts false when unhiding local login', async () => {
|
||||
const loadSecurityStatus = vi.fn().mockResolvedValue(undefined);
|
||||
const { hookState, dispose } = mountHook({ loadSecurityStatus });
|
||||
await hookState.handleHideLocalLoginChange(false);
|
||||
await flushAsync();
|
||||
expect(mocks.updateSystemSettingsMock).toHaveBeenCalledWith({ hideLocalLogin: false });
|
||||
expect(mocks.notificationInfoMock).toHaveBeenCalledWith('Local login visible', 2000);
|
||||
expect(hookState.hideLocalLogin()).toBe(false);
|
||||
expect(loadSecurityStatus).toHaveBeenCalledTimes(1);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('forwards the backend error message and reverts the toggle on API failure', async () => {
|
||||
const loadSecurityStatus = vi.fn().mockResolvedValue(undefined);
|
||||
mocks.updateSystemSettingsMock.mockRejectedValueOnce(new Error('hide failed'));
|
||||
const { hookState, dispose } = mountHook({ loadSecurityStatus });
|
||||
await hookState.handleHideLocalLoginChange(true);
|
||||
await flushAsync();
|
||||
expect(hookState.hideLocalLogin()).toBe(false);
|
||||
expect(mocks.notificationErrorMock).toHaveBeenCalledWith('hide failed');
|
||||
expect(loadSecurityStatus).not.toHaveBeenCalled();
|
||||
expect(hookState.savingHideLocalLogin()).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('falls back to the default message for non-Error failures', async () => {
|
||||
mocks.updateSystemSettingsMock.mockRejectedValueOnce('unknown fault' as unknown as Error);
|
||||
const { hookState, dispose } = mountHook();
|
||||
await hookState.handleHideLocalLoginChange(true);
|
||||
await flushAsync();
|
||||
expect(hookState.hideLocalLogin()).toBe(false);
|
||||
expect(mocks.notificationErrorMock).toHaveBeenCalledWith(
|
||||
'Unable to update local login visibility.',
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDisableDockerUpdateActionsChange', () => {
|
||||
it('is a no-op when the setting is locked by an env override', async () => {
|
||||
const { hookState, dispose } = await mountWithEnvOverrides({
|
||||
PULSE_DISABLE_DOCKER_UPDATE_ACTIONS: true,
|
||||
});
|
||||
await hookState.handleDisableDockerUpdateActionsChange(true);
|
||||
await flushAsync();
|
||||
expect(mocks.updateSystemSettingsMock).not.toHaveBeenCalled();
|
||||
expect(mocks.updateDockerUpdateActionsSettingMock).not.toHaveBeenCalled();
|
||||
expect(hookState.disableDockerUpdateActions()).toBe(false);
|
||||
expect(hookState.savingDockerUpdateActions()).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('hides docker update buttons and mirrors the value into the shared store', async () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
await hookState.handleDisableDockerUpdateActionsChange(true);
|
||||
await flushAsync();
|
||||
expect(mocks.updateSystemSettingsMock).toHaveBeenCalledWith({
|
||||
disableDockerUpdateActions: true,
|
||||
});
|
||||
expect(mocks.updateDockerUpdateActionsSettingMock).toHaveBeenCalledWith(true);
|
||||
expect(mocks.notificationSuccessMock).toHaveBeenCalledWith(
|
||||
'Docker update buttons hidden',
|
||||
2000,
|
||||
);
|
||||
expect(hookState.disableDockerUpdateActions()).toBe(true);
|
||||
expect(hookState.savingDockerUpdateActions()).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('restores docker update buttons when disabling the hide flag', async () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
await hookState.handleDisableDockerUpdateActionsChange(false);
|
||||
await flushAsync();
|
||||
expect(mocks.updateSystemSettingsMock).toHaveBeenCalledWith({
|
||||
disableDockerUpdateActions: false,
|
||||
});
|
||||
expect(mocks.updateDockerUpdateActionsSettingMock).toHaveBeenCalledWith(false);
|
||||
expect(mocks.notificationInfoMock).toHaveBeenCalledWith(
|
||||
'Docker update buttons visible',
|
||||
2000,
|
||||
);
|
||||
expect(hookState.disableDockerUpdateActions()).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('forwards the backend error message and reverts the toggle on API failure', async () => {
|
||||
mocks.updateSystemSettingsMock.mockRejectedValueOnce(new Error('docker failed'));
|
||||
const { hookState, dispose } = mountHook();
|
||||
await hookState.handleDisableDockerUpdateActionsChange(true);
|
||||
await flushAsync();
|
||||
expect(hookState.disableDockerUpdateActions()).toBe(false);
|
||||
expect(mocks.notificationErrorMock).toHaveBeenCalledWith('docker failed');
|
||||
expect(mocks.updateDockerUpdateActionsSettingMock).not.toHaveBeenCalled();
|
||||
expect(hookState.savingDockerUpdateActions()).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('falls back to the default message for non-Error failures', async () => {
|
||||
mocks.updateSystemSettingsMock.mockRejectedValueOnce('unknown fault' as unknown as Error);
|
||||
const { hookState, dispose } = mountHook();
|
||||
await hookState.handleDisableDockerUpdateActionsChange(true);
|
||||
await flushAsync();
|
||||
expect(hookState.disableDockerUpdateActions()).toBe(false);
|
||||
expect(mocks.notificationErrorMock).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^Unable to update .+ update actions\.$/),
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleTelemetryEnabledChange', () => {
|
||||
it('is a no-op when the setting is locked by an env override', async () => {
|
||||
const { hookState, dispose } = await mountWithEnvOverrides({ PULSE_TELEMETRY: true });
|
||||
await hookState.handleTelemetryEnabledChange(true);
|
||||
await flushAsync();
|
||||
expect(mocks.updateSystemSettingsMock).not.toHaveBeenCalled();
|
||||
expect(hookState.telemetryEnabled()).toBe(true);
|
||||
expect(hookState.savingTelemetry()).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('enables telemetry and toasts the enabled message', async () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
await hookState.handleTelemetryEnabledChange(true);
|
||||
await flushAsync();
|
||||
expect(mocks.updateSystemSettingsMock).toHaveBeenCalledWith({ telemetryEnabled: true });
|
||||
expect(hookState.telemetryEnabled()).toBe(true);
|
||||
expect(mocks.notificationSuccessMock).toHaveBeenCalledWith(
|
||||
'Outbound usage telemetry enabled',
|
||||
3000,
|
||||
);
|
||||
expect(hookState.savingTelemetry()).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('disables telemetry and toasts the disabled message', async () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
await hookState.handleTelemetryEnabledChange(false);
|
||||
await flushAsync();
|
||||
expect(mocks.updateSystemSettingsMock).toHaveBeenCalledWith({ telemetryEnabled: false });
|
||||
expect(hookState.telemetryEnabled()).toBe(false);
|
||||
expect(mocks.notificationSuccessMock).toHaveBeenCalledWith(
|
||||
'Outbound usage telemetry disabled',
|
||||
3000,
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('forwards the backend error message and reverts the toggle on API failure', async () => {
|
||||
mocks.updateSystemSettingsMock.mockRejectedValueOnce(new Error('telemetry failed'));
|
||||
const { hookState, dispose } = mountHook();
|
||||
await hookState.handleTelemetryEnabledChange(false);
|
||||
await flushAsync();
|
||||
expect(hookState.telemetryEnabled()).toBe(true);
|
||||
expect(mocks.notificationErrorMock).toHaveBeenCalledWith('telemetry failed');
|
||||
expect(hookState.savingTelemetry()).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('falls back to the default message for non-Error failures', async () => {
|
||||
mocks.updateSystemSettingsMock.mockRejectedValueOnce('unknown fault' as unknown as Error);
|
||||
const { hookState, dispose } = mountHook();
|
||||
await hookState.handleTelemetryEnabledChange(false);
|
||||
await flushAsync();
|
||||
expect(hookState.telemetryEnabled()).toBe(true);
|
||||
expect(mocks.notificationErrorMock).toHaveBeenCalledWith(
|
||||
'Unable to update outbound usage telemetry.',
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleTemperatureMonitoringChange', () => {
|
||||
it('is a no-op when the setting is locked by an env override', async () => {
|
||||
const { hookState, dispose } = await mountWithEnvOverrides({
|
||||
ENABLE_TEMPERATURE_MONITORING: true,
|
||||
});
|
||||
await hookState.handleTemperatureMonitoringChange(false);
|
||||
await flushAsync();
|
||||
expect(mocks.updateSystemSettingsMock).not.toHaveBeenCalled();
|
||||
expect(hookState.temperatureMonitoringEnabled()).toBe(true);
|
||||
expect(hookState.savingTemperatureSetting()).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('is a no-op while a save is already in flight', async () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
hookState.setSavingTemperatureSetting(true);
|
||||
await hookState.handleTemperatureMonitoringChange(false);
|
||||
await flushAsync();
|
||||
expect(mocks.updateSystemSettingsMock).not.toHaveBeenCalled();
|
||||
expect(hookState.temperatureMonitoringEnabled()).toBe(true);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('enables temperature monitoring and toasts success', async () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
hookState.setTemperatureMonitoringEnabled(false);
|
||||
await hookState.handleTemperatureMonitoringChange(true);
|
||||
await flushAsync();
|
||||
expect(mocks.updateSystemSettingsMock).toHaveBeenCalledWith({
|
||||
temperatureMonitoringEnabled: true,
|
||||
});
|
||||
expect(hookState.temperatureMonitoringEnabled()).toBe(true);
|
||||
expect(mocks.notificationSuccessMock).toHaveBeenCalledWith(
|
||||
'Temperature monitoring enabled',
|
||||
2000,
|
||||
);
|
||||
expect(hookState.savingTemperatureSetting()).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('disables temperature monitoring and toasts an info message', async () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
await hookState.handleTemperatureMonitoringChange(false);
|
||||
await flushAsync();
|
||||
expect(mocks.updateSystemSettingsMock).toHaveBeenCalledWith({
|
||||
temperatureMonitoringEnabled: false,
|
||||
});
|
||||
expect(hookState.temperatureMonitoringEnabled()).toBe(false);
|
||||
expect(mocks.notificationInfoMock).toHaveBeenCalledWith(
|
||||
'Temperature monitoring disabled',
|
||||
2000,
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('forwards the backend error message and reverts the toggle on API failure', async () => {
|
||||
mocks.updateSystemSettingsMock.mockRejectedValueOnce(new Error('temp failed'));
|
||||
const { hookState, dispose } = mountHook();
|
||||
await hookState.handleTemperatureMonitoringChange(false);
|
||||
await flushAsync();
|
||||
expect(hookState.temperatureMonitoringEnabled()).toBe(true);
|
||||
expect(mocks.notificationErrorMock).toHaveBeenCalledWith('temp failed');
|
||||
expect(hookState.savingTemperatureSetting()).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('falls back to the default message for non-Error failures', async () => {
|
||||
mocks.updateSystemSettingsMock.mockRejectedValueOnce('unknown fault' as unknown as Error);
|
||||
const { hookState, dispose } = mountHook();
|
||||
await hookState.handleTemperatureMonitoringChange(false);
|
||||
await flushAsync();
|
||||
expect(hookState.temperatureMonitoringEnabled()).toBe(true);
|
||||
expect(mocks.notificationErrorMock).toHaveBeenCalledWith(
|
||||
'Unable to update temperature monitoring.',
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForUpdates', () => {
|
||||
it('fetches the plan and clears a dismissed update when one is available', async () => {
|
||||
const plan = { canAutoUpdate: true, requiresRoot: false, rollbackSupport: true };
|
||||
mocks.getUpdatePlanMock.mockResolvedValue(plan);
|
||||
mocks.updateStoreUpdateInfoMock.mockReturnValue({
|
||||
available: true,
|
||||
latestVersion: '2.0.0',
|
||||
});
|
||||
mocks.updateStoreIsDismissedMock.mockReturnValue(true);
|
||||
const { hookState, dispose } = mountHook();
|
||||
await hookState.checkForUpdates();
|
||||
await flushAsync();
|
||||
expect(mocks.updateStoreCheckForUpdatesMock).toHaveBeenCalledWith(true);
|
||||
expect(mocks.getUpdatePlanMock).toHaveBeenCalledWith('2.0.0');
|
||||
expect(hookState.updatePlan()).toEqual(plan);
|
||||
expect(hookState.updateInfo()).toEqual({ available: true, latestVersion: '2.0.0' });
|
||||
expect(mocks.updateStoreClearDismissedMock).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.notificationSuccessMock).not.toHaveBeenCalledWith(
|
||||
'You are running the latest version',
|
||||
);
|
||||
expect(hookState.checkingForUpdates()).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('toast the latest-version message and clears the plan when no update is available', async () => {
|
||||
mocks.updateStoreUpdateInfoMock.mockReturnValue(null);
|
||||
const { hookState, dispose } = mountHook();
|
||||
await hookState.checkForUpdates();
|
||||
await flushAsync();
|
||||
expect(mocks.getUpdatePlanMock).not.toHaveBeenCalled();
|
||||
expect(hookState.updatePlan()).toBeNull();
|
||||
expect(mocks.notificationSuccessMock).toHaveBeenCalledWith(
|
||||
'You are running the latest version',
|
||||
);
|
||||
expect(mocks.updateStoreClearDismissedMock).not.toHaveBeenCalled();
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('clears the plan and warns when the plan fetch fails for an available update', async () => {
|
||||
mocks.updateStoreUpdateInfoMock.mockReturnValue({
|
||||
available: true,
|
||||
latestVersion: '2.0.0',
|
||||
});
|
||||
mocks.getUpdatePlanMock.mockRejectedValue(new Error('plan down'));
|
||||
const { hookState, dispose } = mountHook();
|
||||
await hookState.checkForUpdates();
|
||||
await flushAsync();
|
||||
expect(hookState.updatePlan()).toBeNull();
|
||||
expect(mocks.loggerWarnMock).toHaveBeenCalledWith(
|
||||
'Failed to fetch update plan',
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(mocks.updateStoreClearDismissedMock).not.toHaveBeenCalled();
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('toast an error and logs when checking for updates throws', async () => {
|
||||
mocks.updateStoreCheckForUpdatesMock.mockRejectedValue(new Error('network down'));
|
||||
const { hookState, dispose } = mountHook();
|
||||
await hookState.checkForUpdates();
|
||||
await flushAsync();
|
||||
expect(mocks.notificationErrorMock).toHaveBeenCalledWith('Unable to check for updates.');
|
||||
expect(mocks.loggerErrorMock).toHaveBeenCalledWith('Update check error', expect.any(Error));
|
||||
expect(hookState.checkingForUpdates()).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('skips the plan fetch when an update is reported without a latest version', async () => {
|
||||
mocks.updateStoreUpdateInfoMock.mockReturnValue({ available: true, latestVersion: '' });
|
||||
const { hookState, dispose } = mountHook();
|
||||
await hookState.checkForUpdates();
|
||||
await flushAsync();
|
||||
expect(mocks.getUpdatePlanMock).not.toHaveBeenCalled();
|
||||
expect(hookState.updatePlan()).toBeNull();
|
||||
expect(mocks.notificationSuccessMock).not.toHaveBeenCalledWith(
|
||||
'You are running the latest version',
|
||||
);
|
||||
expect(mocks.updateStoreClearDismissedMock).not.toHaveBeenCalled();
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleInstallUpdate', () => {
|
||||
it('opens the update confirmation dialog', async () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
expect(hookState.showUpdateConfirmation()).toBe(false);
|
||||
await hookState.handleInstallUpdate();
|
||||
expect(hookState.showUpdateConfirmation()).toBe(true);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleConfirmUpdate', () => {
|
||||
it('applies the update and closes the confirmation when the store starts it', async () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
hookState.setShowUpdateConfirmation(true);
|
||||
mocks.updateStoreApplyUpdateMock.mockResolvedValue(true);
|
||||
await hookState.handleConfirmUpdate();
|
||||
await flushAsync();
|
||||
expect(mocks.updateStoreApplyUpdateMock).toHaveBeenCalledTimes(1);
|
||||
expect(hookState.showUpdateConfirmation()).toBe(false);
|
||||
expect(hookState.isInstallingUpdate()).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('keeps the confirmation open when the store declines to start', async () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
hookState.setShowUpdateConfirmation(true);
|
||||
mocks.updateStoreApplyUpdateMock.mockResolvedValue(false);
|
||||
await hookState.handleConfirmUpdate();
|
||||
await flushAsync();
|
||||
expect(hookState.showUpdateConfirmation()).toBe(true);
|
||||
expect(hookState.isInstallingUpdate()).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('still clears the installing flag (finally) when the store throws', async () => {
|
||||
const { hookState, dispose } = mountHook();
|
||||
hookState.setShowUpdateConfirmation(true);
|
||||
mocks.updateStoreApplyUpdateMock.mockRejectedValue(new Error('apply failed'));
|
||||
await expect(hookState.handleConfirmUpdate()).rejects.toThrow('apply failed');
|
||||
expect(hookState.isInstallingUpdate()).toBe(false);
|
||||
expect(hookState.showUpdateConfirmation()).toBe(true);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue