828 lines
25 KiB
TypeScript
828 lines
25 KiB
TypeScript
import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0';
|
|
import { NupstSnmp } from '../ts/snmp/manager.ts';
|
|
import { UpsOidSets } from '../ts/snmp/oid-sets.ts';
|
|
import type { IOidSet, ISnmpConfig, TUpsModel } from '../ts/snmp/types.ts';
|
|
import {
|
|
analyzeConfigReload,
|
|
shouldRefreshPauseState,
|
|
shouldReloadConfig,
|
|
} from '../ts/config-watch.ts';
|
|
import { type IPauseState, loadPauseSnapshot } from '../ts/pause-state.ts';
|
|
import { shortId } from '../ts/helpers/shortid.ts';
|
|
import { HTTP_SERVER, SNMP, THRESHOLDS, TIMING, UI } from '../ts/constants.ts';
|
|
import { Action, type IActionContext } from '../ts/actions/base-action.ts';
|
|
import {
|
|
applyDefaultShutdownDelay,
|
|
buildUpsActionContext,
|
|
decideUpsActionExecution,
|
|
} from '../ts/action-orchestration.ts';
|
|
import {
|
|
buildShutdownErrorRow,
|
|
buildShutdownStatusRow,
|
|
selectEmergencyCandidate,
|
|
} from '../ts/shutdown-monitoring.ts';
|
|
import {
|
|
buildFailedUpsPollSnapshot,
|
|
buildSuccessfulUpsPollSnapshot,
|
|
hasThresholdViolation,
|
|
} from '../ts/ups-monitoring.ts';
|
|
import { createInitialUpsStatus } from '../ts/ups-status.ts';
|
|
|
|
import * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
|
|
const testQenv = new qenv.Qenv('./', '.nogit/');
|
|
|
|
// =============================================================================
|
|
// UNIT TESTS - No external dependencies required
|
|
// =============================================================================
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// shortId() Tests
|
|
// -----------------------------------------------------------------------------
|
|
|
|
Deno.test('shortId: generates 6-character string', () => {
|
|
const id = shortId();
|
|
assertEquals(id.length, 6);
|
|
});
|
|
|
|
Deno.test('shortId: contains only alphanumeric characters', () => {
|
|
const id = shortId();
|
|
const alphanumericRegex = /^[a-zA-Z0-9]+$/;
|
|
assert(alphanumericRegex.test(id), `ID "${id}" contains non-alphanumeric characters`);
|
|
});
|
|
|
|
Deno.test('shortId: generates unique IDs', () => {
|
|
const ids = new Set<string>();
|
|
const count = 100;
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
ids.add(shortId());
|
|
}
|
|
|
|
// All IDs should be unique (statistically extremely likely for 100 IDs)
|
|
assertEquals(ids.size, count, 'Generated IDs should be unique');
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Constants Tests
|
|
// -----------------------------------------------------------------------------
|
|
|
|
Deno.test('TIMING constants: all values are positive numbers', () => {
|
|
for (const [key, value] of Object.entries(TIMING)) {
|
|
assert(typeof value === 'number', `TIMING.${key} should be a number`);
|
|
assert(value > 0, `TIMING.${key} should be positive`);
|
|
}
|
|
});
|
|
|
|
Deno.test('SNMP constants: port is 161', () => {
|
|
assertEquals(SNMP.DEFAULT_PORT, 161);
|
|
});
|
|
|
|
Deno.test('SNMP constants: timeouts increase with security level', () => {
|
|
assert(
|
|
SNMP.TIMEOUT_NO_AUTH_MS <= SNMP.TIMEOUT_AUTH_MS,
|
|
'Auth timeout should be >= noAuth timeout',
|
|
);
|
|
assert(
|
|
SNMP.TIMEOUT_AUTH_MS <= SNMP.TIMEOUT_AUTH_PRIV_MS,
|
|
'AuthPriv timeout should be >= Auth timeout',
|
|
);
|
|
});
|
|
|
|
Deno.test('THRESHOLDS constants: defaults are reasonable', () => {
|
|
assert(THRESHOLDS.DEFAULT_BATTERY_PERCENT > 0 && THRESHOLDS.DEFAULT_BATTERY_PERCENT <= 100);
|
|
assert(THRESHOLDS.DEFAULT_RUNTIME_MINUTES > 0);
|
|
assert(THRESHOLDS.EMERGENCY_RUNTIME_MINUTES < THRESHOLDS.DEFAULT_RUNTIME_MINUTES);
|
|
});
|
|
|
|
Deno.test('HTTP_SERVER constants: valid defaults', () => {
|
|
assertEquals(HTTP_SERVER.DEFAULT_PORT, 8080);
|
|
assert(HTTP_SERVER.DEFAULT_PATH.startsWith('/'));
|
|
});
|
|
|
|
Deno.test('UI constants: box widths are ascending', () => {
|
|
assert(UI.DEFAULT_BOX_WIDTH < UI.WIDE_BOX_WIDTH);
|
|
assert(UI.WIDE_BOX_WIDTH < UI.EXTRA_WIDE_BOX_WIDTH);
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Pause State Tests
|
|
// -----------------------------------------------------------------------------
|
|
|
|
Deno.test('loadPauseSnapshot: reports paused state for valid pause file', async () => {
|
|
const tempDir = await Deno.makeTempDir();
|
|
const pauseFilePath = `${tempDir}/pause.json`;
|
|
const pauseState: IPauseState = {
|
|
pausedAt: 1000,
|
|
pausedBy: 'cli',
|
|
reason: 'maintenance',
|
|
resumeAt: 5000,
|
|
};
|
|
|
|
try {
|
|
await Deno.writeTextFile(pauseFilePath, JSON.stringify(pauseState));
|
|
|
|
const snapshot = loadPauseSnapshot(pauseFilePath, false, 2000);
|
|
|
|
assertEquals(snapshot.isPaused, true);
|
|
assertEquals(snapshot.pauseState, pauseState);
|
|
assertEquals(snapshot.transition, 'paused');
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
Deno.test('loadPauseSnapshot: auto-resumes expired pause file', async () => {
|
|
const tempDir = await Deno.makeTempDir();
|
|
const pauseFilePath = `${tempDir}/pause.json`;
|
|
const pauseState: IPauseState = {
|
|
pausedAt: 1000,
|
|
pausedBy: 'cli',
|
|
resumeAt: 1500,
|
|
};
|
|
|
|
try {
|
|
await Deno.writeTextFile(pauseFilePath, JSON.stringify(pauseState));
|
|
|
|
const snapshot = loadPauseSnapshot(pauseFilePath, true, 2000);
|
|
|
|
assertEquals(snapshot.isPaused, false);
|
|
assertEquals(snapshot.pauseState, null);
|
|
assertEquals(snapshot.transition, 'autoResumed');
|
|
|
|
let fileExists = true;
|
|
try {
|
|
await Deno.stat(pauseFilePath);
|
|
} catch {
|
|
fileExists = false;
|
|
}
|
|
assertEquals(fileExists, false);
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
Deno.test('loadPauseSnapshot: reports resumed when pause file disappears', async () => {
|
|
const tempDir = await Deno.makeTempDir();
|
|
|
|
try {
|
|
const snapshot = loadPauseSnapshot(`${tempDir}/pause.json`, true, 2000);
|
|
|
|
assertEquals(snapshot.isPaused, false);
|
|
assertEquals(snapshot.pauseState, null);
|
|
assertEquals(snapshot.transition, 'resumed');
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Config Watch Tests
|
|
// -----------------------------------------------------------------------------
|
|
|
|
Deno.test('shouldReloadConfig: matches modify events for config.json', () => {
|
|
assertEquals(
|
|
shouldReloadConfig({ kind: 'modify', paths: ['/etc/nupst/config.json'] }),
|
|
true,
|
|
);
|
|
assertEquals(
|
|
shouldReloadConfig({ kind: 'create', paths: ['/etc/nupst/config.json'] }),
|
|
false,
|
|
);
|
|
assertEquals(
|
|
shouldReloadConfig({ kind: 'modify', paths: ['/etc/nupst/other.json'] }),
|
|
false,
|
|
);
|
|
});
|
|
|
|
Deno.test('shouldRefreshPauseState: matches create/modify/remove pause events', () => {
|
|
assertEquals(
|
|
shouldRefreshPauseState({ kind: 'create', paths: ['/etc/nupst/pause'] }),
|
|
true,
|
|
);
|
|
assertEquals(
|
|
shouldRefreshPauseState({ kind: 'remove', paths: ['/etc/nupst/pause'] }),
|
|
true,
|
|
);
|
|
assertEquals(
|
|
shouldRefreshPauseState({ kind: 'modify', paths: ['/etc/nupst/config.json'] }),
|
|
false,
|
|
);
|
|
});
|
|
|
|
Deno.test('analyzeConfigReload: detects monitoring start and device count changes', () => {
|
|
assertEquals(analyzeConfigReload(0, 2), {
|
|
transition: 'monitoringWillStart',
|
|
message: 'Configuration reloaded! Found 2 UPS device(s)',
|
|
shouldInitializeUpsStatus: false,
|
|
shouldLogMonitoringStart: true,
|
|
});
|
|
|
|
assertEquals(analyzeConfigReload(2, 3), {
|
|
transition: 'deviceCountChanged',
|
|
message: 'Configuration reloaded! UPS devices: 2 -> 3',
|
|
shouldInitializeUpsStatus: true,
|
|
shouldLogMonitoringStart: false,
|
|
});
|
|
|
|
assertEquals(analyzeConfigReload(2, 2), {
|
|
transition: 'reloaded',
|
|
message: 'Configuration reloaded successfully',
|
|
shouldInitializeUpsStatus: false,
|
|
shouldLogMonitoringStart: false,
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// UPS Status Tests
|
|
// -----------------------------------------------------------------------------
|
|
|
|
Deno.test('createInitialUpsStatus: creates default daemon UPS status shape', () => {
|
|
assertEquals(createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1234), {
|
|
id: 'ups-1',
|
|
name: 'Main UPS',
|
|
powerStatus: 'unknown',
|
|
batteryCapacity: 100,
|
|
batteryRuntime: 999,
|
|
outputLoad: 0,
|
|
outputPower: 0,
|
|
outputVoltage: 0,
|
|
outputCurrent: 0,
|
|
lastStatusChange: 1234,
|
|
lastCheckTime: 0,
|
|
consecutiveFailures: 0,
|
|
unreachableSince: 0,
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Action Orchestration Tests
|
|
// -----------------------------------------------------------------------------
|
|
|
|
Deno.test('buildUpsActionContext: includes previous power status and timestamp', () => {
|
|
const status = {
|
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
|
powerStatus: 'onBattery' as const,
|
|
batteryCapacity: 42,
|
|
batteryRuntime: 15,
|
|
};
|
|
const previousStatus = {
|
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 500),
|
|
powerStatus: 'online' as const,
|
|
};
|
|
|
|
assertEquals(
|
|
buildUpsActionContext(
|
|
{ id: 'ups-1', name: 'Main UPS' },
|
|
status,
|
|
previousStatus,
|
|
'thresholdViolation',
|
|
9999,
|
|
),
|
|
{
|
|
upsId: 'ups-1',
|
|
upsName: 'Main UPS',
|
|
powerStatus: 'onBattery',
|
|
batteryCapacity: 42,
|
|
batteryRuntime: 15,
|
|
previousPowerStatus: 'online',
|
|
timestamp: 9999,
|
|
triggerReason: 'thresholdViolation',
|
|
},
|
|
);
|
|
});
|
|
|
|
Deno.test('decideUpsActionExecution: suppresses actions while paused', () => {
|
|
const decision = decideUpsActionExecution(
|
|
true,
|
|
{ id: 'ups-1', name: 'Main UPS', actions: [{ type: 'shutdown' }] },
|
|
createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
|
undefined,
|
|
'powerStatusChange',
|
|
9999,
|
|
);
|
|
|
|
assertEquals(decision, {
|
|
type: 'suppressed',
|
|
message: '[PAUSED] Actions suppressed for UPS Main UPS (trigger: powerStatusChange)',
|
|
});
|
|
});
|
|
|
|
Deno.test('decideUpsActionExecution: falls back to legacy shutdown without actions', () => {
|
|
const decision = decideUpsActionExecution(
|
|
false,
|
|
{ id: 'ups-1', name: 'Main UPS' },
|
|
createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
|
undefined,
|
|
'thresholdViolation',
|
|
9999,
|
|
);
|
|
|
|
assertEquals(decision, {
|
|
type: 'legacyShutdown',
|
|
reason: 'UPS "Main UPS" battery or runtime below threshold',
|
|
});
|
|
});
|
|
|
|
Deno.test('decideUpsActionExecution: returns executable action plan when actions exist', () => {
|
|
const decision = decideUpsActionExecution(
|
|
false,
|
|
{ id: 'ups-1', name: 'Main UPS', actions: [{ type: 'shutdown' }] },
|
|
{
|
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
|
powerStatus: 'onBattery',
|
|
batteryCapacity: 55,
|
|
batteryRuntime: 18,
|
|
},
|
|
{
|
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 500),
|
|
powerStatus: 'online',
|
|
},
|
|
'powerStatusChange',
|
|
9999,
|
|
);
|
|
|
|
assertEquals(decision, {
|
|
type: 'execute',
|
|
actions: [{ type: 'shutdown' }],
|
|
context: {
|
|
upsId: 'ups-1',
|
|
upsName: 'Main UPS',
|
|
powerStatus: 'onBattery',
|
|
batteryCapacity: 55,
|
|
batteryRuntime: 18,
|
|
previousPowerStatus: 'online',
|
|
timestamp: 9999,
|
|
triggerReason: 'powerStatusChange',
|
|
},
|
|
});
|
|
});
|
|
|
|
Deno.test('applyDefaultShutdownDelay: applies only to shutdown actions without explicit delay', () => {
|
|
const actions = [
|
|
{ type: 'shutdown' as const },
|
|
{ type: 'shutdown' as const, shutdownDelay: 0 },
|
|
{ type: 'shutdown' as const, shutdownDelay: 9 },
|
|
{ type: 'webhook' as const },
|
|
];
|
|
|
|
assertEquals(applyDefaultShutdownDelay(actions, 7), [
|
|
{ type: 'shutdown', shutdownDelay: 7 },
|
|
{ type: 'shutdown', shutdownDelay: 0 },
|
|
{ type: 'shutdown', shutdownDelay: 9 },
|
|
{ type: 'webhook' },
|
|
]);
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Shutdown Monitoring Tests
|
|
// -----------------------------------------------------------------------------
|
|
|
|
Deno.test('buildShutdownStatusRow: marks critical rows below emergency runtime threshold', () => {
|
|
const snapshot = buildShutdownStatusRow(
|
|
'Main UPS',
|
|
{
|
|
powerStatus: 'onBattery',
|
|
batteryCapacity: 25,
|
|
batteryRuntime: 4,
|
|
outputLoad: 15,
|
|
outputPower: 100,
|
|
outputVoltage: 230,
|
|
outputCurrent: 0.4,
|
|
raw: {},
|
|
},
|
|
5,
|
|
{
|
|
battery: (value) => `B:${value}`,
|
|
runtime: (value) => `R:${value}`,
|
|
ok: (text) => `ok:${text}`,
|
|
critical: (text) => `critical:${text}`,
|
|
error: (text) => `error:${text}`,
|
|
},
|
|
);
|
|
|
|
assertEquals(snapshot.isCritical, true);
|
|
assertEquals(snapshot.row, {
|
|
name: 'Main UPS',
|
|
battery: 'B:25',
|
|
runtime: 'R:4',
|
|
status: 'critical:CRITICAL!',
|
|
});
|
|
});
|
|
|
|
Deno.test('buildShutdownErrorRow: builds shutdown error table row', () => {
|
|
assertEquals(buildShutdownErrorRow('Main UPS', (text) => `error:${text}`), {
|
|
name: 'Main UPS',
|
|
battery: 'error:N/A',
|
|
runtime: 'error:N/A',
|
|
status: 'error:ERROR',
|
|
});
|
|
});
|
|
|
|
Deno.test('selectEmergencyCandidate: keeps first critical UPS candidate', () => {
|
|
const firstCandidate = selectEmergencyCandidate(
|
|
null,
|
|
{ id: 'ups-1', name: 'UPS 1' },
|
|
{
|
|
powerStatus: 'onBattery',
|
|
batteryCapacity: 40,
|
|
batteryRuntime: 4,
|
|
outputLoad: 10,
|
|
outputPower: 60,
|
|
outputVoltage: 230,
|
|
outputCurrent: 0.3,
|
|
raw: {},
|
|
},
|
|
5,
|
|
);
|
|
|
|
const secondCandidate = selectEmergencyCandidate(
|
|
firstCandidate,
|
|
{ id: 'ups-2', name: 'UPS 2' },
|
|
{
|
|
powerStatus: 'onBattery',
|
|
batteryCapacity: 30,
|
|
batteryRuntime: 3,
|
|
outputLoad: 15,
|
|
outputPower: 70,
|
|
outputVoltage: 230,
|
|
outputCurrent: 0.4,
|
|
raw: {},
|
|
},
|
|
5,
|
|
);
|
|
|
|
assertEquals(secondCandidate, firstCandidate);
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// UPS Monitoring Tests
|
|
// -----------------------------------------------------------------------------
|
|
|
|
Deno.test('buildSuccessfulUpsPollSnapshot: marks recovery from unreachable', () => {
|
|
const currentStatus = {
|
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
|
powerStatus: 'unreachable' as const,
|
|
unreachableSince: 2000,
|
|
consecutiveFailures: 3,
|
|
};
|
|
|
|
const snapshot = buildSuccessfulUpsPollSnapshot(
|
|
{ id: 'ups-1', name: 'Main UPS' },
|
|
{
|
|
powerStatus: 'online',
|
|
batteryCapacity: 95,
|
|
batteryRuntime: 40,
|
|
outputLoad: 10,
|
|
outputPower: 50,
|
|
outputVoltage: 230,
|
|
outputCurrent: 0.5,
|
|
raw: {},
|
|
},
|
|
currentStatus,
|
|
8000,
|
|
);
|
|
|
|
assertEquals(snapshot.transition, 'recovered');
|
|
assertEquals(snapshot.downtimeSeconds, 6);
|
|
assertEquals(snapshot.updatedStatus.powerStatus, 'online');
|
|
assertEquals(snapshot.updatedStatus.consecutiveFailures, 0);
|
|
assertEquals(snapshot.updatedStatus.lastStatusChange, 8000);
|
|
});
|
|
|
|
Deno.test('buildFailedUpsPollSnapshot: marks UPS unreachable at failure threshold', () => {
|
|
const currentStatus = {
|
|
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
|
powerStatus: 'onBattery' as const,
|
|
consecutiveFailures: 2,
|
|
};
|
|
|
|
const snapshot = buildFailedUpsPollSnapshot(
|
|
{ id: 'ups-1', name: 'Main UPS' },
|
|
currentStatus,
|
|
9000,
|
|
);
|
|
|
|
assertEquals(snapshot.transition, 'unreachable');
|
|
assertEquals(snapshot.failures, 3);
|
|
assertEquals(snapshot.updatedStatus.powerStatus, 'unreachable');
|
|
assertEquals(snapshot.updatedStatus.unreachableSince, 9000);
|
|
assertEquals(snapshot.updatedStatus.lastStatusChange, 9000);
|
|
});
|
|
|
|
Deno.test('hasThresholdViolation: only fires on battery when any action threshold is exceeded', () => {
|
|
assertEquals(
|
|
hasThresholdViolation('online', 40, 10, [
|
|
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
|
]),
|
|
false,
|
|
);
|
|
|
|
assertEquals(
|
|
hasThresholdViolation('onBattery', 40, 10, [
|
|
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
|
]),
|
|
true,
|
|
);
|
|
|
|
assertEquals(
|
|
hasThresholdViolation('onBattery', 90, 60, [
|
|
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
|
]),
|
|
false,
|
|
);
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// UpsOidSets Tests
|
|
// -----------------------------------------------------------------------------
|
|
|
|
const UPS_MODELS: TUpsModel[] = ['cyberpower', 'apc', 'eaton', 'tripplite', 'liebert', 'custom'];
|
|
|
|
Deno.test('UpsOidSets: all models have OID sets', () => {
|
|
for (const model of UPS_MODELS) {
|
|
const oidSet = UpsOidSets.getOidSet(model);
|
|
assertExists(oidSet, `OID set for ${model} should exist`);
|
|
}
|
|
});
|
|
|
|
Deno.test('UpsOidSets: all non-custom models have complete OIDs', () => {
|
|
const requiredOids = ['POWER_STATUS', 'BATTERY_CAPACITY', 'BATTERY_RUNTIME', 'OUTPUT_LOAD'];
|
|
|
|
for (const model of UPS_MODELS.filter((m) => m !== 'custom')) {
|
|
const oidSet = UpsOidSets.getOidSet(model);
|
|
|
|
for (const oid of requiredOids) {
|
|
const value = oidSet[oid as keyof IOidSet];
|
|
assert(
|
|
typeof value === 'string' && value.length > 0,
|
|
`${model} should have non-empty ${oid}`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
Deno.test('UpsOidSets: power status values defined for non-custom models', () => {
|
|
for (const model of UPS_MODELS.filter((m) => m !== 'custom')) {
|
|
const oidSet = UpsOidSets.getOidSet(model);
|
|
assertExists(oidSet.POWER_STATUS_VALUES, `${model} should have POWER_STATUS_VALUES`);
|
|
assertExists(oidSet.POWER_STATUS_VALUES?.online, `${model} should have online value`);
|
|
assertExists(oidSet.POWER_STATUS_VALUES?.onBattery, `${model} should have onBattery value`);
|
|
}
|
|
});
|
|
|
|
Deno.test('UpsOidSets: getStandardOids returns RFC 1628 OIDs', () => {
|
|
const standardOids = UpsOidSets.getStandardOids();
|
|
|
|
assert('power status' in standardOids);
|
|
assert('battery capacity' in standardOids);
|
|
assert('battery runtime' in standardOids);
|
|
|
|
// RFC 1628 OIDs start with 1.3.6.1.2.1.33
|
|
for (const oid of Object.values(standardOids)) {
|
|
assert(oid.startsWith('1.3.6.1.2.1.33'), `Standard OID should be RFC 1628: ${oid}`);
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Action Base Class Tests
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// Create a concrete implementation for testing
|
|
class TestAction extends Action {
|
|
readonly type = 'test';
|
|
executeCallCount = 0;
|
|
|
|
execute(_context: IActionContext): Promise<void> {
|
|
this.executeCallCount++;
|
|
return Promise.resolve();
|
|
}
|
|
|
|
// Expose protected methods for testing
|
|
public testShouldExecute(context: IActionContext): boolean {
|
|
return this.shouldExecute(context);
|
|
}
|
|
|
|
public testAreThresholdsExceeded(batteryCapacity: number, batteryRuntime: number): boolean {
|
|
return this.areThresholdsExceeded(batteryCapacity, batteryRuntime);
|
|
}
|
|
}
|
|
|
|
function createMockContext(overrides: Partial<IActionContext> = {}): IActionContext {
|
|
return {
|
|
upsId: 'test-ups',
|
|
upsName: 'Test UPS',
|
|
powerStatus: 'online',
|
|
batteryCapacity: 100,
|
|
batteryRuntime: 60,
|
|
previousPowerStatus: 'online',
|
|
timestamp: Date.now(),
|
|
triggerReason: 'powerStatusChange',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
Deno.test('Action.areThresholdsExceeded: returns false when no thresholds configured', () => {
|
|
const action = new TestAction({ type: 'shutdown' });
|
|
assertEquals(action.testAreThresholdsExceeded(50, 30), false);
|
|
});
|
|
|
|
Deno.test('Action.areThresholdsExceeded: returns true when battery below threshold', () => {
|
|
const action = new TestAction({
|
|
type: 'shutdown',
|
|
thresholds: { battery: 60, runtime: 20 },
|
|
});
|
|
|
|
assertEquals(action.testAreThresholdsExceeded(59, 30), true); // Battery below
|
|
assertEquals(action.testAreThresholdsExceeded(60, 30), false); // Battery at threshold
|
|
assertEquals(action.testAreThresholdsExceeded(100, 30), false); // Battery above
|
|
});
|
|
|
|
Deno.test('Action.areThresholdsExceeded: returns true when runtime below threshold', () => {
|
|
const action = new TestAction({
|
|
type: 'shutdown',
|
|
thresholds: { battery: 60, runtime: 20 },
|
|
});
|
|
|
|
assertEquals(action.testAreThresholdsExceeded(100, 19), true); // Runtime below
|
|
assertEquals(action.testAreThresholdsExceeded(100, 20), false); // Runtime at threshold
|
|
assertEquals(action.testAreThresholdsExceeded(100, 60), false); // Runtime above
|
|
});
|
|
|
|
Deno.test('Action.shouldExecute: onlyPowerChanges mode', () => {
|
|
const action = new TestAction({
|
|
type: 'shutdown',
|
|
triggerMode: 'onlyPowerChanges',
|
|
});
|
|
|
|
assertEquals(
|
|
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
|
|
true,
|
|
);
|
|
assertEquals(
|
|
action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation' })),
|
|
false,
|
|
);
|
|
});
|
|
|
|
Deno.test('Action.shouldExecute: onlyThresholds mode', () => {
|
|
const action = new TestAction({
|
|
type: 'shutdown',
|
|
triggerMode: 'onlyThresholds',
|
|
thresholds: { battery: 60, runtime: 20 },
|
|
});
|
|
|
|
// Below thresholds - should execute
|
|
assertEquals(
|
|
action.testShouldExecute(createMockContext({ batteryCapacity: 50, batteryRuntime: 10 })),
|
|
true,
|
|
);
|
|
|
|
// Above thresholds - should not execute
|
|
assertEquals(
|
|
action.testShouldExecute(createMockContext({ batteryCapacity: 100, batteryRuntime: 60 })),
|
|
false,
|
|
);
|
|
});
|
|
|
|
Deno.test('Action.shouldExecute: onlyThresholds mode without thresholds returns false', () => {
|
|
const action = new TestAction({
|
|
type: 'shutdown',
|
|
triggerMode: 'onlyThresholds',
|
|
// No thresholds configured
|
|
});
|
|
|
|
assertEquals(action.testShouldExecute(createMockContext()), false);
|
|
});
|
|
|
|
Deno.test('Action.shouldExecute: powerChangesAndThresholds mode (default)', () => {
|
|
const action = new TestAction({
|
|
type: 'shutdown',
|
|
thresholds: { battery: 60, runtime: 20 },
|
|
// No triggerMode = defaults to powerChangesAndThresholds
|
|
});
|
|
|
|
// Power change - should execute
|
|
assertEquals(
|
|
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
|
|
true,
|
|
);
|
|
|
|
// Threshold violation - should execute
|
|
assertEquals(
|
|
action.testShouldExecute(createMockContext({
|
|
triggerReason: 'thresholdViolation',
|
|
batteryCapacity: 50,
|
|
})),
|
|
true,
|
|
);
|
|
|
|
// No power change and above thresholds - should not execute
|
|
assertEquals(
|
|
action.testShouldExecute(createMockContext({
|
|
triggerReason: 'thresholdViolation',
|
|
batteryCapacity: 100,
|
|
batteryRuntime: 60,
|
|
})),
|
|
false,
|
|
);
|
|
});
|
|
|
|
Deno.test('Action.shouldExecute: anyChange mode always returns true', () => {
|
|
const action = new TestAction({
|
|
type: 'shutdown',
|
|
triggerMode: 'anyChange',
|
|
});
|
|
|
|
assertEquals(
|
|
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
|
|
true,
|
|
);
|
|
assertEquals(
|
|
action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation' })),
|
|
true,
|
|
);
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// NupstSnmp Class Tests (Unit tests - no real UPS needed)
|
|
// -----------------------------------------------------------------------------
|
|
|
|
Deno.test('NupstSnmp: can be instantiated', () => {
|
|
const snmp = new NupstSnmp(false);
|
|
assertExists(snmp);
|
|
});
|
|
|
|
Deno.test('NupstSnmp: debug mode can be enabled', () => {
|
|
const snmpDebug = new NupstSnmp(true);
|
|
const snmpNormal = new NupstSnmp(false);
|
|
|
|
assertExists(snmpDebug);
|
|
assertExists(snmpNormal);
|
|
});
|
|
|
|
// =============================================================================
|
|
// INTEGRATION TESTS - Require real UPS (loaded from .nogit/env.json)
|
|
// =============================================================================
|
|
|
|
// Helper function to run UPS test with config
|
|
async function testUpsConnection(
|
|
snmp: NupstSnmp,
|
|
config: Record<string, unknown>,
|
|
description: string,
|
|
): Promise<void> {
|
|
console.log(`Testing ${description}...`);
|
|
|
|
const snmpConfig = config.snmp as ISnmpConfig;
|
|
console.log('SNMP Config:');
|
|
console.log(` Host: ${snmpConfig.host}:${snmpConfig.port}`);
|
|
console.log(` Version: SNMPv${snmpConfig.version}`);
|
|
console.log(` UPS Model: ${snmpConfig.upsModel}`);
|
|
|
|
// Use a reasonable timeout for testing
|
|
const testSnmpConfig = {
|
|
...snmpConfig,
|
|
timeout: Math.min(snmpConfig.timeout, SNMP.MAX_TEST_TIMEOUT_MS),
|
|
};
|
|
|
|
const status = await snmp.getUpsStatus(testSnmpConfig);
|
|
|
|
console.log('UPS Status:');
|
|
console.log(` Power Status: ${status.powerStatus}`);
|
|
console.log(` Battery Capacity: ${status.batteryCapacity}%`);
|
|
console.log(` Runtime Remaining: ${status.batteryRuntime} minutes`);
|
|
|
|
// Validate response structure
|
|
assertExists(status, 'Status should exist');
|
|
assert(
|
|
['online', 'onBattery', 'unknown'].includes(status.powerStatus),
|
|
`Power status should be valid: ${status.powerStatus}`,
|
|
);
|
|
assertEquals(typeof status.batteryCapacity, 'number', 'Battery capacity should be a number');
|
|
assertEquals(typeof status.batteryRuntime, 'number', 'Battery runtime should be a number');
|
|
|
|
// Validate ranges
|
|
assert(
|
|
status.batteryCapacity >= 0 && status.batteryCapacity <= 100,
|
|
`Battery capacity should be 0-100: ${status.batteryCapacity}`,
|
|
);
|
|
assert(
|
|
status.batteryRuntime >= 0,
|
|
`Battery runtime should be non-negative: ${status.batteryRuntime}`,
|
|
);
|
|
}
|
|
|
|
// Create SNMP instance for integration tests
|
|
const snmp = new NupstSnmp(true);
|
|
|
|
// Load test configurations
|
|
const testConfigV1 = await testQenv.getEnvVarOnDemandAsObject('testConfigV1');
|
|
const testConfigV3 = await testQenv.getEnvVarOnDemandAsObject('testConfigV3');
|
|
|
|
Deno.test('Integration: Real UPS test v1', async () => {
|
|
await testUpsConnection(snmp, testConfigV1, 'SNMPv1 connection');
|
|
});
|
|
|
|
Deno.test('Integration: Real UPS test v3', async () => {
|
|
await testUpsConnection(snmp, testConfigV3, 'SNMPv3 connection');
|
|
});
|