mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Add unit tests for 12 untested and weakly-tested frontend modules
Nightly GLM grunt wave: new Vitest coverage for pure logic that had no test file or only shallow export coverage. 436 test cases, all green; tsc and eslint clean. Tests characterize current behavior only; no source was modified. Untested modules now covered: - api/responseUtils (19 coercion/guard fns) - utils/workloads (8 predicates/builders) - features/storageBackups/storageSearchQuery, storageMetricsIdentity - utils/platformSupportManifest (12 manifest lookups) - stores/sessionCapabilities, content/help registry Weakly-tested modules strengthened (new *.coverage.test.ts): - utils/format (5 formatters), features/alerts/helpers (apprise round-trip) - utils/cloudPlans (parseCloudTier), utils/alerts, utils/agentResources
This commit is contained in:
parent
fb75ac6a05
commit
54f700371e
12 changed files with 3254 additions and 0 deletions
539
frontend-modern/src/api/__tests__/responseUtils.coverage.test.ts
Normal file
539
frontend-modern/src/api/__tests__/responseUtils.coverage.test.ts
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
apiErrorDetails,
|
||||
apiErrorStatus,
|
||||
apiResponseStatus,
|
||||
arrayOrEmpty,
|
||||
arrayOrUndefined,
|
||||
coerceTimestampMillis,
|
||||
finiteNumberOrUndefined,
|
||||
isAPIErrorStatus,
|
||||
isAPIResponseStatus,
|
||||
normalizeStructuredAPIError,
|
||||
objectArrayFieldOrEmpty,
|
||||
optionalTrimmedString,
|
||||
parseJSONTextSafe,
|
||||
promoteLegacyAlertIdentifier,
|
||||
strictBoolean,
|
||||
strictString,
|
||||
stringArray,
|
||||
stringRecordOrUndefined,
|
||||
trimmedString,
|
||||
} from '@/api/responseUtils';
|
||||
|
||||
describe('responseUtils pure coercion and guards', () => {
|
||||
describe('apiErrorStatus', () => {
|
||||
it('returns the integer status within the 100-599 HTTP range inclusive', () => {
|
||||
expect(apiErrorStatus({ status: 404 })).toBe(404);
|
||||
expect(apiErrorStatus({ status: 100 })).toBe(100);
|
||||
expect(apiErrorStatus({ status: 599 })).toBe(599);
|
||||
});
|
||||
|
||||
it('returns null below the 100 floor and above the 599 ceiling', () => {
|
||||
expect(apiErrorStatus({ status: 99 })).toBeNull();
|
||||
expect(apiErrorStatus({ status: 600 })).toBeNull();
|
||||
expect(apiErrorStatus({ status: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects non-integer and numeric-string statuses', () => {
|
||||
expect(apiErrorStatus({ status: 404.5 })).toBeNull();
|
||||
expect(apiErrorStatus({ status: '404' })).toBeNull();
|
||||
expect(apiErrorStatus({ status: NaN })).toBeNull();
|
||||
expect(apiErrorStatus({ status: Infinity })).toBeNull();
|
||||
expect(apiErrorStatus({ status: -Infinity })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when status is missing and for non-object inputs', () => {
|
||||
expect(apiErrorStatus({})).toBeNull();
|
||||
expect(apiErrorStatus({ status: null })).toBeNull();
|
||||
expect(apiErrorStatus({ status: undefined })).toBeNull();
|
||||
expect(apiErrorStatus(null)).toBeNull();
|
||||
expect(apiErrorStatus(undefined)).toBeNull();
|
||||
expect(apiErrorStatus('error')).toBeNull();
|
||||
expect(apiErrorStatus(404)).toBeNull();
|
||||
expect(apiErrorStatus(true)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiErrorDetails', () => {
|
||||
it('returns the full details record when all entries are non-empty strings', () => {
|
||||
expect(apiErrorDetails({ details: { a: '1', b: '2' } })).toEqual({ a: '1', b: '2' });
|
||||
});
|
||||
|
||||
it('trims both keys and values and keeps the normalized pair', () => {
|
||||
expect(apiErrorDetails({ details: { ' key ': ' val ' } })).toEqual({ key: 'val' });
|
||||
});
|
||||
|
||||
it('drops entries whose key or value is empty/whitespace or non-string', () => {
|
||||
expect(apiErrorDetails({ details: { a: '1', b: ' ', c: 2, d: null } })).toEqual({ a: '1' });
|
||||
expect(apiErrorDetails({ details: { ' ': 'val' } })).toBeNull();
|
||||
expect(apiErrorDetails({ details: { a: 1, b: 2 } })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty details objects, missing details, and non-object inputs', () => {
|
||||
expect(apiErrorDetails({ details: {} })).toBeNull();
|
||||
expect(apiErrorDetails({})).toBeNull();
|
||||
expect(apiErrorDetails({ details: null })).toBeNull();
|
||||
expect(apiErrorDetails({ details: 'string' })).toBeNull();
|
||||
expect(apiErrorDetails({ details: [] })).toBeNull();
|
||||
expect(apiErrorDetails(null)).toBeNull();
|
||||
expect(apiErrorDetails('error')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiResponseStatus', () => {
|
||||
it('returns the integer status within the 100-599 range inclusive', () => {
|
||||
expect(apiResponseStatus({ status: 200 })).toBe(200);
|
||||
expect(apiResponseStatus({ status: 100 })).toBe(100);
|
||||
expect(apiResponseStatus({ status: 599 })).toBe(599);
|
||||
});
|
||||
|
||||
it('returns null outside the 100-599 range', () => {
|
||||
expect(apiResponseStatus({ status: 99 })).toBeNull();
|
||||
expect(apiResponseStatus({ status: 600 })).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects non-integer and non-number statuses', () => {
|
||||
expect(apiResponseStatus({ status: 200.5 })).toBeNull();
|
||||
expect(apiResponseStatus({ status: '200' })).toBeNull();
|
||||
expect(apiResponseStatus({ status: NaN })).toBeNull();
|
||||
expect(apiResponseStatus({ status: true })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for missing status and non-object inputs', () => {
|
||||
expect(apiResponseStatus({})).toBeNull();
|
||||
expect(apiResponseStatus({ status: null })).toBeNull();
|
||||
expect(apiResponseStatus(null)).toBeNull();
|
||||
expect(apiResponseStatus(undefined)).toBeNull();
|
||||
expect(apiResponseStatus(200 as unknown as Parameters<typeof apiResponseStatus>[0])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAPIResponseStatus', () => {
|
||||
it('returns true only when the response status matches a valid expected status', () => {
|
||||
expect(isAPIResponseStatus({ status: 200 }, 200)).toBe(true);
|
||||
expect(isAPIResponseStatus({ status: 100 }, 100)).toBe(true);
|
||||
expect(isAPIResponseStatus({ status: 599 }, 599)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false on status mismatch', () => {
|
||||
expect(isAPIResponseStatus({ status: 200 }, 404)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the response status is outside the valid range', () => {
|
||||
expect(isAPIResponseStatus({ status: 99 }, 99)).toBe(false);
|
||||
expect(isAPIResponseStatus({ status: 600 }, 600)).toBe(false);
|
||||
expect(isAPIResponseStatus({ status: '200' }, 200)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for null/undefined responses', () => {
|
||||
expect(isAPIResponseStatus(null, 200)).toBe(false);
|
||||
expect(isAPIResponseStatus(undefined, 200)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAPIErrorStatus', () => {
|
||||
it('returns true when the error status matches a valid expected status', () => {
|
||||
expect(isAPIErrorStatus({ status: 404 }, 404)).toBe(true);
|
||||
expect(isAPIErrorStatus({ status: 503 }, 503)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false on mismatch and on invalid statuses', () => {
|
||||
expect(isAPIErrorStatus({ status: 404 }, 500)).toBe(false);
|
||||
expect(isAPIErrorStatus({ status: '404' }, 404)).toBe(false);
|
||||
expect(isAPIErrorStatus({ status: 99 }, 99)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-object errors', () => {
|
||||
expect(isAPIErrorStatus(null, 404)).toBe(false);
|
||||
expect(isAPIErrorStatus('error', 404)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trimmedString', () => {
|
||||
it('trims surrounding whitespace from string inputs', () => {
|
||||
expect(trimmedString(' hello ')).toBe('hello');
|
||||
expect(trimmedString('hello')).toBe('hello');
|
||||
expect(trimmedString('\t\n x \t\n')).toBe('x');
|
||||
});
|
||||
|
||||
it('returns an empty string for empty or whitespace-only strings', () => {
|
||||
expect(trimmedString('')).toBe('');
|
||||
expect(trimmedString(' ')).toBe('');
|
||||
});
|
||||
|
||||
it('returns an empty string for null and undefined via loose equality', () => {
|
||||
expect(trimmedString(null)).toBe('');
|
||||
expect(trimmedString(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('coerces non-string, non-nullish values via String() then trims', () => {
|
||||
expect(trimmedString(0)).toBe('0');
|
||||
expect(trimmedString(42)).toBe('42');
|
||||
expect(trimmedString(-1.5)).toBe('-1.5');
|
||||
expect(trimmedString(true)).toBe('true');
|
||||
expect(trimmedString(false)).toBe('false');
|
||||
expect(trimmedString(NaN)).toBe('NaN');
|
||||
expect(trimmedString([1, 2])).toBe('1,2');
|
||||
expect(trimmedString({ a: 1 })).toBe('[object Object]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('optionalTrimmedString', () => {
|
||||
it('returns the trimmed string when it is non-empty', () => {
|
||||
expect(optionalTrimmedString(' hello ')).toBe('hello');
|
||||
expect(optionalTrimmedString('hello')).toBe('hello');
|
||||
});
|
||||
|
||||
it('returns undefined for empty, whitespace-only, nullish, and zero-numeric inputs that coerce to empty', () => {
|
||||
expect(optionalTrimmedString('')).toBeUndefined();
|
||||
expect(optionalTrimmedString(' ')).toBeUndefined();
|
||||
expect(optionalTrimmedString(null)).toBeUndefined();
|
||||
expect(optionalTrimmedString(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the coerced string for truthy non-string values', () => {
|
||||
expect(optionalTrimmedString(0)).toBe('0');
|
||||
expect(optionalTrimmedString(42)).toBe('42');
|
||||
expect(optionalTrimmedString(false)).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('strictString', () => {
|
||||
it('returns the original string when value is a string (including empty string)', () => {
|
||||
expect(strictString('hello')).toBe('hello');
|
||||
expect(strictString('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns the default empty fallback for non-strings', () => {
|
||||
expect(strictString(42)).toBe('');
|
||||
expect(strictString(null)).toBe('');
|
||||
expect(strictString(undefined)).toBe('');
|
||||
expect(strictString(true)).toBe('');
|
||||
});
|
||||
|
||||
it('returns the provided fallback for non-strings', () => {
|
||||
expect(strictString(42, 'fallback')).toBe('fallback');
|
||||
expect(strictString(null, 'fallback')).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('strictBoolean', () => {
|
||||
it('returns the original boolean for true and false', () => {
|
||||
expect(strictBoolean(true)).toBe(true);
|
||||
expect(strictBoolean(false)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the default false fallback for non-booleans', () => {
|
||||
expect(strictBoolean('true')).toBe(false);
|
||||
expect(strictBoolean(1)).toBe(false);
|
||||
expect(strictBoolean(0)).toBe(false);
|
||||
expect(strictBoolean(null)).toBe(false);
|
||||
expect(strictBoolean(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the provided fallback for non-booleans', () => {
|
||||
expect(strictBoolean('true', true)).toBe(true);
|
||||
expect(strictBoolean(1, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finiteNumberOrUndefined', () => {
|
||||
it('returns finite numbers including zero and negatives', () => {
|
||||
expect(finiteNumberOrUndefined(42)).toBe(42);
|
||||
expect(finiteNumberOrUndefined(0)).toBe(0);
|
||||
expect(finiteNumberOrUndefined(-1.5)).toBe(-1.5);
|
||||
});
|
||||
|
||||
it('returns undefined for NaN and infinities', () => {
|
||||
expect(finiteNumberOrUndefined(NaN)).toBeUndefined();
|
||||
expect(finiteNumberOrUndefined(Infinity)).toBeUndefined();
|
||||
expect(finiteNumberOrUndefined(-Infinity)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for non-number inputs', () => {
|
||||
expect(finiteNumberOrUndefined('42')).toBeUndefined();
|
||||
expect(finiteNumberOrUndefined(null)).toBeUndefined();
|
||||
expect(finiteNumberOrUndefined(undefined)).toBeUndefined();
|
||||
expect(finiteNumberOrUndefined(true)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('coerceTimestampMillis', () => {
|
||||
it('returns finite numeric inputs directly', () => {
|
||||
expect(coerceTimestampMillis(1000, 0)).toBe(1000);
|
||||
expect(coerceTimestampMillis(0, 999)).toBe(0);
|
||||
expect(coerceTimestampMillis(-100, 999)).toBe(-100);
|
||||
});
|
||||
|
||||
it('parses ISO 8601 strings into epoch milliseconds', () => {
|
||||
expect(coerceTimestampMillis('2024-01-01T00:00:00Z', 0)).toBe(1704067200000);
|
||||
});
|
||||
|
||||
it('trims whitespace around parseable date strings', () => {
|
||||
expect(coerceTimestampMillis(' 2024-01-01T00:00:00Z ', 0)).toBe(1704067200000);
|
||||
});
|
||||
|
||||
it('returns the fallback for NaN, Infinity, and non-numeric inputs', () => {
|
||||
const fallback = 12345;
|
||||
expect(coerceTimestampMillis(NaN, fallback)).toBe(fallback);
|
||||
expect(coerceTimestampMillis(Infinity, fallback)).toBe(fallback);
|
||||
expect(coerceTimestampMillis('not-a-date', fallback)).toBe(fallback);
|
||||
expect(coerceTimestampMillis('', fallback)).toBe(fallback);
|
||||
expect(coerceTimestampMillis(' ', fallback)).toBe(fallback);
|
||||
expect(coerceTimestampMillis(null, fallback)).toBe(fallback);
|
||||
expect(coerceTimestampMillis(undefined, fallback)).toBe(fallback);
|
||||
expect(coerceTimestampMillis(false, fallback)).toBe(fallback);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringArray', () => {
|
||||
it('returns only the string elements preserving order', () => {
|
||||
expect(stringArray(['a', 1, 'b', true, null, 'c'])).toEqual(['a', 'b', 'c']);
|
||||
expect(stringArray(['a', 'b'])).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('keeps empty-string elements', () => {
|
||||
expect(stringArray(['', 'a', ''])).toEqual(['', 'a', '']);
|
||||
});
|
||||
|
||||
it('returns an empty array for non-arrays', () => {
|
||||
expect(stringArray('abc')).toEqual([]);
|
||||
expect(stringArray(null)).toEqual([]);
|
||||
expect(stringArray(undefined)).toEqual([]);
|
||||
expect(stringArray({ a: 'b' })).toEqual([]);
|
||||
expect(stringArray(42)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringRecordOrUndefined', () => {
|
||||
it('returns the record when at least one value is a string', () => {
|
||||
expect(stringRecordOrUndefined({ a: '1', b: '2' })).toEqual({ a: '1', b: '2' });
|
||||
});
|
||||
|
||||
it('drops non-string values, keeping string values (including empty strings)', () => {
|
||||
expect(stringRecordOrUndefined({ a: '1', b: 2, c: null })).toEqual({ a: '1' });
|
||||
expect(stringRecordOrUndefined({ a: '', b: '2' })).toEqual({ a: '', b: '2' });
|
||||
});
|
||||
|
||||
it('returns undefined when no string values remain', () => {
|
||||
expect(stringRecordOrUndefined({ a: 1, b: 2 })).toBeUndefined();
|
||||
expect(stringRecordOrUndefined({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for non-object inputs', () => {
|
||||
expect(stringRecordOrUndefined(null)).toBeUndefined();
|
||||
expect(stringRecordOrUndefined(undefined)).toBeUndefined();
|
||||
expect(stringRecordOrUndefined('str')).toBeUndefined();
|
||||
expect(stringRecordOrUndefined(42)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('coerces arrays into their index-keyed string entries (current behavior)', () => {
|
||||
expect(stringRecordOrUndefined(['a', 'b'])).toEqual({ 0: 'a', 1: 'b' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeStructuredAPIError', () => {
|
||||
it('returns canonical code and message for a well-formed payload', () => {
|
||||
expect(normalizeStructuredAPIError({ code: 'not_found', message: 'gone' }, 404)).toEqual({
|
||||
code: 'not_found',
|
||||
message: 'gone',
|
||||
details: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('trims whitespace from code and message', () => {
|
||||
expect(normalizeStructuredAPIError({ code: ' x ', message: ' y ' }, 500)).toEqual({
|
||||
code: 'x',
|
||||
message: 'y',
|
||||
details: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the provided fallbackCode and fallbackStatus-derived message', () => {
|
||||
expect(normalizeStructuredAPIError({}, 503, 'custom_code')).toEqual({
|
||||
code: 'custom_code',
|
||||
message: 'Request failed (503)',
|
||||
details: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults fallbackCode to request_failed when omitted', () => {
|
||||
expect(normalizeStructuredAPIError(null, 500)).toEqual({
|
||||
code: 'request_failed',
|
||||
message: 'Request failed (500)',
|
||||
details: undefined,
|
||||
});
|
||||
expect(normalizeStructuredAPIError('str', 500)).toEqual({
|
||||
code: 'request_failed',
|
||||
message: 'Request failed (500)',
|
||||
details: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back when code and message are empty or whitespace', () => {
|
||||
expect(normalizeStructuredAPIError({ code: '', message: ' ' }, 500, 'c')).toEqual({
|
||||
code: 'c',
|
||||
message: 'Request failed (500)',
|
||||
details: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes a string details record when present and omits nothing otherwise', () => {
|
||||
const withDetails = normalizeStructuredAPIError(
|
||||
{ code: 'x', message: 'y', details: { field: 'bad' } },
|
||||
400,
|
||||
);
|
||||
expect(withDetails.details).toEqual({ field: 'bad' });
|
||||
|
||||
const withoutDetails = normalizeStructuredAPIError({ code: 'x', message: 'y' }, 400);
|
||||
expect(withoutDetails.details).toBeUndefined();
|
||||
expect('details' in withoutDetails).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('promoteLegacyAlertIdentifier', () => {
|
||||
it('promotes snake_case alert_identifier to camelCase alertIdentifier', () => {
|
||||
const result = promoteLegacyAlertIdentifier({ alert_identifier: 'id-1', foo: 'bar' });
|
||||
expect(result).toEqual({ alertIdentifier: 'id-1', foo: 'bar' });
|
||||
expect('alert_identifier' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps existing camelCase alertIdentifier and drops the snake_case key', () => {
|
||||
const result = promoteLegacyAlertIdentifier({
|
||||
alertIdentifier: 'camel',
|
||||
alert_identifier: 'snake',
|
||||
foo: 'bar',
|
||||
});
|
||||
expect(result).toEqual({ alertIdentifier: 'camel', foo: 'bar' });
|
||||
expect('alert_identifier' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to snake_case when camelCase is whitespace', () => {
|
||||
const result = promoteLegacyAlertIdentifier({
|
||||
alertIdentifier: ' ',
|
||||
alert_identifier: 'real-id',
|
||||
});
|
||||
expect(result).toEqual({ alertIdentifier: 'real-id' });
|
||||
});
|
||||
|
||||
it('preserves camelCase and removes snake_case when snake is whitespace', () => {
|
||||
const result = promoteLegacyAlertIdentifier({
|
||||
alertIdentifier: 'real-id',
|
||||
alert_identifier: ' ',
|
||||
});
|
||||
expect(result).toEqual({ alertIdentifier: 'real-id' });
|
||||
});
|
||||
|
||||
it('returns the record unchanged when no identifier fields are present', () => {
|
||||
const result = promoteLegacyAlertIdentifier({ foo: 'bar', count: 3 });
|
||||
expect(result).toEqual({ foo: 'bar', count: 3 });
|
||||
});
|
||||
|
||||
it('removes snake_case key and leaves the existing whitespace camelCase as-is when both are blank', () => {
|
||||
const result = promoteLegacyAlertIdentifier({
|
||||
alertIdentifier: ' ',
|
||||
alert_identifier: ' ',
|
||||
foo: 'bar',
|
||||
});
|
||||
expect(result).toEqual({ alertIdentifier: ' ', foo: 'bar' });
|
||||
expect('alert_identifier' in result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseJSONTextSafe', () => {
|
||||
it('parses valid JSON objects and arrays', () => {
|
||||
expect(parseJSONTextSafe('{"a":1}')).toEqual({ a: 1 });
|
||||
expect(parseJSONTextSafe('[1,2,3]')).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('parses JSON primitives including null, booleans, and numbers', () => {
|
||||
expect(parseJSONTextSafe('null')).toBeNull();
|
||||
expect(parseJSONTextSafe('true')).toBe(true);
|
||||
expect(parseJSONTextSafe('false')).toBe(false);
|
||||
expect(parseJSONTextSafe('42')).toBe(42);
|
||||
});
|
||||
|
||||
it('parses JSON with surrounding whitespace', () => {
|
||||
expect(parseJSONTextSafe(' {"a":1} ')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('returns null for empty and whitespace-only input', () => {
|
||||
expect(parseJSONTextSafe('')).toBeNull();
|
||||
expect(parseJSONTextSafe(' ')).toBeNull();
|
||||
expect(parseJSONTextSafe('\t\n')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for malformed JSON without throwing', () => {
|
||||
expect(parseJSONTextSafe('not json')).toBeNull();
|
||||
expect(parseJSONTextSafe('{bad}')).toBeNull();
|
||||
expect(parseJSONTextSafe('{')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('arrayOrEmpty', () => {
|
||||
it('returns arrays unchanged including empty arrays', () => {
|
||||
expect(arrayOrEmpty([1, 2, 3])).toEqual([1, 2, 3]);
|
||||
expect(arrayOrEmpty([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns a fresh empty array for non-arrays', () => {
|
||||
expect(arrayOrEmpty('str')).toEqual([]);
|
||||
expect(arrayOrEmpty(null)).toEqual([]);
|
||||
expect(arrayOrEmpty(undefined)).toEqual([]);
|
||||
expect(arrayOrEmpty({})).toEqual([]);
|
||||
expect(arrayOrEmpty(0)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns a distinct empty array reference each time it coerces', () => {
|
||||
const a = arrayOrEmpty(null);
|
||||
const b = arrayOrEmpty(null);
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('arrayOrUndefined', () => {
|
||||
it('returns arrays unchanged including empty arrays', () => {
|
||||
expect(arrayOrUndefined([1, 2])).toEqual([1, 2]);
|
||||
expect(arrayOrUndefined([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns undefined for non-arrays', () => {
|
||||
expect(arrayOrUndefined('str')).toBeUndefined();
|
||||
expect(arrayOrUndefined(null)).toBeUndefined();
|
||||
expect(arrayOrUndefined(undefined)).toBeUndefined();
|
||||
expect(arrayOrUndefined({})).toBeUndefined();
|
||||
expect(arrayOrUndefined(0)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectArrayFieldOrEmpty', () => {
|
||||
it('returns the array at the given field when present', () => {
|
||||
expect(objectArrayFieldOrEmpty({ items: [1, 2] }, 'items')).toEqual([1, 2]);
|
||||
expect(objectArrayFieldOrEmpty({ items: [] }, 'items')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the field holds a non-array value', () => {
|
||||
expect(objectArrayFieldOrEmpty({ items: 'str' }, 'items')).toEqual([]);
|
||||
expect(objectArrayFieldOrEmpty({ items: {} }, 'items')).toEqual([]);
|
||||
expect(objectArrayFieldOrEmpty({ items: null }, 'items')).toEqual([]);
|
||||
expect(objectArrayFieldOrEmpty({ items: undefined }, 'items')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the field is missing', () => {
|
||||
expect(objectArrayFieldOrEmpty({ other: 1 }, 'items')).toEqual([]);
|
||||
expect(objectArrayFieldOrEmpty({}, 'items')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array for non-object roots', () => {
|
||||
expect(objectArrayFieldOrEmpty(null, 'items')).toEqual([]);
|
||||
expect(objectArrayFieldOrEmpty(undefined, 'items')).toEqual([]);
|
||||
expect(objectArrayFieldOrEmpty('str', 'items')).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not descend into nested objects to find arrays', () => {
|
||||
expect(objectArrayFieldOrEmpty({ nested: { inner: [1, 2] } }, 'nested')).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
104
frontend-modern/src/content/help/__tests__/index.test.ts
Normal file
104
frontend-modern/src/content/help/__tests__/index.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { HelpContent } from '../types';
|
||||
import { alertsHelpContent } from '../alerts';
|
||||
import { aiHelpContent } from '../ai';
|
||||
import { updatesHelpContent } from '../updates';
|
||||
import { getHelpContent } from '../index';
|
||||
|
||||
const allContent = [...alertsHelpContent, ...aiHelpContent, ...updatesHelpContent];
|
||||
const registeredIds = allContent.map((item) => item.id);
|
||||
|
||||
describe('getHelpContent', () => {
|
||||
describe('registered id lookup', () => {
|
||||
it.each(registeredIds)('returns the source entry by reference for "%s"', (id) => {
|
||||
const expected = allContent.find((item) => item.id === id);
|
||||
const resolved = getHelpContent(id);
|
||||
|
||||
expect(resolved).toBe(expected);
|
||||
expect(resolved?.id).toBe(id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown id', () => {
|
||||
it('returns undefined for an id that is not registered', () => {
|
||||
expect(getHelpContent('does.not.exist')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for an empty string', () => {
|
||||
expect(getHelpContent('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is case-sensitive (a differently-cased registered id resolves to undefined)', () => {
|
||||
expect(getHelpContent('ALERTS.THRESHOLDS.DELAY')).toBeUndefined();
|
||||
expect(getHelpContent('ai.ollama.baseurl')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not resolve ids with surrounding whitespace', () => {
|
||||
expect(getHelpContent(' alerts.thresholds.delay')).toBeUndefined();
|
||||
expect(getHelpContent('alerts.thresholds.delay ')).toBeUndefined();
|
||||
expect(getHelpContent('alerts.thresholds.delay\n')).toBeUndefined();
|
||||
expect(getHelpContent('\tupdates.pulse.channel')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not resolve a registered id with an extra trailing segment', () => {
|
||||
expect(getHelpContent('alerts.thresholds.delay.')).toBeUndefined();
|
||||
expect(getHelpContent('updates.pulse.channel.extra')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('entry shape', () => {
|
||||
it('every registered id resolves to a well-formed HelpContent entry', () => {
|
||||
for (const id of registeredIds) {
|
||||
const entry = getHelpContent(id) as HelpContent;
|
||||
|
||||
// Required fields are present and non-empty strings.
|
||||
expect(entry).toBeDefined();
|
||||
expect(typeof entry.id).toBe('string');
|
||||
expect(entry.id.length).toBeGreaterThan(0);
|
||||
expect(typeof entry.title).toBe('string');
|
||||
expect(entry.title.length).toBeGreaterThan(0);
|
||||
expect(typeof entry.description).toBe('string');
|
||||
expect(entry.description.length).toBeGreaterThan(0);
|
||||
|
||||
// Optional fields, when present, must have the declared type.
|
||||
if (entry.examples !== undefined) {
|
||||
expect(Array.isArray(entry.examples)).toBe(true);
|
||||
expect(entry.examples.length).toBeGreaterThan(0);
|
||||
for (const example of entry.examples) {
|
||||
expect(typeof example).toBe('string');
|
||||
expect(example.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
if (entry.related !== undefined) {
|
||||
expect(Array.isArray(entry.related)).toBe(true);
|
||||
expect(entry.related.length).toBeGreaterThan(0);
|
||||
for (const relatedId of entry.related) {
|
||||
expect(typeof relatedId).toBe('string');
|
||||
expect(relatedId.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
if (entry.docUrl !== undefined) {
|
||||
expect(typeof entry.docUrl).toBe('string');
|
||||
expect(entry.docUrl.length).toBeGreaterThan(0);
|
||||
}
|
||||
if (entry.addedInVersion !== undefined) {
|
||||
expect(typeof entry.addedInVersion).toBe('string');
|
||||
expect(entry.addedInVersion.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('every related id cross-reference resolves to a registered entry', () => {
|
||||
const withRelated = allContent.filter((item) => (item.related?.length ?? 0) > 0);
|
||||
expect(withRelated.length).toBeGreaterThan(0);
|
||||
|
||||
for (const item of withRelated) {
|
||||
for (const relatedId of item.related ?? []) {
|
||||
const resolved = getHelpContent(relatedId);
|
||||
expect(resolved).toBeDefined();
|
||||
expect(resolved?.id).toBe(relatedId);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
readStringValue,
|
||||
readBooleanValue,
|
||||
readNumberValue,
|
||||
readStringArrayValue,
|
||||
parseAppriseTargets,
|
||||
formatAppriseTargets,
|
||||
guessNumericId,
|
||||
platformData,
|
||||
} from '@/features/alerts/helpers';
|
||||
|
||||
describe('alerts helpers — coercion coverage', () => {
|
||||
describe('readStringValue', () => {
|
||||
it('returns the string when given a string', () => {
|
||||
expect(readStringValue('apprise')).toBe('apprise');
|
||||
});
|
||||
|
||||
it('treats the empty string as a valid string', () => {
|
||||
expect(readStringValue('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns the default fallback for non-string inputs', () => {
|
||||
expect(readStringValue(undefined)).toBe('');
|
||||
expect(readStringValue(null)).toBe('');
|
||||
expect(readStringValue(42)).toBe('');
|
||||
expect(readStringValue(true)).toBe('');
|
||||
expect(readStringValue({ server: 'x' })).toBe('');
|
||||
expect(readStringValue(['a'])).toBe('');
|
||||
});
|
||||
|
||||
it('honors a caller-supplied fallback', () => {
|
||||
expect(readStringValue(undefined, 'fallback')).toBe('fallback');
|
||||
expect(readStringValue(0, 'fallback')).toBe('fallback');
|
||||
expect(readStringValue('real', 'fallback')).toBe('real');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readBooleanValue', () => {
|
||||
it('returns the boolean when given a boolean', () => {
|
||||
expect(readBooleanValue(true)).toBe(true);
|
||||
expect(readBooleanValue(false)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the default fallback for non-boolean inputs', () => {
|
||||
expect(readBooleanValue(undefined)).toBe(false);
|
||||
expect(readBooleanValue(null)).toBe(false);
|
||||
expect(readBooleanValue('true')).toBe(false);
|
||||
expect(readBooleanValue(0)).toBe(false);
|
||||
expect(readBooleanValue(1)).toBe(false);
|
||||
});
|
||||
|
||||
it('honors a caller-supplied fallback and still preserves an explicit false', () => {
|
||||
expect(readBooleanValue(undefined, true)).toBe(true);
|
||||
expect(readBooleanValue('x', true)).toBe(true);
|
||||
// An explicit false must not be overwritten by the fallback.
|
||||
expect(readBooleanValue(false, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readNumberValue', () => {
|
||||
it('returns the number when given a finite number', () => {
|
||||
expect(readNumberValue(587, 0)).toBe(587);
|
||||
expect(readNumberValue(0, 99)).toBe(0);
|
||||
expect(readNumberValue(-5, 99)).toBe(-5);
|
||||
});
|
||||
|
||||
it('returns the fallback for non-finite numbers', () => {
|
||||
expect(readNumberValue(Number.NaN, 25)).toBe(25);
|
||||
expect(readNumberValue(Number.POSITIVE_INFINITY, 25)).toBe(25);
|
||||
expect(readNumberValue(Number.NEGATIVE_INFINITY, 25)).toBe(25);
|
||||
});
|
||||
|
||||
it('returns the fallback for non-number inputs', () => {
|
||||
expect(readNumberValue(undefined, 25)).toBe(25);
|
||||
expect(readNumberValue(null, 25)).toBe(25);
|
||||
expect(readNumberValue('587', 25)).toBe(25);
|
||||
expect(readNumberValue(true, 25)).toBe(25);
|
||||
expect(readNumberValue({}, 25)).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readStringArrayValue', () => {
|
||||
it('returns the array when every entry is a string', () => {
|
||||
expect(readStringArrayValue(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('filters out non-string entries while preserving order', () => {
|
||||
expect(readStringArrayValue(['a', 1, null, 'b', undefined, true, 'c'])).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array for non-array inputs', () => {
|
||||
expect(readStringArrayValue(undefined)).toEqual([]);
|
||||
expect(readStringArrayValue(null)).toEqual([]);
|
||||
expect(readStringArrayValue('not-an-array')).toEqual([]);
|
||||
expect(readStringArrayValue({ length: 1 })).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array for an empty array input', () => {
|
||||
expect(readStringArrayValue([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAppriseTargets', () => {
|
||||
it('splits newline-separated targets', () => {
|
||||
expect(parseAppriseTargets('mailto:a@x\nmailto:b@x')).toEqual([
|
||||
'mailto:a@x',
|
||||
'mailto:b@x',
|
||||
]);
|
||||
});
|
||||
|
||||
it('splits comma-separated targets', () => {
|
||||
expect(parseAppriseTargets('mailto:a@x,mailto:b@x')).toEqual([
|
||||
'mailto:a@x',
|
||||
'mailto:b@x',
|
||||
]);
|
||||
});
|
||||
|
||||
it('splits a mix of newlines and commas', () => {
|
||||
expect(parseAppriseTargets('mailto:a@x\nmailto:b@x,mailto:c@x')).toEqual([
|
||||
'mailto:a@x',
|
||||
'mailto:b@x',
|
||||
'mailto:c@x',
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes CRLF line endings', () => {
|
||||
expect(parseAppriseTargets('mailto:a@x\r\nmailto:b@x')).toEqual([
|
||||
'mailto:a@x',
|
||||
'mailto:b@x',
|
||||
]);
|
||||
});
|
||||
|
||||
it('trims whitespace around each target', () => {
|
||||
expect(parseAppriseTargets(' mailto:a@x , \tmailto:b@x \n')).toEqual([
|
||||
'mailto:a@x',
|
||||
'mailto:b@x',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops empty entries produced by trailing separators and blanks', () => {
|
||||
expect(parseAppriseTargets('mailto:a@x,\n, ,\nmailto:b@x\n')).toEqual([
|
||||
'mailto:a@x',
|
||||
'mailto:b@x',
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates targets keeping first occurrence order', () => {
|
||||
expect(parseAppriseTargets('mailto:a@x\nmailto:b@x\nmailto:a@x')).toEqual([
|
||||
'mailto:a@x',
|
||||
'mailto:b@x',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array for an empty string', () => {
|
||||
expect(parseAppriseTargets('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAppriseTargets', () => {
|
||||
it('joins targets with newlines', () => {
|
||||
expect(formatAppriseTargets(['mailto:a@x', 'mailto:b@x'])).toBe(
|
||||
'mailto:a@x\nmailto:b@x',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a single target without a trailing newline', () => {
|
||||
expect(formatAppriseTargets(['mailto:a@x'])).toBe('mailto:a@x');
|
||||
});
|
||||
|
||||
it('returns an empty string for an empty array', () => {
|
||||
expect(formatAppriseTargets([])).toBe('');
|
||||
});
|
||||
|
||||
it('returns an empty string for nullish inputs', () => {
|
||||
expect(formatAppriseTargets(undefined)).toBe('');
|
||||
expect(formatAppriseTargets(null)).toBe('');
|
||||
});
|
||||
|
||||
it('preserves caller-provided order', () => {
|
||||
expect(formatAppriseTargets(['c', 'a', 'b'])).toBe('c\na\nb');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAppriseTargets / formatAppriseTargets round-trip', () => {
|
||||
it('parse(format(targets)) is identity for a clean list', () => {
|
||||
const targets = ['mailto:a@x', 'mailto:b@x', 'mailto:c@x'];
|
||||
expect(parseAppriseTargets(formatAppriseTargets(targets))).toEqual(targets);
|
||||
});
|
||||
|
||||
it('format(parse(text)) normalizes messy input to newline form', () => {
|
||||
const messy = ' mailto:a@x , mailto:b@x\n\r\nmailto:a@x\n ';
|
||||
expect(formatAppriseTargets(parseAppriseTargets(messy))).toBe(
|
||||
'mailto:a@x\nmailto:b@x',
|
||||
);
|
||||
});
|
||||
|
||||
it('an empty list round-trips through the empty string', () => {
|
||||
expect(formatAppriseTargets(parseAppriseTargets(''))).toBe('');
|
||||
expect(parseAppriseTargets(formatAppriseTargets([]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guessNumericId', () => {
|
||||
it('extracts trailing digits', () => {
|
||||
expect(guessNumericId('node-100')).toBe(100);
|
||||
expect(guessNumericId('vm/42')).toBe(42);
|
||||
expect(guessNumericId('100')).toBe(100);
|
||||
});
|
||||
|
||||
it('tolerates trailing whitespace after the digits', () => {
|
||||
expect(guessNumericId('guest 7 ')).toBe(7);
|
||||
expect(guessNumericId('guest 7\t')).toBe(7);
|
||||
});
|
||||
|
||||
it('returns 0 when no digits are present', () => {
|
||||
expect(guessNumericId('no-id-here')).toBe(0);
|
||||
expect(guessNumericId('')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when digits appear only before a non-digit suffix', () => {
|
||||
expect(guessNumericId('100abc')).toBe(0);
|
||||
expect(guessNumericId('node-5-extra')).toBe(0);
|
||||
});
|
||||
|
||||
it('parses leading zeros with radix 10', () => {
|
||||
expect(guessNumericId('ct-007')).toBe(7);
|
||||
});
|
||||
|
||||
it('parses large ids', () => {
|
||||
expect(guessNumericId('storage-100200300')).toBe(100200300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('platformData', () => {
|
||||
const makeResource = (overrides: Partial<Resource>): Resource =>
|
||||
({ id: 'r1', name: 'r1', type: 'vm', ...overrides }) as Resource;
|
||||
|
||||
it('returns undefined when platformData is absent', () => {
|
||||
expect(platformData(makeResource({}))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when platformData is null', () => {
|
||||
expect(platformData(makeResource({ platformData: null as unknown as undefined }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the unwrapped payload when present', () => {
|
||||
const payload = { proxmox: { vmid: 100, node: 'n1' } };
|
||||
const result = platformData(makeResource({ platformData: payload }));
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
it('returns a (truthy) empty object when platformData is an empty object', () => {
|
||||
const result = platformData(makeResource({ platformData: {} }));
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import type { StorageRecord } from '@/features/storageBackups/models';
|
||||
import {
|
||||
resolvePhysicalDiskMetricResourceId,
|
||||
resolveStorageRecordMetricResourceId,
|
||||
} from '@/features/storageBackups/storageMetricsIdentity';
|
||||
|
||||
const makeRecord = (overrides: Partial<StorageRecord> = {}): StorageRecord => ({
|
||||
id: 'storage-1',
|
||||
name: 'tank',
|
||||
category: 'pool',
|
||||
health: 'healthy',
|
||||
location: { label: 'truenas01', scope: 'host' },
|
||||
capacity: { totalBytes: 100, usedBytes: 40, freeBytes: 60, usagePercent: 40 },
|
||||
capabilities: ['capacity'],
|
||||
source: {
|
||||
platform: 'truenas',
|
||||
family: 'onprem',
|
||||
origin: 'resource',
|
||||
adapterId: 'resource-storage',
|
||||
},
|
||||
observedAt: Date.now(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeResource = (overrides: Partial<Resource> = {}): Resource =>
|
||||
({
|
||||
id: 'disk-1',
|
||||
type: 'storage',
|
||||
name: 'sda',
|
||||
platformType: 'truenas',
|
||||
sourceType: 'api',
|
||||
...overrides,
|
||||
}) as Resource;
|
||||
|
||||
describe('resolveStorageRecordMetricResourceId', () => {
|
||||
it('prefers metricsTarget.resourceId over refs and id', () => {
|
||||
expect(
|
||||
resolveStorageRecordMetricResourceId(
|
||||
makeRecord({
|
||||
metricsTarget: { resourceType: 'storage', resourceId: 'metrics-id' },
|
||||
refs: { resourceId: 'refs-id' },
|
||||
id: 'storage-1',
|
||||
}),
|
||||
),
|
||||
).toBe('metrics-id');
|
||||
});
|
||||
|
||||
it('falls back to refs.resourceId when metricsTarget is absent', () => {
|
||||
expect(
|
||||
resolveStorageRecordMetricResourceId(
|
||||
makeRecord({ refs: { resourceId: 'refs-id' }, id: 'storage-1' }),
|
||||
),
|
||||
).toBe('refs-id');
|
||||
});
|
||||
|
||||
it('falls back to refs.resourceId when metricsTarget.resourceId is empty or whitespace', () => {
|
||||
expect(
|
||||
resolveStorageRecordMetricResourceId(
|
||||
makeRecord({
|
||||
metricsTarget: { resourceType: 'storage', resourceId: ' ' },
|
||||
refs: { resourceId: 'refs-id' },
|
||||
}),
|
||||
),
|
||||
).toBe('refs-id');
|
||||
expect(
|
||||
resolveStorageRecordMetricResourceId(
|
||||
makeRecord({
|
||||
metricsTarget: { resourceType: 'storage', resourceId: '' },
|
||||
refs: { resourceId: 'refs-id' },
|
||||
}),
|
||||
),
|
||||
).toBe('refs-id');
|
||||
});
|
||||
|
||||
it('falls back to id when neither metricsTarget nor refs carry a resourceId', () => {
|
||||
expect(
|
||||
resolveStorageRecordMetricResourceId(
|
||||
makeRecord({ id: 'storage-1', refs: { resourceId: ' ' } }),
|
||||
),
|
||||
).toBe('storage-1');
|
||||
expect(resolveStorageRecordMetricResourceId(makeRecord({ id: 'storage-1' }))).toBe(
|
||||
'storage-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('trims whitespace from the selected field at every precedence level', () => {
|
||||
expect(
|
||||
resolveStorageRecordMetricResourceId(
|
||||
makeRecord({
|
||||
metricsTarget: { resourceType: 'storage', resourceId: ' trimmed-metrics ' },
|
||||
}),
|
||||
),
|
||||
).toBe('trimmed-metrics');
|
||||
expect(
|
||||
resolveStorageRecordMetricResourceId(
|
||||
makeRecord({ refs: { resourceId: ' trimmed-refs ' } }),
|
||||
),
|
||||
).toBe('trimmed-refs');
|
||||
expect(
|
||||
resolveStorageRecordMetricResourceId(makeRecord({ id: ' trimmed-id ' })),
|
||||
).toBe('trimmed-id');
|
||||
});
|
||||
|
||||
it('returns an empty string when id is empty and no higher-precedence field is set', () => {
|
||||
expect(resolveStorageRecordMetricResourceId(makeRecord({ id: '' }))).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePhysicalDiskMetricResourceId', () => {
|
||||
it('returns metricsTarget.resourceId when type is explicitly disk', () => {
|
||||
expect(
|
||||
resolvePhysicalDiskMetricResourceId(
|
||||
makeResource({
|
||||
metricsTarget: { resourceType: 'disk', resourceId: 'metrics-disk-id' },
|
||||
}),
|
||||
),
|
||||
).toBe('metrics-disk-id');
|
||||
});
|
||||
|
||||
it('matches the disk type case-insensitively', () => {
|
||||
expect(
|
||||
resolvePhysicalDiskMetricResourceId(
|
||||
makeResource({
|
||||
metricsTarget: { resourceType: 'DISK', resourceId: 'metrics-disk-id' },
|
||||
} as unknown as Partial<Resource>),
|
||||
),
|
||||
).toBe('metrics-disk-id');
|
||||
});
|
||||
|
||||
it('returns metricsTarget.resourceId when type is absent', () => {
|
||||
expect(
|
||||
resolvePhysicalDiskMetricResourceId(
|
||||
makeResource({
|
||||
metricsTarget: { resourceType: '', resourceId: 'metrics-disk-id' },
|
||||
} as unknown as Partial<Resource>),
|
||||
),
|
||||
).toBe('metrics-disk-id');
|
||||
});
|
||||
|
||||
it('returns metricsTarget.resourceId when type is whitespace-only (normalized to empty)', () => {
|
||||
expect(
|
||||
resolvePhysicalDiskMetricResourceId(
|
||||
makeResource({
|
||||
metricsTarget: { resourceType: ' ', resourceId: 'metrics-disk-id' },
|
||||
} as unknown as Partial<Resource>),
|
||||
),
|
||||
).toBe('metrics-disk-id');
|
||||
});
|
||||
|
||||
it('skips metricsTarget when type is a non-disk resource type, falling through to serial', () => {
|
||||
expect(
|
||||
resolvePhysicalDiskMetricResourceId(
|
||||
makeResource({
|
||||
metricsTarget: { resourceType: 'storage', resourceId: 'metrics-storage-id' },
|
||||
physicalDisk: { serial: 'SERIAL-1' },
|
||||
}),
|
||||
),
|
||||
).toBe('SERIAL-1');
|
||||
});
|
||||
|
||||
it('skips metricsTarget when resourceId is empty even with a disk type, falling through', () => {
|
||||
expect(
|
||||
resolvePhysicalDiskMetricResourceId(
|
||||
makeResource({
|
||||
metricsTarget: { resourceType: 'disk', resourceId: '' },
|
||||
physicalDisk: { serial: 'SERIAL-1' },
|
||||
}),
|
||||
),
|
||||
).toBe('SERIAL-1');
|
||||
});
|
||||
|
||||
it('trims the selected metricsTarget.resourceId', () => {
|
||||
expect(
|
||||
resolvePhysicalDiskMetricResourceId(
|
||||
makeResource({
|
||||
metricsTarget: { resourceType: 'disk', resourceId: ' trimmed-disk ' },
|
||||
}),
|
||||
),
|
||||
).toBe('trimmed-disk');
|
||||
});
|
||||
|
||||
it('returns serial from disk.physicalDisk.serial', () => {
|
||||
expect(
|
||||
resolvePhysicalDiskMetricResourceId(
|
||||
makeResource({ physicalDisk: { serial: 'SERIAL-1' } }),
|
||||
),
|
||||
).toBe('SERIAL-1');
|
||||
});
|
||||
|
||||
it('returns serial from platformData.physicalDisk.serial when disk.physicalDisk is absent', () => {
|
||||
expect(
|
||||
resolvePhysicalDiskMetricResourceId(
|
||||
makeResource({
|
||||
platformData: { physicalDisk: { serial: 'PD-SERIAL-1' } },
|
||||
}),
|
||||
),
|
||||
).toBe('PD-SERIAL-1');
|
||||
});
|
||||
|
||||
it('prefers disk.physicalDisk.serial over platformData.physicalDisk.serial', () => {
|
||||
expect(
|
||||
resolvePhysicalDiskMetricResourceId(
|
||||
makeResource({
|
||||
physicalDisk: { serial: 'DIRECT' },
|
||||
platformData: { physicalDisk: { serial: 'PLATFORM' } },
|
||||
}),
|
||||
),
|
||||
).toBe('DIRECT');
|
||||
});
|
||||
|
||||
it('trims the selected serial', () => {
|
||||
expect(
|
||||
resolvePhysicalDiskMetricResourceId(
|
||||
makeResource({ physicalDisk: { serial: ' TRIMMED-SERIAL ' } }),
|
||||
),
|
||||
).toBe('TRIMMED-SERIAL');
|
||||
});
|
||||
|
||||
it('falls through to wwn when no serial is available', () => {
|
||||
expect(
|
||||
resolvePhysicalDiskMetricResourceId(
|
||||
makeResource({ physicalDisk: { wwn: 'WWN-1' } }),
|
||||
),
|
||||
).toBe('WWN-1');
|
||||
});
|
||||
|
||||
it('returns wwn from platformData.physicalDisk.wwn when disk.physicalDisk.wwn is absent', () => {
|
||||
expect(
|
||||
resolvePhysicalDiskMetricResourceId(
|
||||
makeResource({
|
||||
platformData: { physicalDisk: { wwn: 'PD-WWN-1' } },
|
||||
}),
|
||||
),
|
||||
).toBe('PD-WWN-1');
|
||||
});
|
||||
|
||||
it('prefers disk.physicalDisk.wwn over platformData.physicalDisk.wwn', () => {
|
||||
expect(
|
||||
resolvePhysicalDiskMetricResourceId(
|
||||
makeResource({
|
||||
physicalDisk: { wwn: 'DIRECT-WWN' },
|
||||
platformData: { physicalDisk: { wwn: 'PLATFORM-WWN' } },
|
||||
}),
|
||||
),
|
||||
).toBe('DIRECT-WWN');
|
||||
});
|
||||
|
||||
it('trims the selected wwn', () => {
|
||||
expect(
|
||||
resolvePhysicalDiskMetricResourceId(
|
||||
makeResource({ physicalDisk: { wwn: ' TRIMMED-WWN ' } }),
|
||||
),
|
||||
).toBe('TRIMMED-WWN');
|
||||
});
|
||||
|
||||
it('falls back to id when serial, wwn, and metricsTarget are all absent', () => {
|
||||
expect(resolvePhysicalDiskMetricResourceId(makeResource({ id: 'disk-1' }))).toBe(
|
||||
'disk-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to id when serial and wwn are whitespace-only', () => {
|
||||
expect(
|
||||
resolvePhysicalDiskMetricResourceId(
|
||||
makeResource({ id: 'disk-1', physicalDisk: { serial: ' ', wwn: ' ' } }),
|
||||
),
|
||||
).toBe('disk-1');
|
||||
});
|
||||
|
||||
it('lets a whitespace-only disk.physicalDisk.serial shadow platformData.physicalDisk.serial (|| binds before trim)', () => {
|
||||
// The `||` on the raw values runs before trim(): a whitespace-only direct
|
||||
// serial wins the `||`, then trims to empty, so the platformData serial is
|
||||
// never consulted and resolution falls all the way through to id. This pins
|
||||
// the current (suspected-buggy) behavior — see GLM_REPORT.md caveat.
|
||||
expect(
|
||||
resolvePhysicalDiskMetricResourceId(
|
||||
makeResource({
|
||||
id: 'disk-1',
|
||||
physicalDisk: { serial: ' ' },
|
||||
platformData: { physicalDisk: { serial: 'PD-SERIAL-1' } },
|
||||
}),
|
||||
),
|
||||
).toBe('disk-1');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
matchesStorageNodeTerms,
|
||||
parseStorageSearchQuery,
|
||||
} from '@/features/storageBackups/storageSearchQuery';
|
||||
|
||||
describe('storageSearchQuery', () => {
|
||||
it('returns empty term lists for blank input', () => {
|
||||
expect(parseStorageSearchQuery('')).toEqual({ freeTerms: [], nodeTerms: [] });
|
||||
expect(parseStorageSearchQuery(' ')).toEqual({ freeTerms: [], nodeTerms: [] });
|
||||
expect(parseStorageSearchQuery('\t\n')).toEqual({ freeTerms: [], nodeTerms: [] });
|
||||
});
|
||||
|
||||
it('collects free terms and lowercases them', () => {
|
||||
expect(parseStorageSearchQuery('foo').freeTerms).toEqual(['foo']);
|
||||
expect(parseStorageSearchQuery('FOO Bar').freeTerms).toEqual(['foo', 'bar']);
|
||||
});
|
||||
|
||||
it('routes node: tokens into nodeTerms after stripping the prefix', () => {
|
||||
expect(parseStorageSearchQuery('node:foo')).toEqual({ freeTerms: [], nodeTerms: ['foo'] });
|
||||
expect(parseStorageSearchQuery('node:foo node:bar').nodeTerms).toEqual(['foo', 'bar']);
|
||||
// keeps everything after the first colon as the term, including extra colons
|
||||
expect(parseStorageSearchQuery('node:foo:bar').nodeTerms).toEqual(['foo:bar']);
|
||||
});
|
||||
|
||||
it('keeps free and node terms separate regardless of token order', () => {
|
||||
expect(parseStorageSearchQuery('foo node:bar baz')).toEqual({
|
||||
freeTerms: ['foo', 'baz'],
|
||||
nodeTerms: ['bar'],
|
||||
});
|
||||
expect(parseStorageSearchQuery('node:alpha beta node:gamma')).toEqual({
|
||||
freeTerms: ['beta'],
|
||||
nodeTerms: ['alpha', 'gamma'],
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes case for both free and node terms', () => {
|
||||
expect(parseStorageSearchQuery('FOO NODE:Bar BaZ')).toEqual({
|
||||
freeTerms: ['foo', 'baz'],
|
||||
nodeTerms: ['bar'],
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses leading, trailing, and repeated internal whitespace', () => {
|
||||
expect(parseStorageSearchQuery(' foo ')).toEqual({ freeTerms: ['foo'], nodeTerms: [] });
|
||||
expect(parseStorageSearchQuery('foo\tbar baz')).toEqual({
|
||||
freeTerms: ['foo', 'bar', 'baz'],
|
||||
nodeTerms: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a bare node: prefix and does not bind a following whitespace-separated word', () => {
|
||||
// 'node:' with no attached value yields no node term...
|
||||
expect(parseStorageSearchQuery('node:')).toEqual({ freeTerms: [], nodeTerms: [] });
|
||||
// ...and a space-separated successor becomes a free term, not node:'s argument.
|
||||
expect(parseStorageSearchQuery('node: foo')).toEqual({ freeTerms: ['foo'], nodeTerms: [] });
|
||||
expect(parseStorageSearchQuery('node: foo node:bar')).toEqual({
|
||||
freeTerms: ['foo'],
|
||||
nodeTerms: ['bar'],
|
||||
});
|
||||
});
|
||||
|
||||
it('matches when every node term is a substring of at least one hint', () => {
|
||||
expect(matchesStorageNodeTerms(['node-foo'], ['foo'])).toBe(true);
|
||||
expect(matchesStorageNodeTerms(['node-alpha', 'node-beta'], ['alpha', 'beta'])).toBe(true);
|
||||
// a term may match against a different hint than the others
|
||||
expect(matchesStorageNodeTerms(['aaa', 'bbb'], ['a', 'b'])).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when any node term is absent from every hint', () => {
|
||||
expect(matchesStorageNodeTerms(['node-foo'], ['foo', 'bar'])).toBe(false);
|
||||
expect(matchesStorageNodeTerms(['node-a'], ['b'])).toBe(false);
|
||||
expect(matchesStorageNodeTerms([], ['foo'])).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an empty node term set as a universal match', () => {
|
||||
expect(matchesStorageNodeTerms(['node-foo'], [])).toBe(true);
|
||||
expect(matchesStorageNodeTerms([], [])).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes hints case-insensitively and trims surrounding whitespace', () => {
|
||||
expect(matchesStorageNodeTerms(['NODE-FOO'], ['foo'])).toBe(true);
|
||||
expect(matchesStorageNodeTerms([' Node-Foo '], ['foo'])).toBe(true);
|
||||
});
|
||||
|
||||
it('matches via substring (partial) as well as exact equality', () => {
|
||||
expect(matchesStorageNodeTerms(['foo'], ['foo'])).toBe(true);
|
||||
expect(matchesStorageNodeTerms(['node-foo-cluster'], ['foo', 'cluster'])).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores blank hints when searching for a match', () => {
|
||||
expect(matchesStorageNodeTerms(['', ' '], ['foo'])).toBe(false);
|
||||
expect(matchesStorageNodeTerms(['', 'node-foo'], ['foo'])).toBe(true);
|
||||
});
|
||||
});
|
||||
133
frontend-modern/src/stores/__tests__/sessionCapabilities.test.ts
Normal file
133
frontend-modern/src/stores/__tests__/sessionCapabilities.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
sessionCapabilities,
|
||||
sessionCapabilitiesResolved,
|
||||
syncSessionCapabilities,
|
||||
} from '@/stores/sessionCapabilities';
|
||||
|
||||
const resetSessionCapabilities = () => {
|
||||
syncSessionCapabilities(null);
|
||||
};
|
||||
|
||||
describe('session capabilities store', () => {
|
||||
afterEach(() => {
|
||||
resetSessionCapabilities();
|
||||
});
|
||||
|
||||
it('default-fills capabilities when no status payload is provided', () => {
|
||||
const next = syncSessionCapabilities();
|
||||
|
||||
expect(next).toEqual({ demoMode: false });
|
||||
expect(sessionCapabilities()).toEqual({ demoMode: false });
|
||||
});
|
||||
|
||||
it('default-fills capabilities when the status payload is null', () => {
|
||||
const next = syncSessionCapabilities(null);
|
||||
|
||||
expect(next).toEqual({ demoMode: false });
|
||||
expect(sessionCapabilities()).toEqual({ demoMode: false });
|
||||
});
|
||||
|
||||
it('default-fills capabilities when sessionCapabilities is omitted from status', () => {
|
||||
const next = syncSessionCapabilities({});
|
||||
|
||||
expect(next).toEqual({ demoMode: false });
|
||||
expect(sessionCapabilities()).toEqual({ demoMode: false });
|
||||
});
|
||||
|
||||
it('default-fills capabilities when sessionCapabilities is explicitly undefined', () => {
|
||||
const next = syncSessionCapabilities({ sessionCapabilities: undefined });
|
||||
|
||||
expect(next).toEqual({ demoMode: false });
|
||||
});
|
||||
|
||||
it('default-fills capabilities when sessionCapabilities is null', () => {
|
||||
const next = syncSessionCapabilities({ sessionCapabilities: null } as unknown as Parameters<typeof syncSessionCapabilities>[0]);
|
||||
|
||||
expect(next).toEqual({ demoMode: false });
|
||||
});
|
||||
|
||||
it('preserves an explicit demoMode===true capability', () => {
|
||||
const next = syncSessionCapabilities({
|
||||
sessionCapabilities: { demoMode: true },
|
||||
});
|
||||
|
||||
expect(next).toEqual({ demoMode: true });
|
||||
expect(sessionCapabilities()).toEqual({ demoMode: true });
|
||||
});
|
||||
|
||||
it('coerces an explicit demoMode===false capability to the default', () => {
|
||||
const next = syncSessionCapabilities({
|
||||
sessionCapabilities: { demoMode: false },
|
||||
});
|
||||
|
||||
expect(next).toEqual({ demoMode: false });
|
||||
});
|
||||
|
||||
// The normalizer gates demoMode on strict equality with `true`, so any
|
||||
// truthy value that is not the boolean `true` must collapse to false.
|
||||
it.each([
|
||||
['number 1', 1],
|
||||
['string "true"', 'true'],
|
||||
['string "yes"', 'yes'],
|
||||
['empty object', {}],
|
||||
['array', [1]],
|
||||
])('coerces a truthy-but-not-true demoMode (%s) to false', (_label, demoMode) => {
|
||||
const next = syncSessionCapabilities({
|
||||
sessionCapabilities: { demoMode: demoMode as unknown as boolean },
|
||||
});
|
||||
|
||||
expect(next).toEqual({ demoMode: false });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['zero', 0],
|
||||
['empty string', ''],
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
['NaN', Number.NaN],
|
||||
])('coerces a falsy demoMode (%s) to false', (_label, demoMode) => {
|
||||
const next = syncSessionCapabilities({
|
||||
sessionCapabilities: { demoMode: demoMode as unknown as boolean },
|
||||
});
|
||||
|
||||
expect(next).toEqual({ demoMode: false });
|
||||
});
|
||||
|
||||
it('returns the same normalized value it publishes to the signal', () => {
|
||||
const next = syncSessionCapabilities({
|
||||
sessionCapabilities: { demoMode: true },
|
||||
});
|
||||
|
||||
expect(sessionCapabilities()).toBe(next);
|
||||
});
|
||||
|
||||
it('marks session capabilities as resolved after a sync', () => {
|
||||
syncSessionCapabilities(null);
|
||||
|
||||
expect(sessionCapabilitiesResolved()).toBe(true);
|
||||
});
|
||||
|
||||
it('overwrites the previous capability value instead of merging', () => {
|
||||
syncSessionCapabilities({ sessionCapabilities: { demoMode: true } });
|
||||
expect(sessionCapabilities()).toEqual({ demoMode: true });
|
||||
|
||||
const next = syncSessionCapabilities(null);
|
||||
|
||||
expect(next).toEqual({ demoMode: false });
|
||||
expect(sessionCapabilities()).toEqual({ demoMode: false });
|
||||
});
|
||||
|
||||
it('strips unrecognized capability fields rather than passing them through', () => {
|
||||
const next = syncSessionCapabilities({
|
||||
sessionCapabilities: {
|
||||
demoMode: true,
|
||||
assistantEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(next).toEqual({ demoMode: true });
|
||||
expect(next).not.toHaveProperty('assistantEnabled');
|
||||
expect(sessionCapabilities()).not.toHaveProperty('assistantEnabled');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
getPlatformAgentRecord,
|
||||
getPlatformDataRecord,
|
||||
getPreferredResourceKubernetesContext,
|
||||
hasDockerFacetEvidence,
|
||||
} from '@/utils/agentResources';
|
||||
|
||||
const makeResource = (overrides: Partial<Resource> = {}): Resource =>
|
||||
({
|
||||
id: 'resource-1',
|
||||
type: 'agent',
|
||||
name: 'resource-1',
|
||||
displayName: 'resource-1',
|
||||
platformId: 'resource-1',
|
||||
platformType: 'agent',
|
||||
sourceType: 'agent',
|
||||
status: 'online',
|
||||
lastSeen: Date.now(),
|
||||
...overrides,
|
||||
}) as Resource;
|
||||
|
||||
describe('getPlatformDataRecord', () => {
|
||||
it('returns the platformData record by reference when present', () => {
|
||||
const platformData = { agent: { agentId: 'agent-1' } };
|
||||
const resource = makeResource({ platformData });
|
||||
expect(getPlatformDataRecord(resource)).toBe(platformData);
|
||||
});
|
||||
|
||||
it('returns the record for an empty platformData object', () => {
|
||||
const resource = makeResource({ platformData: {} });
|
||||
expect(getPlatformDataRecord(resource)).toEqual({});
|
||||
});
|
||||
|
||||
it('returns undefined when platformData is absent', () => {
|
||||
expect(getPlatformDataRecord(makeResource())).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when platformData is null (falsy guard)', () => {
|
||||
expect(
|
||||
getPlatformDataRecord(
|
||||
makeResource({ platformData: null as unknown as Record<string, unknown> }),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlatformAgentRecord', () => {
|
||||
it('returns the nested agent facet by reference when present', () => {
|
||||
const agent = { agentId: 'agent-1', hostname: 'tower' };
|
||||
const resource = makeResource({ platformData: { agent } });
|
||||
expect(getPlatformAgentRecord(resource)).toBe(agent);
|
||||
});
|
||||
|
||||
it('returns an empty record when the agent facet is present but empty', () => {
|
||||
const resource = makeResource({ platformData: { agent: {} } });
|
||||
expect(getPlatformAgentRecord(resource)).toEqual({});
|
||||
});
|
||||
|
||||
it('returns undefined when platformData is absent', () => {
|
||||
expect(getPlatformAgentRecord(makeResource())).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when platformData has no agent key', () => {
|
||||
expect(
|
||||
getPlatformAgentRecord(makeResource({ platformData: { docker: { hostSourceId: 'd-1' } } })),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the agent facet is null', () => {
|
||||
expect(getPlatformAgentRecord(makeResource({ platformData: { agent: null } }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the agent facet is a non-object scalar', () => {
|
||||
expect(
|
||||
getPlatformAgentRecord(makeResource({ platformData: { agent: 'agent-1' } })),
|
||||
).toBeUndefined();
|
||||
expect(getPlatformAgentRecord(makeResource({ platformData: { agent: 42 } }))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPreferredResourceKubernetesContext', () => {
|
||||
it('returns undefined when no kubernetes context coordinates are present', () => {
|
||||
expect(getPreferredResourceKubernetesContext({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers kubernetes.clusterName over context and clusterId', () => {
|
||||
expect(
|
||||
getPreferredResourceKubernetesContext({
|
||||
kubernetes: { clusterName: 'by-name', context: 'ctx', clusterId: 'cid' },
|
||||
}),
|
||||
).toBe('by-name');
|
||||
});
|
||||
|
||||
it('falls back to kubernetes.context when clusterName is absent', () => {
|
||||
expect(
|
||||
getPreferredResourceKubernetesContext({ kubernetes: { context: 'ctx', clusterId: 'cid' } }),
|
||||
).toBe('ctx');
|
||||
});
|
||||
|
||||
it('falls back to kubernetes.clusterId when name and context are absent', () => {
|
||||
expect(getPreferredResourceKubernetesContext({ kubernetes: { clusterId: 'cid' } })).toBe('cid');
|
||||
});
|
||||
|
||||
it('uses platformData.kubernetes when the top-level kubernetes facet is absent', () => {
|
||||
expect(
|
||||
getPreferredResourceKubernetesContext({
|
||||
platformData: {
|
||||
kubernetes: { clusterName: 'pd-name', context: 'pd-ctx', clusterId: 'pd-cid' },
|
||||
},
|
||||
}),
|
||||
).toBe('pd-name');
|
||||
});
|
||||
|
||||
it('prefers platformData.kubernetes.context over its own clusterId', () => {
|
||||
expect(
|
||||
getPreferredResourceKubernetesContext({
|
||||
platformData: { kubernetes: { context: 'pd-ctx', clusterId: 'pd-cid' } },
|
||||
}),
|
||||
).toBe('pd-ctx');
|
||||
});
|
||||
|
||||
it('prefers the top-level kubernetes.clusterId over platformData.kubernetes.clusterName', () => {
|
||||
expect(
|
||||
getPreferredResourceKubernetesContext({
|
||||
kubernetes: { clusterId: 'top-cid' },
|
||||
platformData: { kubernetes: { clusterName: 'pd-name' } },
|
||||
}),
|
||||
).toBe('top-cid');
|
||||
});
|
||||
|
||||
it('falls back to the standalone clusterId when no kubernetes facet is present', () => {
|
||||
expect(getPreferredResourceKubernetesContext({ clusterId: 'standalone-cluster' })).toBe(
|
||||
'standalone-cluster',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats whitespace-only, empty, and null strings as absent and falls through', () => {
|
||||
expect(
|
||||
getPreferredResourceKubernetesContext({
|
||||
kubernetes: { clusterName: ' ', context: '', clusterId: null },
|
||||
platformData: { kubernetes: { clusterName: 'pd-name' } },
|
||||
}),
|
||||
).toBe('pd-name');
|
||||
});
|
||||
|
||||
it('treats non-string values as absent via asTrimmedString', () => {
|
||||
expect(
|
||||
getPreferredResourceKubernetesContext({
|
||||
kubernetes: { clusterName: 123 as unknown as string, context: 'fallback-ctx' },
|
||||
}),
|
||||
).toBe('fallback-ctx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasDockerFacetEvidence', () => {
|
||||
it('returns false for non-record inputs', () => {
|
||||
expect(hasDockerFacetEvidence(undefined)).toBe(false);
|
||||
expect(hasDockerFacetEvidence(null)).toBe(false);
|
||||
expect(hasDockerFacetEvidence('docker')).toBe(false);
|
||||
expect(hasDockerFacetEvidence(42)).toBe(false);
|
||||
expect(hasDockerFacetEvidence(true)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an empty object', () => {
|
||||
expect(hasDockerFacetEvidence({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when any string value is non-empty', () => {
|
||||
expect(hasDockerFacetEvidence({ hostname: 'tower' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when string values are empty or whitespace-only', () => {
|
||||
expect(hasDockerFacetEvidence({ hostname: '' })).toBe(false);
|
||||
expect(hasDockerFacetEvidence({ hostname: ' ', runtime: '\t' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a non-zero finite number and false for zero, NaN, and Infinity', () => {
|
||||
expect(hasDockerFacetEvidence({ containerCount: 5 })).toBe(true);
|
||||
expect(hasDockerFacetEvidence({ containerCount: 0 })).toBe(false);
|
||||
expect(hasDockerFacetEvidence({ containerCount: NaN })).toBe(false);
|
||||
expect(hasDockerFacetEvidence({ containerCount: Infinity })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for boolean true and false for boolean false', () => {
|
||||
expect(hasDockerFacetEvidence({ healthy: true })).toBe(true);
|
||||
expect(hasDockerFacetEvidence({ healthy: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('inspects nested objects recursively', () => {
|
||||
expect(hasDockerFacetEvidence({ info: { name: 'tower' } })).toBe(true);
|
||||
expect(hasDockerFacetEvidence({ info: {} })).toBe(false);
|
||||
expect(hasDockerFacetEvidence({ info: { name: '' } })).toBe(false);
|
||||
});
|
||||
|
||||
it('inspects arrays recursively', () => {
|
||||
expect(hasDockerFacetEvidence({ nodes: ['node-1'] })).toBe(true);
|
||||
expect(hasDockerFacetEvidence({ nodes: [] })).toBe(false);
|
||||
expect(hasDockerFacetEvidence({ nodes: ['', 0, false] })).toBe(false);
|
||||
expect(hasDockerFacetEvidence({ nodes: [{ name: 'node-1' }] })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when at least one of many values is meaningful', () => {
|
||||
expect(hasDockerFacetEvidence({ a: '', b: 0, c: false, d: 'real' })).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a top-level array as a record and inspects its elements', () => {
|
||||
expect(hasDockerFacetEvidence(['runtime'])).toBe(true);
|
||||
expect(hasDockerFacetEvidence([])).toBe(false);
|
||||
expect(hasDockerFacetEvidence(['', 0])).toBe(false);
|
||||
});
|
||||
});
|
||||
111
frontend-modern/src/utils/__tests__/alerts.coverage.test.ts
Normal file
111
frontend-modern/src/utils/__tests__/alerts.coverage.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { isMetricAlertType, isStateAlertType } from '@/utils/alerts';
|
||||
|
||||
const STATE_ALERT_TYPES = [
|
||||
'powered-off',
|
||||
'unreachable',
|
||||
'offline',
|
||||
'host-offline',
|
||||
'connectivity',
|
||||
'docker-host-offline',
|
||||
'docker-container-state',
|
||||
'docker-container-health',
|
||||
] as const;
|
||||
|
||||
const METRIC_ALERT_TYPES = [
|
||||
'high-cpu',
|
||||
'cpu',
|
||||
'memory',
|
||||
'disk-usage',
|
||||
'network-in',
|
||||
'latency',
|
||||
] as const;
|
||||
|
||||
describe('isStateAlertType', () => {
|
||||
it.each(STATE_ALERT_TYPES)('returns true for the state alert type %s', (alertType) => {
|
||||
expect(isStateAlertType(alertType)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(METRIC_ALERT_TYPES)('returns false for the metric alert type %s', (alertType) => {
|
||||
expect(isStateAlertType(alertType)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an arbitrary unknown alert type', () => {
|
||||
expect(isStateAlertType('some-new-threshold-rule')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(isStateAlertType(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for the empty string via the falsy guard', () => {
|
||||
expect(isStateAlertType('')).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'Powered-Off',
|
||||
'UNREACHABLE',
|
||||
'Offline',
|
||||
'Host-Offline',
|
||||
])('is case-sensitive: %s is not a state alert type', (alertType) => {
|
||||
expect(isStateAlertType(alertType)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([' powered-off', 'powered-off ', 'unreachable\n', '\toffline'])(
|
||||
'does not match state types with surrounding whitespace: %j',
|
||||
(alertType) => {
|
||||
expect(isStateAlertType(alertType)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['PoweredOff', 'powered_off', 'poweredOff', 'hostOffline'])(
|
||||
'does not match separator/camel variants of state types: %j',
|
||||
(alertType) => {
|
||||
expect(isStateAlertType(alertType)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('isMetricAlertType', () => {
|
||||
it.each(METRIC_ALERT_TYPES)('returns true for the metric alert type %s', (alertType) => {
|
||||
expect(isMetricAlertType(alertType)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(STATE_ALERT_TYPES)('returns false for the state alert type %s', (alertType) => {
|
||||
expect(isMetricAlertType(alertType)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for an arbitrary unknown alert type', () => {
|
||||
expect(isMetricAlertType('some-new-threshold-rule')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for undefined', () => {
|
||||
expect(isMetricAlertType(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for the empty string', () => {
|
||||
expect(isMetricAlertType('')).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['Powered-Off', ' powered-off ', 'powered_off'])(
|
||||
'treats non-canonical variants of state types as metric: %j',
|
||||
(alertType) => {
|
||||
expect(isMetricAlertType(alertType)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('is the exact logical inverse of isStateAlertType across a mixed input set', () => {
|
||||
const inputs = [
|
||||
...STATE_ALERT_TYPES,
|
||||
...METRIC_ALERT_TYPES,
|
||||
'',
|
||||
undefined,
|
||||
'unknown-type',
|
||||
'Powered-Off',
|
||||
' powered-off ',
|
||||
];
|
||||
for (const input of inputs) {
|
||||
expect(isMetricAlertType(input)).toBe(!isStateAlertType(input));
|
||||
}
|
||||
});
|
||||
});
|
||||
139
frontend-modern/src/utils/__tests__/cloudPlans.coverage.test.ts
Normal file
139
frontend-modern/src/utils/__tests__/cloudPlans.coverage.test.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
CLOUD_PLAN_DEFINITIONS,
|
||||
CLOUD_PLAN_LABELS,
|
||||
DEFAULT_CLOUD_TIER,
|
||||
parseCloudTier,
|
||||
} from '@/utils/cloudPlans';
|
||||
|
||||
describe('parseCloudTier', () => {
|
||||
it('returns each tier key when given its canonical lowercase name', () => {
|
||||
expect(parseCloudTier('starter')).toBe('starter');
|
||||
expect(parseCloudTier('power')).toBe('power');
|
||||
expect(parseCloudTier('max')).toBe('max');
|
||||
});
|
||||
|
||||
it('normalizes mixed-case input through toLowerCase', () => {
|
||||
expect(parseCloudTier('STARTER')).toBe('starter');
|
||||
expect(parseCloudTier('Power')).toBe('power');
|
||||
expect(parseCloudTier('MAX')).toBe('max');
|
||||
expect(parseCloudTier('MaX')).toBe('max');
|
||||
expect(parseCloudTier('sTaRtEr')).toBe('starter');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace before matching', () => {
|
||||
expect(parseCloudTier(' power ')).toBe('power');
|
||||
expect(parseCloudTier('\tmax\n')).toBe('max');
|
||||
expect(parseCloudTier(' starter ')).toBe('starter');
|
||||
expect(parseCloudTier('\t POWER \n')).toBe('power');
|
||||
});
|
||||
|
||||
it('falls back to DEFAULT_CLOUD_TIER for empty, whitespace-only, null, and undefined', () => {
|
||||
expect(parseCloudTier('')).toBe(DEFAULT_CLOUD_TIER);
|
||||
expect(parseCloudTier(' ')).toBe(DEFAULT_CLOUD_TIER);
|
||||
expect(parseCloudTier('\t\n')).toBe(DEFAULT_CLOUD_TIER);
|
||||
expect(parseCloudTier(null)).toBe(DEFAULT_CLOUD_TIER);
|
||||
expect(parseCloudTier(undefined)).toBe(DEFAULT_CLOUD_TIER);
|
||||
});
|
||||
|
||||
it('falls back to the starter tier for any unrecognized non-empty string', () => {
|
||||
expect(parseCloudTier('enterprise')).toBe('starter');
|
||||
expect(parseCloudTier('free')).toBe('starter');
|
||||
expect(parseCloudTier('foo')).toBe('starter');
|
||||
expect(parseCloudTier('starter-annual')).toBe('starter');
|
||||
expect(parseCloudTier('power_pro')).toBe('starter');
|
||||
expect(parseCloudTier('123')).toBe('starter');
|
||||
});
|
||||
|
||||
it('does not treat cloud planVersion identifiers as tier names', () => {
|
||||
// planVersions use the cloud_<tier> form; they must not collide with bare tier keys
|
||||
expect(parseCloudTier('cloud_starter')).toBe('starter');
|
||||
expect(parseCloudTier('cloud_power')).toBe('starter');
|
||||
expect(parseCloudTier('cloud_max')).toBe('starter');
|
||||
expect(parseCloudTier('cloud_founding')).toBe('starter');
|
||||
});
|
||||
|
||||
it('does not treat MSP plan identifiers as cloud tiers', () => {
|
||||
expect(parseCloudTier('msp_starter')).toBe('starter');
|
||||
expect(parseCloudTier('msp_growth')).toBe('starter');
|
||||
expect(parseCloudTier('msp_scale')).toBe('starter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLOUD_PLAN_DEFINITIONS', () => {
|
||||
it('defines exactly the three canonical cloud tiers in canonical order', () => {
|
||||
expect(CLOUD_PLAN_DEFINITIONS.map((d) => d.tier)).toEqual(['starter', 'power', 'max']);
|
||||
});
|
||||
|
||||
it('keeps monitored-system capacity strictly increasing and distinct across tiers', () => {
|
||||
const capacities = CLOUD_PLAN_DEFINITIONS.map((d) => d.monitoredSystems);
|
||||
const sorted = [...capacities].sort((a, b) => a - b);
|
||||
expect(capacities).toEqual(sorted);
|
||||
expect(new Set(capacities).size).toBe(capacities.length);
|
||||
for (const capacity of capacities) {
|
||||
expect(capacity).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every definition a non-empty planVersion that has a human-readable label', () => {
|
||||
for (const definition of CLOUD_PLAN_DEFINITIONS) {
|
||||
expect(definition.planVersion.length).toBeGreaterThan(0);
|
||||
expect(CLOUD_PLAN_LABELS[definition.planVersion]).toBeTruthy();
|
||||
expect(typeof CLOUD_PLAN_LABELS[definition.planVersion]).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps planVersions and names unique across definitions', () => {
|
||||
const planVersions = CLOUD_PLAN_DEFINITIONS.map((d) => d.planVersion);
|
||||
const names = CLOUD_PLAN_DEFINITIONS.map((d) => d.name);
|
||||
expect(new Set(planVersions).size).toBe(planVersions.length);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
it('keeps every definition structurally complete with valid support level', () => {
|
||||
for (const definition of CLOUD_PLAN_DEFINITIONS) {
|
||||
expect(definition.name.length).toBeGreaterThan(0);
|
||||
expect(definition.monthlyPrice.length).toBeGreaterThan(0);
|
||||
expect(definition.annualSummary.length).toBeGreaterThan(0);
|
||||
expect(['Community', 'Priority']).toContain(definition.support);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLOUD_PLAN_LABELS', () => {
|
||||
it('provides a label for every planVersion used by the cloud plan definitions', () => {
|
||||
const usedPlanVersions = CLOUD_PLAN_DEFINITIONS.map((d) => d.planVersion);
|
||||
for (const planVersion of usedPlanVersions) {
|
||||
expect(CLOUD_PLAN_LABELS).toHaveProperty(planVersion);
|
||||
}
|
||||
});
|
||||
|
||||
it('only maps non-empty string keys to non-empty string labels', () => {
|
||||
for (const [key, label] of Object.entries(CLOUD_PLAN_LABELS)) {
|
||||
expect(typeof label).toBe('string');
|
||||
expect(label.length).toBeGreaterThan(0);
|
||||
expect(key.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps display labels distinct from their raw keys', () => {
|
||||
for (const [key, label] of Object.entries(CLOUD_PLAN_LABELS)) {
|
||||
expect(label).not.toBe(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_CLOUD_TIER', () => {
|
||||
it('points at the starter tier', () => {
|
||||
expect(DEFAULT_CLOUD_TIER).toBe('starter');
|
||||
});
|
||||
|
||||
it('is one of the tiers defined in CLOUD_PLAN_DEFINITIONS', () => {
|
||||
expect(CLOUD_PLAN_DEFINITIONS.map((d) => d.tier)).toContain(DEFAULT_CLOUD_TIER);
|
||||
});
|
||||
|
||||
it('survives a parseCloudTier round-trip', () => {
|
||||
expect(parseCloudTier(DEFAULT_CLOUD_TIER)).toBe(DEFAULT_CLOUD_TIER);
|
||||
});
|
||||
});
|
||||
285
frontend-modern/src/utils/__tests__/format.coverage.test.ts
Normal file
285
frontend-modern/src/utils/__tests__/format.coverage.test.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
/**
|
||||
* Coverage tests for format utility functions.
|
||||
*
|
||||
* Fills boundary / edge-case gaps not already exercised by
|
||||
* `format.test.ts` (byte/duration formatters) and `formatExtra.test.ts`
|
||||
* (basic happy-path coverage for the exports below).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ANOMALY_SEVERITY_CLASS,
|
||||
estimateTextWidth,
|
||||
formatAnomalyRatio,
|
||||
formatPowerOnHours,
|
||||
getShortImageName,
|
||||
normalizeDiskArray,
|
||||
} from '@/utils/format';
|
||||
|
||||
describe('formatPowerOnHours - boundaries', () => {
|
||||
it('treats negative hours as a raw hour count (no clamping)', () => {
|
||||
expect(formatPowerOnHours(-5)).toBe('-5 hours');
|
||||
expect(formatPowerOnHours(-5, true)).toBe('-5h');
|
||||
});
|
||||
|
||||
it('rounds days half-up at the 1.5-day boundary', () => {
|
||||
// 35h = 1.458 days -> rounds down to 1
|
||||
expect(formatPowerOnHours(35)).toBe('1 days');
|
||||
// 36h = exactly 1.5 days -> Math.round rounds half toward +Inf -> 2
|
||||
expect(formatPowerOnHours(36)).toBe('2 days');
|
||||
expect(formatPowerOnHours(36, true)).toBe('2d');
|
||||
});
|
||||
|
||||
it('keeps the day bucket up to 8759 hours (just under a year)', () => {
|
||||
expect(formatPowerOnHours(8759)).toBe('365 days');
|
||||
expect(formatPowerOnHours(8759, true)).toBe('365d');
|
||||
});
|
||||
|
||||
it('condenses mid-range day values', () => {
|
||||
// 100h / 24 = 4.1667 -> rounds to 4
|
||||
expect(formatPowerOnHours(100, true)).toBe('4d');
|
||||
});
|
||||
|
||||
it('formats years with one decimal just above the 8760 threshold', () => {
|
||||
// 9000/8760 = 1.0274 -> toFixed(1) = "1.0"
|
||||
expect(formatPowerOnHours(9000)).toBe('1.0 years');
|
||||
expect(formatPowerOnHours(9000, true)).toBe('1.0y');
|
||||
});
|
||||
|
||||
it('formats fractional years', () => {
|
||||
expect(formatPowerOnHours(13140)).toBe('1.5 years');
|
||||
expect(formatPowerOnHours(13140, true)).toBe('1.5y');
|
||||
});
|
||||
|
||||
it('handles very large hour counts', () => {
|
||||
// 100000/8760 = 11.4155 -> "11.4"
|
||||
expect(formatPowerOnHours(100000)).toBe('11.4 years');
|
||||
expect(formatPowerOnHours(100000, true)).toBe('11.4y');
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateTextWidth - edge inputs', () => {
|
||||
it('counts whitespace characters as full-width', () => {
|
||||
expect(estimateTextWidth(' ')).toBe(13.5);
|
||||
expect(estimateTextWidth(' ')).toBe(19);
|
||||
});
|
||||
|
||||
it('counts control characters (tab, newline) as length 1', () => {
|
||||
expect(estimateTextWidth('\t')).toBe(13.5);
|
||||
expect(estimateTextWidth('\n')).toBe(13.5);
|
||||
});
|
||||
|
||||
it('counts surrogate pairs as length 2 (UTF-16 code units)', () => {
|
||||
// "😀" is one code point but two UTF-16 code units -> length === 2
|
||||
expect('😀'.length).toBe(2);
|
||||
expect(estimateTextWidth('😀')).toBe(19);
|
||||
});
|
||||
|
||||
it('scales linearly for very long strings', () => {
|
||||
expect(estimateTextWidth('a'.repeat(100))).toBe(558);
|
||||
});
|
||||
|
||||
it('is strictly monotonic with respect to length', () => {
|
||||
const a = estimateTextWidth('a');
|
||||
const aa = estimateTextWidth('aa');
|
||||
const aaa = estimateTextWidth('aaa');
|
||||
expect(a).toBeLessThan(aa);
|
||||
expect(aa).toBeLessThan(aaa);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAnomalyRatio - boundaries', () => {
|
||||
it('returns the double-up arrow at exactly the 1.5 boundary', () => {
|
||||
expect(formatAnomalyRatio({ baseline_mean: 100, current_value: 150 })).toBe('↑↑');
|
||||
});
|
||||
|
||||
it('returns a single arrow just below 1.5', () => {
|
||||
expect(formatAnomalyRatio({ baseline_mean: 100, current_value: 149.9 })).toBe('↑');
|
||||
});
|
||||
|
||||
it('returns the double-up arrow just below 2', () => {
|
||||
expect(formatAnomalyRatio({ baseline_mean: 100, current_value: 199.99 })).toBe('↑↑');
|
||||
});
|
||||
|
||||
it('returns a single arrow at exactly 1x (no anomaly)', () => {
|
||||
expect(formatAnomalyRatio({ baseline_mean: 100, current_value: 100 })).toBe('↑');
|
||||
});
|
||||
|
||||
it('returns a single arrow for a decrease (current < baseline)', () => {
|
||||
// 50/100 = 0.5 -> below 1.5 threshold -> "↑" (no directional distinction)
|
||||
expect(formatAnomalyRatio({ baseline_mean: 100, current_value: 50 })).toBe('↑');
|
||||
});
|
||||
|
||||
it('returns a single arrow when current_value is 0', () => {
|
||||
expect(formatAnomalyRatio({ baseline_mean: 100, current_value: 0 })).toBe('↑');
|
||||
});
|
||||
|
||||
it('formats very large ratios with one decimal', () => {
|
||||
expect(formatAnomalyRatio({ baseline_mean: 1, current_value: 1000 })).toBe('1000.0x');
|
||||
});
|
||||
|
||||
it('rounds ratio decimals (not truncates)', () => {
|
||||
// 7/3 = 2.3333 -> toFixed(1) = "2.3"
|
||||
expect(formatAnomalyRatio({ baseline_mean: 3, current_value: 7 })).toBe('2.3x');
|
||||
});
|
||||
|
||||
it('treats negative/negative baselines as a positive ratio', () => {
|
||||
// -200 / -100 = 2 -> "2.0x"
|
||||
expect(formatAnomalyRatio({ baseline_mean: -100, current_value: -200 })).toBe('2.0x');
|
||||
});
|
||||
|
||||
it('returns a single arrow for a negative ratio (opposite signs)', () => {
|
||||
// 100 / -100 = -1 -> below 1.5 -> "↑"
|
||||
expect(formatAnomalyRatio({ baseline_mean: -100, current_value: 100 })).toBe('↑');
|
||||
// -300 / 100 = -3 -> below 1.5 -> "↑" (magnitude ignored)
|
||||
expect(formatAnomalyRatio({ baseline_mean: 100, current_value: -300 })).toBe('↑');
|
||||
});
|
||||
|
||||
it('does not short-circuit on a NaN baseline (falls through to "↑")', () => {
|
||||
// NaN === 0 is false, ratio is NaN, all comparisons false -> "↑"
|
||||
expect(formatAnomalyRatio({ baseline_mean: NaN, current_value: 100 })).toBe('↑');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ANOMALY_SEVERITY_CLASS - structural invariants', () => {
|
||||
it('exposes exactly the four severity levels', () => {
|
||||
expect(Object.keys(ANOMALY_SEVERITY_CLASS).sort()).toEqual([
|
||||
'critical',
|
||||
'high',
|
||||
'low',
|
||||
'medium',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns undefined for an unknown severity (no fallback class)', () => {
|
||||
expect(ANOMALY_SEVERITY_CLASS.nonexistent).toBeUndefined();
|
||||
expect(ANOMALY_SEVERITY_CLASS['made-up']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses valid Tailwind text-color classes for every severity', () => {
|
||||
for (const cls of Object.values(ANOMALY_SEVERITY_CLASS)) {
|
||||
expect(cls).toMatch(/^text-[a-z]+-400$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShortImageName - edge cases', () => {
|
||||
it('strips a registry that includes a port number', () => {
|
||||
expect(getShortImageName('localhost:5000/myapp:v1')).toBe('myapp:v1');
|
||||
});
|
||||
|
||||
it('strips a registry with port and nested path', () => {
|
||||
expect(getShortImageName('registry.io:5000/org/app:tag')).toBe('app:tag');
|
||||
});
|
||||
|
||||
it('returns a tagless image name unchanged', () => {
|
||||
expect(getShortImageName('nginx')).toBe('nginx');
|
||||
});
|
||||
|
||||
it('preserves a bare digest-style token that has no slash or @', () => {
|
||||
// "sha256:abc123" has no '@' and no '/', so it is returned as-is.
|
||||
expect(getShortImageName('sha256:abc123')).toBe('sha256:abc123');
|
||||
});
|
||||
|
||||
it('returns the leaf before the digest when an @ is present', () => {
|
||||
expect(getShortImageName('nginx@sha256:abc123')).toBe('nginx');
|
||||
});
|
||||
|
||||
it('does not strip a trailing slash (falls back to the full string)', () => {
|
||||
// split('/') yields [..., '']; last element is '' (falsy) so the
|
||||
// `|| cleanImage` branch returns the untrimmed "foo/bar/".
|
||||
expect(getShortImageName('foo/bar/')).toBe('foo/bar/');
|
||||
});
|
||||
|
||||
it('returns the em-dash when only a digest with empty image prefix is given', () => {
|
||||
// "@sha256:abc" -> cleanImage "" -> last part "" -> cleanImage "" -> "—"
|
||||
expect(getShortImageName('@sha256:abc')).toBe('—');
|
||||
});
|
||||
|
||||
it('does not trim surrounding whitespace', () => {
|
||||
expect(getShortImageName(' nginx ')).toBe(' nginx ');
|
||||
expect(getShortImageName(' ')).toBe(' ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeDiskArray - edge cases', () => {
|
||||
it('uses an explicitly-provided free value over total-used', () => {
|
||||
const result = normalizeDiskArray([{ total: 1000, used: 500, free: 250 }]);
|
||||
expect(result?.[0].free).toBe(250);
|
||||
expect(result?.[0].usage).toBe(50);
|
||||
});
|
||||
|
||||
it('clamps computed free to 0 when used exceeds total', () => {
|
||||
const result = normalizeDiskArray([{ total: 100, used: 200 }]);
|
||||
expect(result?.[0].free).toBe(0);
|
||||
expect(result?.[0].usage).toBe(200);
|
||||
});
|
||||
|
||||
it('reports exactly 100% usage when used equals total', () => {
|
||||
const result = normalizeDiskArray([{ total: 100, used: 100 }]);
|
||||
expect(result?.[0].usage).toBe(100);
|
||||
expect(result?.[0].free).toBe(0);
|
||||
});
|
||||
|
||||
it('does not clamp an explicitly-provided free that exceeds total', () => {
|
||||
const result = normalizeDiskArray([{ total: 1000, used: 500, free: 5000 }]);
|
||||
expect(result?.[0].free).toBe(5000);
|
||||
});
|
||||
|
||||
it('gives filesystem precedence over type when both are present', () => {
|
||||
const result = normalizeDiskArray([{ filesystem: 'ext4', type: 'xfs' }]);
|
||||
expect(result?.[0].type).toBe('ext4');
|
||||
});
|
||||
|
||||
it('leaves type undefined when neither filesystem nor type is provided', () => {
|
||||
const result = normalizeDiskArray([{ total: 100, used: 50 }]);
|
||||
expect(result?.[0].type).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves the device identifier in the output', () => {
|
||||
const result = normalizeDiskArray([{ device: '/dev/sda1', total: 100 }]);
|
||||
expect(result?.[0].device).toBe('/dev/sda1');
|
||||
});
|
||||
|
||||
it('trims mountpoint whitespace before matching the non-operational set', () => {
|
||||
// " /boot/efi " trims to "/boot/efi" which is filtered -> all filtered -> undefined
|
||||
expect(
|
||||
normalizeDiskArray([{ mountpoint: ' /boot/efi ', total: 100, used: 50 }]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('filters mountpoints containing "System Reserved" as a substring', () => {
|
||||
expect(
|
||||
normalizeDiskArray([{ mountpoint: 'System Reserved Drive', total: 100 }]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('filters any mountpoint under the /System/Volumes/ prefix', () => {
|
||||
expect(
|
||||
normalizeDiskArray([{ mountpoint: '/System/Volumes/VM', total: 100 }]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps disks whose mountpoint is undefined or empty', () => {
|
||||
const result = normalizeDiskArray([
|
||||
{ total: 100, used: 50 }, // mountpoint undefined
|
||||
{ mountpoint: '', total: 200, used: 100 },
|
||||
]);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not trim the mountpoint in the returned object (only the filter trims)', () => {
|
||||
// "/mnt " trims to "/mnt" (operational) so it is kept, but the output
|
||||
// preserves the original trailing space.
|
||||
const result = normalizeDiskArray([{ mountpoint: '/mnt ', total: 100 }]);
|
||||
expect(result?.[0].mountpoint).toBe('/mnt ');
|
||||
});
|
||||
|
||||
it('keeps only operational disks when mixed with filtered ones', () => {
|
||||
const result = normalizeDiskArray([
|
||||
{ mountpoint: '/etc/pve', total: 100, used: 10 },
|
||||
{ mountpoint: '/mnt/data', total: 200, used: 50 },
|
||||
{ mountpoint: '/boot/firmware', total: 100, used: 10 },
|
||||
]);
|
||||
expect(result?.map((d) => d.mountpoint)).toEqual(['/mnt/data']);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,374 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getSourcePlatformManifestEntry,
|
||||
getSourcePlatformReadinessStage,
|
||||
getSourcePlatformSurfaceKind,
|
||||
getSourcePlatformPrimaryMode,
|
||||
getSourcePlatformCanonicalProjections,
|
||||
getSourcePlatformOnboardingPaths,
|
||||
getSourcePlatformFamily,
|
||||
getSourcePlatformStorageFamily,
|
||||
getAgentHostProfileRuntimePlatform,
|
||||
sourcePlatformIsRuntimeLens,
|
||||
sourcePlatformIsOwningPlatform,
|
||||
sourcePlatformSupportsOnboardingPath,
|
||||
} from '@/utils/platformSupportManifest';
|
||||
|
||||
describe('platformSupportManifest', () => {
|
||||
describe('getSourcePlatformManifestEntry', () => {
|
||||
it('resolves a known platform by canonical id', () => {
|
||||
expect(getSourcePlatformManifestEntry('agent')?.id).toBe('agent');
|
||||
expect(getSourcePlatformManifestEntry('truenas')?.id).toBe('truenas');
|
||||
});
|
||||
|
||||
it('resolves alias tokens through the alias map before any display-token fallback', () => {
|
||||
expect(getSourcePlatformManifestEntry('pve')?.id).toBe('proxmox-pve');
|
||||
expect(getSourcePlatformManifestEntry('proxmox')?.id).toBe('proxmox-pve');
|
||||
expect(getSourcePlatformManifestEntry('pbs')?.id).toBe('proxmox-pbs');
|
||||
expect(getSourcePlatformManifestEntry('pmg')?.id).toBe('proxmox-pmg');
|
||||
expect(getSourcePlatformManifestEntry('k8s')?.id).toBe('kubernetes');
|
||||
expect(getSourcePlatformManifestEntry('vmware')?.id).toBe('vmware-vsphere');
|
||||
expect(getSourcePlatformManifestEntry('hyper-v')?.id).toBe('microsoft-hyperv');
|
||||
});
|
||||
|
||||
it('normalizes case and surrounding whitespace before lookup', () => {
|
||||
expect(getSourcePlatformManifestEntry('AGENT')?.id).toBe('agent');
|
||||
expect(getSourcePlatformManifestEntry(' Proxmox-PVE ')?.id).toBe('proxmox-pve');
|
||||
expect(getSourcePlatformManifestEntry('\tK8S\n')?.id).toBe('kubernetes');
|
||||
});
|
||||
|
||||
it('falls back to display tokens when no id or alias matches', () => {
|
||||
expect(getSourcePlatformManifestEntry('Container runtime')?.id).toBe('docker');
|
||||
expect(getSourcePlatformManifestEntry('podman')?.id).toBe('docker');
|
||||
expect(getSourcePlatformManifestEntry('Proxmox VE')?.id).toBe('proxmox-pve');
|
||||
expect(getSourcePlatformManifestEntry('vmware vsphere')?.id).toBe('vmware-vsphere');
|
||||
});
|
||||
|
||||
it('returns null for empty, whitespace-only, null, and undefined inputs', () => {
|
||||
expect(getSourcePlatformManifestEntry(null)).toBeNull();
|
||||
expect(getSourcePlatformManifestEntry(undefined)).toBeNull();
|
||||
expect(getSourcePlatformManifestEntry('')).toBeNull();
|
||||
expect(getSourcePlatformManifestEntry(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unknown tokens that are neither ids, aliases, nor display tokens', () => {
|
||||
expect(getSourcePlatformManifestEntry('mystery-platform')).toBeNull();
|
||||
expect(getSourcePlatformManifestEntry('docker-host')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSourcePlatformReadinessStage', () => {
|
||||
it('returns the readiness stage for supported and presentation-only platforms', () => {
|
||||
expect(getSourcePlatformReadinessStage('agent')).toBe('supported');
|
||||
expect(getSourcePlatformReadinessStage('docker')).toBe('supported');
|
||||
expect(getSourcePlatformReadinessStage('proxmox-pve')).toBe('supported');
|
||||
expect(getSourcePlatformReadinessStage('unraid')).toBe('presentation-only');
|
||||
expect(getSourcePlatformReadinessStage('synology-dsm')).toBe('presentation-only');
|
||||
});
|
||||
|
||||
it('resolves aliases and case-insensitive tokens to the canonical stage', () => {
|
||||
expect(getSourcePlatformReadinessStage('pve')).toBe('supported');
|
||||
expect(getSourcePlatformReadinessStage('k8s')).toBe('supported');
|
||||
expect(getSourcePlatformReadinessStage('AGENT')).toBe('supported');
|
||||
expect(getSourcePlatformReadinessStage('Truenas')).toBe('supported');
|
||||
});
|
||||
|
||||
it('returns null for unknown or empty input', () => {
|
||||
expect(getSourcePlatformReadinessStage(null)).toBeNull();
|
||||
expect(getSourcePlatformReadinessStage('')).toBeNull();
|
||||
expect(getSourcePlatformReadinessStage('mystery-platform')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSourcePlatformSurfaceKind', () => {
|
||||
it('classifies owning platforms, runtime lenses, and presentation-only surfaces', () => {
|
||||
expect(getSourcePlatformSurfaceKind('agent')).toBe('platform');
|
||||
expect(getSourcePlatformSurfaceKind('kubernetes')).toBe('platform');
|
||||
expect(getSourcePlatformSurfaceKind('proxmox-pve')).toBe('platform');
|
||||
expect(getSourcePlatformSurfaceKind('unraid')).toBe('presentation-only');
|
||||
expect(getSourcePlatformSurfaceKind('aws')).toBe('presentation-only');
|
||||
});
|
||||
|
||||
it('resolves aliases and case-insensitive tokens to the canonical surface kind', () => {
|
||||
expect(getSourcePlatformSurfaceKind('vmware')).toBe('platform');
|
||||
expect(getSourcePlatformSurfaceKind('hyper-v')).toBe('presentation-only');
|
||||
expect(getSourcePlatformSurfaceKind('AGENT')).toBe('platform');
|
||||
expect(getSourcePlatformSurfaceKind('KUBERNETES')).toBe('platform');
|
||||
});
|
||||
|
||||
it('returns null for unknown or empty input', () => {
|
||||
expect(getSourcePlatformSurfaceKind(null)).toBeNull();
|
||||
expect(getSourcePlatformSurfaceKind(undefined)).toBeNull();
|
||||
expect(getSourcePlatformSurfaceKind('')).toBeNull();
|
||||
expect(getSourcePlatformSurfaceKind('mystery-platform')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourcePlatformIsRuntimeLens', () => {
|
||||
it('returns true for the runtime-lens surface kind', () => {
|
||||
expect(sourcePlatformIsRuntimeLens('docker')).toBe(true);
|
||||
expect(sourcePlatformIsRuntimeLens('Container runtime')).toBe(true);
|
||||
expect(sourcePlatformIsRuntimeLens('podman')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for owning platforms and presentation-only surfaces', () => {
|
||||
expect(sourcePlatformIsRuntimeLens('agent')).toBe(false);
|
||||
expect(sourcePlatformIsRuntimeLens('kubernetes')).toBe(false);
|
||||
expect(sourcePlatformIsRuntimeLens('proxmox-pve')).toBe(false);
|
||||
expect(sourcePlatformIsRuntimeLens('unraid')).toBe(false);
|
||||
expect(sourcePlatformIsRuntimeLens('aws')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for unknown or empty input', () => {
|
||||
expect(sourcePlatformIsRuntimeLens(null)).toBe(false);
|
||||
expect(sourcePlatformIsRuntimeLens('')).toBe(false);
|
||||
expect(sourcePlatformIsRuntimeLens('mystery-platform')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourcePlatformIsOwningPlatform', () => {
|
||||
it('returns true for platform surface kinds', () => {
|
||||
expect(sourcePlatformIsOwningPlatform('agent')).toBe(true);
|
||||
expect(sourcePlatformIsOwningPlatform('kubernetes')).toBe(true);
|
||||
expect(sourcePlatformIsOwningPlatform('proxmox-pve')).toBe(true);
|
||||
expect(sourcePlatformIsOwningPlatform('truenas')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves aliases and case-insensitive tokens', () => {
|
||||
expect(sourcePlatformIsOwningPlatform('pve')).toBe(true);
|
||||
expect(sourcePlatformIsOwningPlatform('k8s')).toBe(true);
|
||||
expect(sourcePlatformIsOwningPlatform('vmware')).toBe(true);
|
||||
expect(sourcePlatformIsOwningPlatform('AGENT')).toBe(true);
|
||||
expect(sourcePlatformIsOwningPlatform('KUBERNETES')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for runtime lenses, presentation-only surfaces, and unknown input', () => {
|
||||
expect(sourcePlatformIsOwningPlatform('docker')).toBe(false);
|
||||
expect(sourcePlatformIsOwningPlatform('unraid')).toBe(false);
|
||||
expect(sourcePlatformIsOwningPlatform('gcp')).toBe(false);
|
||||
expect(sourcePlatformIsOwningPlatform(null)).toBe(false);
|
||||
expect(sourcePlatformIsOwningPlatform('')).toBe(false);
|
||||
expect(sourcePlatformIsOwningPlatform('mystery-platform')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSourcePlatformPrimaryMode', () => {
|
||||
it('returns agent-backed, api-backed, and presentation-only modes for known platforms', () => {
|
||||
expect(getSourcePlatformPrimaryMode('agent')).toBe('agent-backed');
|
||||
expect(getSourcePlatformPrimaryMode('docker')).toBe('agent-backed');
|
||||
expect(getSourcePlatformPrimaryMode('kubernetes')).toBe('agent-backed');
|
||||
expect(getSourcePlatformPrimaryMode('proxmox-pve')).toBe('api-backed');
|
||||
expect(getSourcePlatformPrimaryMode('truenas')).toBe('api-backed');
|
||||
expect(getSourcePlatformPrimaryMode('vmware-vsphere')).toBe('api-backed');
|
||||
expect(getSourcePlatformPrimaryMode('unraid')).toBe('presentation-only');
|
||||
expect(getSourcePlatformPrimaryMode('aws')).toBe('presentation-only');
|
||||
});
|
||||
|
||||
it('resolves aliases and case-insensitive tokens to the canonical mode', () => {
|
||||
expect(getSourcePlatformPrimaryMode('pve')).toBe('api-backed');
|
||||
expect(getSourcePlatformPrimaryMode('pbs')).toBe('api-backed');
|
||||
expect(getSourcePlatformPrimaryMode('pmg')).toBe('api-backed');
|
||||
expect(getSourcePlatformPrimaryMode('k8s')).toBe('agent-backed');
|
||||
expect(getSourcePlatformPrimaryMode('vmware')).toBe('api-backed');
|
||||
expect(getSourcePlatformPrimaryMode('AGENT')).toBe('agent-backed');
|
||||
expect(getSourcePlatformPrimaryMode('PROXMOX-PBS')).toBe('api-backed');
|
||||
});
|
||||
|
||||
it('returns null for unknown or empty input', () => {
|
||||
expect(getSourcePlatformPrimaryMode(null)).toBeNull();
|
||||
expect(getSourcePlatformPrimaryMode('')).toBeNull();
|
||||
expect(getSourcePlatformPrimaryMode('mystery-platform')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSourcePlatformCanonicalProjections', () => {
|
||||
it('returns the canonical projection list for known platforms', () => {
|
||||
expect(getSourcePlatformCanonicalProjections('agent')).toEqual([
|
||||
'agent',
|
||||
'storage',
|
||||
'physical-disk',
|
||||
]);
|
||||
expect(getSourcePlatformCanonicalProjections('proxmox-pbs')).toEqual(['pbs', 'storage']);
|
||||
expect(getSourcePlatformCanonicalProjections('proxmox-pmg')).toEqual(['pmg']);
|
||||
});
|
||||
|
||||
it('resolves aliases and case-insensitive tokens to the canonical projections', () => {
|
||||
expect(getSourcePlatformCanonicalProjections('pve')).toEqual([
|
||||
'agent',
|
||||
'vm',
|
||||
'system-container',
|
||||
'storage',
|
||||
'ceph',
|
||||
'physical-disk',
|
||||
]);
|
||||
expect(getSourcePlatformCanonicalProjections('pbs')).toEqual(['pbs', 'storage']);
|
||||
expect(getSourcePlatformCanonicalProjections('AGENT')).toEqual([
|
||||
'agent',
|
||||
'storage',
|
||||
'physical-disk',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array for presentation-only platforms with no projections', () => {
|
||||
expect(getSourcePlatformCanonicalProjections('unraid')).toEqual([]);
|
||||
expect(getSourcePlatformCanonicalProjections('aws')).toEqual([]);
|
||||
expect(getSourcePlatformCanonicalProjections('gcp')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array for unknown and empty input', () => {
|
||||
expect(getSourcePlatformCanonicalProjections(null)).toEqual([]);
|
||||
expect(getSourcePlatformCanonicalProjections('')).toEqual([]);
|
||||
expect(getSourcePlatformCanonicalProjections('mystery-platform')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSourcePlatformOnboardingPaths', () => {
|
||||
it('returns install-workspace for agent-backed platforms', () => {
|
||||
expect(getSourcePlatformOnboardingPaths('agent')).toEqual(['install-workspace']);
|
||||
expect(getSourcePlatformOnboardingPaths('docker')).toEqual(['install-workspace']);
|
||||
expect(getSourcePlatformOnboardingPaths('kubernetes')).toEqual(['install-workspace']);
|
||||
});
|
||||
|
||||
it('returns platform-connections for api-backed platforms', () => {
|
||||
expect(getSourcePlatformOnboardingPaths('proxmox-pve')).toEqual(['platform-connections']);
|
||||
expect(getSourcePlatformOnboardingPaths('proxmox-pbs')).toEqual(['platform-connections']);
|
||||
expect(getSourcePlatformOnboardingPaths('truenas')).toEqual(['platform-connections']);
|
||||
expect(getSourcePlatformOnboardingPaths('vmware-vsphere')).toEqual(['platform-connections']);
|
||||
});
|
||||
|
||||
it('resolves aliases and case-insensitive tokens to the canonical paths', () => {
|
||||
expect(getSourcePlatformOnboardingPaths('pve')).toEqual(['platform-connections']);
|
||||
expect(getSourcePlatformOnboardingPaths('k8s')).toEqual(['install-workspace']);
|
||||
expect(getSourcePlatformOnboardingPaths('vmware')).toEqual(['platform-connections']);
|
||||
expect(getSourcePlatformOnboardingPaths('AGENT')).toEqual(['install-workspace']);
|
||||
});
|
||||
|
||||
it('returns an empty array for presentation-only platforms and unknown input', () => {
|
||||
expect(getSourcePlatformOnboardingPaths('unraid')).toEqual([]);
|
||||
expect(getSourcePlatformOnboardingPaths('aws')).toEqual([]);
|
||||
expect(getSourcePlatformOnboardingPaths(null)).toEqual([]);
|
||||
expect(getSourcePlatformOnboardingPaths('')).toEqual([]);
|
||||
expect(getSourcePlatformOnboardingPaths('mystery-platform')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSourcePlatformFamily', () => {
|
||||
it('returns the family label for known platforms', () => {
|
||||
expect(getSourcePlatformFamily('agent')).toBe('Pulse-managed host');
|
||||
expect(getSourcePlatformFamily('docker')).toBe('Container runtime');
|
||||
expect(getSourcePlatformFamily('kubernetes')).toBe('Cluster runtime');
|
||||
expect(getSourcePlatformFamily('proxmox-pve')).toBe('Proxmox');
|
||||
expect(getSourcePlatformFamily('proxmox-pbs')).toBe('Proxmox');
|
||||
expect(getSourcePlatformFamily('proxmox-pmg')).toBe('Proxmox');
|
||||
expect(getSourcePlatformFamily('aws')).toBe('AWS');
|
||||
});
|
||||
|
||||
it('resolves aliases and case-insensitive tokens to the canonical family', () => {
|
||||
expect(getSourcePlatformFamily('pve')).toBe('Proxmox');
|
||||
expect(getSourcePlatformFamily('pbs')).toBe('Proxmox');
|
||||
expect(getSourcePlatformFamily('pmg')).toBe('Proxmox');
|
||||
expect(getSourcePlatformFamily('k8s')).toBe('Cluster runtime');
|
||||
expect(getSourcePlatformFamily('vmware')).toBe('VMware');
|
||||
expect(getSourcePlatformFamily('hyper-v')).toBe('Hyper-V');
|
||||
expect(getSourcePlatformFamily('AGENT')).toBe('Pulse-managed host');
|
||||
expect(getSourcePlatformFamily('DOCKER')).toBe('Container runtime');
|
||||
});
|
||||
|
||||
it('falls back to display tokens for non-alias labels', () => {
|
||||
expect(getSourcePlatformFamily('Container runtime')).toBe('Container runtime');
|
||||
expect(getSourcePlatformFamily('podman')).toBe('Container runtime');
|
||||
});
|
||||
|
||||
it('returns null for unknown or empty input', () => {
|
||||
expect(getSourcePlatformFamily(null)).toBeNull();
|
||||
expect(getSourcePlatformFamily('')).toBeNull();
|
||||
expect(getSourcePlatformFamily('mystery-platform')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSourcePlatformStorageFamily', () => {
|
||||
it('returns the storage family for known platforms', () => {
|
||||
expect(getSourcePlatformStorageFamily('agent')).toBe('onprem');
|
||||
expect(getSourcePlatformStorageFamily('truenas')).toBe('onprem');
|
||||
expect(getSourcePlatformStorageFamily('docker')).toBe('container');
|
||||
expect(getSourcePlatformStorageFamily('kubernetes')).toBe('container');
|
||||
expect(getSourcePlatformStorageFamily('proxmox-pve')).toBe('virtualization');
|
||||
expect(getSourcePlatformStorageFamily('vmware-vsphere')).toBe('virtualization');
|
||||
expect(getSourcePlatformStorageFamily('aws')).toBe('cloud');
|
||||
expect(getSourcePlatformStorageFamily('azure')).toBe('cloud');
|
||||
expect(getSourcePlatformStorageFamily('gcp')).toBe('cloud');
|
||||
});
|
||||
|
||||
it('resolves aliases and case-insensitive tokens to the canonical storage family', () => {
|
||||
expect(getSourcePlatformStorageFamily('pve')).toBe('virtualization');
|
||||
expect(getSourcePlatformStorageFamily('pbs')).toBe('virtualization');
|
||||
expect(getSourcePlatformStorageFamily('k8s')).toBe('container');
|
||||
expect(getSourcePlatformStorageFamily('vmware')).toBe('virtualization');
|
||||
expect(getSourcePlatformStorageFamily('hyper-v')).toBe('virtualization');
|
||||
expect(getSourcePlatformStorageFamily('DOCKER')).toBe('container');
|
||||
expect(getSourcePlatformStorageFamily('AGENT')).toBe('onprem');
|
||||
expect(getSourcePlatformStorageFamily('AWS')).toBe('cloud');
|
||||
});
|
||||
|
||||
it('returns null for unknown or empty input', () => {
|
||||
expect(getSourcePlatformStorageFamily(null)).toBeNull();
|
||||
expect(getSourcePlatformStorageFamily('')).toBeNull();
|
||||
expect(getSourcePlatformStorageFamily('mystery-platform')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentHostProfileRuntimePlatform', () => {
|
||||
it('resolves the runtime platform via host identity tokens and case variants', () => {
|
||||
expect(getAgentHostProfileRuntimePlatform('unraid-os')).toBe('linux');
|
||||
expect(getAgentHostProfileRuntimePlatform('unraid os')).toBe('linux');
|
||||
expect(getAgentHostProfileRuntimePlatform('Unraid')).toBe('linux');
|
||||
expect(getAgentHostProfileRuntimePlatform('UNRAID-OS')).toBe('linux');
|
||||
});
|
||||
|
||||
it('returns null for unknown or empty input', () => {
|
||||
expect(getAgentHostProfileRuntimePlatform(null)).toBeNull();
|
||||
expect(getAgentHostProfileRuntimePlatform('')).toBeNull();
|
||||
expect(getAgentHostProfileRuntimePlatform('mystery-host')).toBeNull();
|
||||
expect(getAgentHostProfileRuntimePlatform('docker')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourcePlatformSupportsOnboardingPath', () => {
|
||||
it('returns true when the platform declares the requested onboarding path', () => {
|
||||
expect(sourcePlatformSupportsOnboardingPath('agent', 'install-workspace')).toBe(true);
|
||||
expect(sourcePlatformSupportsOnboardingPath('docker', 'install-workspace')).toBe(true);
|
||||
expect(sourcePlatformSupportsOnboardingPath('kubernetes', 'install-workspace')).toBe(true);
|
||||
expect(sourcePlatformSupportsOnboardingPath('proxmox-pve', 'platform-connections')).toBe(true);
|
||||
expect(sourcePlatformSupportsOnboardingPath('truenas', 'platform-connections')).toBe(true);
|
||||
expect(
|
||||
sourcePlatformSupportsOnboardingPath('vmware-vsphere', 'platform-connections'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the platform declares a different onboarding path', () => {
|
||||
expect(sourcePlatformSupportsOnboardingPath('agent', 'platform-connections')).toBe(false);
|
||||
expect(sourcePlatformSupportsOnboardingPath('docker', 'platform-connections')).toBe(false);
|
||||
expect(sourcePlatformSupportsOnboardingPath('proxmox-pve', 'install-workspace')).toBe(false);
|
||||
expect(sourcePlatformSupportsOnboardingPath('truenas', 'install-workspace')).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves aliases and case-insensitive tokens before checking paths', () => {
|
||||
expect(sourcePlatformSupportsOnboardingPath('pve', 'platform-connections')).toBe(true);
|
||||
expect(sourcePlatformSupportsOnboardingPath('k8s', 'install-workspace')).toBe(true);
|
||||
expect(sourcePlatformSupportsOnboardingPath('vmware', 'platform-connections')).toBe(true);
|
||||
expect(sourcePlatformSupportsOnboardingPath('AGENT', 'install-workspace')).toBe(true);
|
||||
expect(sourcePlatformSupportsOnboardingPath('Proxmox-PVE', 'platform-connections')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for presentation-only platforms, unknown input, and empty input', () => {
|
||||
expect(sourcePlatformSupportsOnboardingPath('unraid', 'install-workspace')).toBe(false);
|
||||
expect(sourcePlatformSupportsOnboardingPath('unraid', 'platform-connections')).toBe(false);
|
||||
expect(sourcePlatformSupportsOnboardingPath('aws', 'install-workspace')).toBe(false);
|
||||
expect(sourcePlatformSupportsOnboardingPath(null, 'install-workspace')).toBe(false);
|
||||
expect(sourcePlatformSupportsOnboardingPath('', 'install-workspace')).toBe(false);
|
||||
expect(sourcePlatformSupportsOnboardingPath('mystery-platform', 'install-workspace')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
711
frontend-modern/src/utils/__tests__/workloads.coverage.test.ts
Normal file
711
frontend-modern/src/utils/__tests__/workloads.coverage.test.ts
Normal file
|
|
@ -0,0 +1,711 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildAppContainerMetadataId,
|
||||
getWorkloadPlatformScopes,
|
||||
hasDiscoverySupportForWorkload,
|
||||
isContainerWorkloadType,
|
||||
isContainerWorkloadViewMode,
|
||||
resolveDiscoveryTargetForWorkload,
|
||||
workloadMatchesPlatformScope,
|
||||
workloadMatchesViewMode,
|
||||
} from '@/utils/workloads';
|
||||
import type { ViewMode, WorkloadGuest, WorkloadType } from '@/types/workloads';
|
||||
import type { ResourceDiscoveryTarget } from '@/types/resource';
|
||||
|
||||
// `name` is a required string on WorkloadGuest, but buildAppContainerMetadataId
|
||||
// tolerates null/undefined at runtime (`(value || '').trim()`). This alias lets
|
||||
// the malformed-input cases opt out of the contract intentionally.
|
||||
type AppContainerMetadataIdInput = Pick<WorkloadGuest, 'dockerHostId' | 'name'>;
|
||||
|
||||
|
||||
describe('buildAppContainerMetadataId', () => {
|
||||
it('builds a host-and-name id in the canonical app-container format', () => {
|
||||
expect(
|
||||
buildAppContainerMetadataId({ dockerHostId: 'docker-main', name: 'grafana' }),
|
||||
).toBe('app-container:docker-main:name:grafana');
|
||||
});
|
||||
|
||||
it('strips leading slashes from the container name', () => {
|
||||
expect(
|
||||
buildAppContainerMetadataId({ dockerHostId: 'docker-main', name: '/grafana' }),
|
||||
).toBe('app-container:docker-main:name:grafana');
|
||||
});
|
||||
|
||||
it('strips multiple leading slashes from the container name', () => {
|
||||
expect(
|
||||
buildAppContainerMetadataId({ dockerHostId: 'docker-main', name: '///traefik' }),
|
||||
).toBe('app-container:docker-main:name:traefik');
|
||||
});
|
||||
|
||||
it('preserves internal slashes in the container name', () => {
|
||||
expect(
|
||||
buildAppContainerMetadataId({ dockerHostId: 'docker-main', name: '/stack/app-1' }),
|
||||
).toBe('app-container:docker-main:name:stack/app-1');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace from both parts', () => {
|
||||
expect(
|
||||
buildAppContainerMetadataId({ dockerHostId: ' docker-main ', name: ' grafana ' }),
|
||||
).toBe('app-container:docker-main:name:grafana');
|
||||
});
|
||||
|
||||
it('returns null when the docker host id is missing', () => {
|
||||
expect(buildAppContainerMetadataId({ dockerHostId: undefined, name: 'grafana' })).toBeNull();
|
||||
expect(
|
||||
buildAppContainerMetadataId({
|
||||
dockerHostId: null,
|
||||
name: 'grafana',
|
||||
} as unknown as AppContainerMetadataIdInput),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the docker host id is empty or whitespace', () => {
|
||||
expect(buildAppContainerMetadataId({ dockerHostId: '', name: 'grafana' })).toBeNull();
|
||||
expect(buildAppContainerMetadataId({ dockerHostId: ' ', name: 'grafana' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the container name is missing', () => {
|
||||
expect(
|
||||
buildAppContainerMetadataId({
|
||||
dockerHostId: 'docker-main',
|
||||
name: undefined,
|
||||
} as unknown as AppContainerMetadataIdInput),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildAppContainerMetadataId({
|
||||
dockerHostId: 'docker-main',
|
||||
name: null,
|
||||
} as unknown as AppContainerMetadataIdInput),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the container name is empty or whitespace', () => {
|
||||
expect(buildAppContainerMetadataId({ dockerHostId: 'docker-main', name: '' })).toBeNull();
|
||||
expect(buildAppContainerMetadataId({ dockerHostId: 'docker-main', name: ' ' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the container name is only leading slashes', () => {
|
||||
expect(buildAppContainerMetadataId({ dockerHostId: 'docker-main', name: '//' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkloadPlatformScopes', () => {
|
||||
it('normalizes and deduplicates explicit platform scopes', () => {
|
||||
expect(
|
||||
getWorkloadPlatformScopes({
|
||||
platformScopes: ['docker', 'docker', 'k8s'],
|
||||
platformType: 'proxmox-pve',
|
||||
}),
|
||||
).toEqual(['docker', 'kubernetes']);
|
||||
});
|
||||
|
||||
it('lowercases and trims scope values', () => {
|
||||
expect(
|
||||
getWorkloadPlatformScopes({
|
||||
platformScopes: [' Docker ', 'Proxmox-PVE'],
|
||||
platformType: undefined,
|
||||
}),
|
||||
).toEqual(['docker', 'proxmox-pve']);
|
||||
});
|
||||
|
||||
it('resolves known aliases to their canonical platform id', () => {
|
||||
expect(
|
||||
getWorkloadPlatformScopes({
|
||||
platformScopes: ['pve', 'vmware', 'hyper-v'],
|
||||
platformType: undefined,
|
||||
}),
|
||||
).toEqual(['proxmox-pve', 'vmware-vsphere', 'microsoft-hyperv']);
|
||||
});
|
||||
|
||||
it('drops the all sentinel and empty scope values', () => {
|
||||
expect(
|
||||
getWorkloadPlatformScopes({
|
||||
platformScopes: ['all', '', ' ', 'docker'],
|
||||
platformType: undefined,
|
||||
}),
|
||||
).toEqual(['docker']);
|
||||
});
|
||||
|
||||
it('falls back to the platform type when no scopes are provided', () => {
|
||||
expect(getWorkloadPlatformScopes({ platformScopes: undefined, platformType: 'docker' })).toEqual(
|
||||
['docker'],
|
||||
);
|
||||
expect(getWorkloadPlatformScopes({ platformScopes: [], platformType: 'K8s' })).toEqual([
|
||||
'kubernetes',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array when neither scopes nor platform type resolve', () => {
|
||||
expect(getWorkloadPlatformScopes({ platformScopes: undefined, platformType: undefined })).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(getWorkloadPlatformScopes({ platformScopes: ['all'], platformType: '' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not fall back to platform type when at least one scope resolves', () => {
|
||||
expect(
|
||||
getWorkloadPlatformScopes({
|
||||
platformScopes: ['docker'],
|
||||
platformType: 'proxmox-pve',
|
||||
}),
|
||||
).toEqual(['docker']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workloadMatchesPlatformScope', () => {
|
||||
it('matches a workload scope verbatim', () => {
|
||||
expect(
|
||||
workloadMatchesPlatformScope({ platformScopes: ['docker'], platformType: undefined }, 'docker'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('matches a query alias to a canonical scope stored on the guest', () => {
|
||||
expect(
|
||||
workloadMatchesPlatformScope(
|
||||
{ platformScopes: ['kubernetes'], platformType: undefined },
|
||||
'k8s',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
workloadMatchesPlatformScope(
|
||||
{ platformScopes: ['proxmox-pve'], platformType: undefined },
|
||||
'pve',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('matches case-insensitively', () => {
|
||||
expect(
|
||||
workloadMatchesPlatformScope({ platformScopes: ['Docker'], platformType: undefined }, 'DOCKER'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the scope is not part of the workload scopes', () => {
|
||||
expect(
|
||||
workloadMatchesPlatformScope(
|
||||
{ platformScopes: ['docker'], platformType: undefined },
|
||||
'proxmox-pve',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when the scope is undefined, null, empty, or whitespace', () => {
|
||||
const guest = { platformScopes: ['docker'], platformType: undefined };
|
||||
expect(workloadMatchesPlatformScope(guest, undefined)).toBe(true);
|
||||
expect(workloadMatchesPlatformScope(guest, null)).toBe(true);
|
||||
expect(workloadMatchesPlatformScope(guest, '')).toBe(true);
|
||||
expect(workloadMatchesPlatformScope(guest, ' ')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when the scope is the all sentinel (case-insensitive)', () => {
|
||||
const guest = { platformScopes: ['docker'], platformType: undefined };
|
||||
expect(workloadMatchesPlatformScope(guest, 'all')).toBe(true);
|
||||
expect(workloadMatchesPlatformScope(guest, 'ALL')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches against the platform type fallback when scopes are absent', () => {
|
||||
expect(
|
||||
workloadMatchesPlatformScope({ platformScopes: undefined, platformType: 'docker' }, 'docker'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isContainerWorkloadType', () => {
|
||||
it('returns true for system-container and app-container', () => {
|
||||
expect(isContainerWorkloadType('system-container')).toBe(true);
|
||||
expect(isContainerWorkloadType('app-container')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for vm and pod', () => {
|
||||
expect(isContainerWorkloadType('vm')).toBe(false);
|
||||
expect(isContainerWorkloadType('pod')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for unknown workload type values', () => {
|
||||
expect(isContainerWorkloadType('unknown' as WorkloadType)).toBe(false);
|
||||
expect(isContainerWorkloadType('container' as WorkloadType)).toBe(false);
|
||||
expect(isContainerWorkloadType('' as WorkloadType)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isContainerWorkloadViewMode', () => {
|
||||
it('returns true for the container, system-container, and app-container view modes', () => {
|
||||
expect(isContainerWorkloadViewMode('container')).toBe(true);
|
||||
expect(isContainerWorkloadViewMode('system-container')).toBe(true);
|
||||
expect(isContainerWorkloadViewMode('app-container')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for all, vm, and pod view modes', () => {
|
||||
expect(isContainerWorkloadViewMode('all')).toBe(false);
|
||||
expect(isContainerWorkloadViewMode('vm')).toBe(false);
|
||||
expect(isContainerWorkloadViewMode('pod')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for unknown view mode values', () => {
|
||||
expect(isContainerWorkloadViewMode('docker' as ViewMode)).toBe(false);
|
||||
expect(isContainerWorkloadViewMode('' as ViewMode)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workloadMatchesViewMode', () => {
|
||||
it('returns true for every workload type when view mode is all', () => {
|
||||
const types: WorkloadType[] = ['vm', 'system-container', 'app-container', 'pod'];
|
||||
for (const workloadType of types) {
|
||||
expect(workloadMatchesViewMode(workloadType, 'all')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('matches both system-container and app-container against the container view mode', () => {
|
||||
expect(workloadMatchesViewMode('system-container', 'container')).toBe(true);
|
||||
expect(workloadMatchesViewMode('app-container', 'container')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match vm or pod against the container view mode', () => {
|
||||
expect(workloadMatchesViewMode('vm', 'container')).toBe(false);
|
||||
expect(workloadMatchesViewMode('pod', 'container')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches a workload type only against its own exact view mode', () => {
|
||||
expect(workloadMatchesViewMode('vm', 'vm')).toBe(true);
|
||||
expect(workloadMatchesViewMode('system-container', 'system-container')).toBe(true);
|
||||
expect(workloadMatchesViewMode('app-container', 'app-container')).toBe(true);
|
||||
expect(workloadMatchesViewMode('pod', 'pod')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match a workload type against a different specific view mode', () => {
|
||||
expect(workloadMatchesViewMode('vm', 'system-container')).toBe(false);
|
||||
expect(workloadMatchesViewMode('system-container', 'vm')).toBe(false);
|
||||
expect(workloadMatchesViewMode('app-container', 'pod')).toBe(false);
|
||||
expect(workloadMatchesViewMode('pod', 'app-container')).toBe(false);
|
||||
expect(workloadMatchesViewMode('pod', 'system-container')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDiscoveryTargetForWorkload', () => {
|
||||
describe('explicit discoveryTarget branch', () => {
|
||||
it('returns a fully-resolved target with canonical resource type and preserved hostname', () => {
|
||||
const guest = {
|
||||
id: 'vm:pve1:101',
|
||||
instance: 'cluster-a',
|
||||
workloadType: 'vm' as const,
|
||||
type: 'vm',
|
||||
node: 'pve1',
|
||||
vmid: 101,
|
||||
discoveryTarget: {
|
||||
resourceType: 'vm' as const,
|
||||
agentId: 'agent-pve1',
|
||||
resourceId: '101',
|
||||
hostname: 'pve1.local',
|
||||
},
|
||||
};
|
||||
expect(resolveDiscoveryTargetForWorkload(guest)).toEqual({
|
||||
resourceType: 'vm',
|
||||
agentId: 'agent-pve1',
|
||||
resourceId: '101',
|
||||
hostname: 'pve1.local',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves an undefined hostname when not supplied on the explicit target', () => {
|
||||
const guest = {
|
||||
id: 'vm:pve1:101',
|
||||
instance: 'cluster-a',
|
||||
workloadType: 'vm' as const,
|
||||
type: 'vm',
|
||||
node: 'pve1',
|
||||
vmid: 101,
|
||||
discoveryTarget: {
|
||||
resourceType: 'vm' as const,
|
||||
agentId: 'agent-pve1',
|
||||
resourceId: '101',
|
||||
},
|
||||
};
|
||||
expect(resolveDiscoveryTargetForWorkload(guest)).toEqual({
|
||||
resourceType: 'vm',
|
||||
agentId: 'agent-pve1',
|
||||
resourceId: '101',
|
||||
hostname: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('trims whitespace from explicit agentId and resourceId', () => {
|
||||
const guest = {
|
||||
id: 'vm:pve1:101',
|
||||
instance: 'cluster-a',
|
||||
workloadType: 'vm' as const,
|
||||
type: 'vm',
|
||||
node: 'pve1',
|
||||
vmid: 101,
|
||||
discoveryTarget: {
|
||||
resourceType: 'vm' as const,
|
||||
agentId: ' agent-pve1 ',
|
||||
resourceId: '\n101\n',
|
||||
},
|
||||
};
|
||||
expect(resolveDiscoveryTargetForWorkload(guest)).toEqual({
|
||||
resourceType: 'vm',
|
||||
agentId: 'agent-pve1',
|
||||
resourceId: '101',
|
||||
hostname: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('canonicalizes an aliased resource type on the explicit target', () => {
|
||||
const guest = {
|
||||
id: 'app:1',
|
||||
instance: 'cluster-a',
|
||||
workloadType: 'vm' as const,
|
||||
type: 'vm',
|
||||
node: 'pve1',
|
||||
vmid: 101,
|
||||
discoveryTarget: {
|
||||
resourceType: 'docker',
|
||||
agentId: 'agent-1',
|
||||
resourceId: 'container-1',
|
||||
} as unknown as ResourceDiscoveryTarget,
|
||||
};
|
||||
expect(resolveDiscoveryTargetForWorkload(guest)?.resourceType).toBe('app-container');
|
||||
});
|
||||
|
||||
it('falls through when the explicit target has a whitespace-only resource type', () => {
|
||||
const guest = {
|
||||
id: 'vm:pve1:101',
|
||||
instance: 'cluster-a',
|
||||
workloadType: 'vm' as const,
|
||||
type: 'vm',
|
||||
node: 'pve1',
|
||||
vmid: 101,
|
||||
discoveryTarget: {
|
||||
resourceType: ' ',
|
||||
agentId: 'agent-pve1',
|
||||
resourceId: '101',
|
||||
} as unknown as ResourceDiscoveryTarget,
|
||||
};
|
||||
expect(resolveDiscoveryTargetForWorkload(guest)).toBeNull();
|
||||
});
|
||||
|
||||
it('falls through when the explicit target has a whitespace-only agentId', () => {
|
||||
const guest = {
|
||||
id: 'vm:pve1:101',
|
||||
instance: 'cluster-a',
|
||||
workloadType: 'vm' as const,
|
||||
type: 'vm',
|
||||
node: 'pve1',
|
||||
vmid: 101,
|
||||
discoveryTarget: {
|
||||
resourceType: 'vm' as const,
|
||||
agentId: ' ',
|
||||
resourceId: '101',
|
||||
},
|
||||
};
|
||||
expect(resolveDiscoveryTargetForWorkload(guest)).toBeNull();
|
||||
});
|
||||
|
||||
it('falls through when the explicit target has a whitespace-only resourceId', () => {
|
||||
const guest = {
|
||||
id: 'vm:pve1:101',
|
||||
instance: 'cluster-a',
|
||||
workloadType: 'vm' as const,
|
||||
type: 'vm',
|
||||
node: 'pve1',
|
||||
vmid: 101,
|
||||
discoveryTarget: {
|
||||
resourceType: 'vm' as const,
|
||||
agentId: 'agent-pve1',
|
||||
resourceId: '',
|
||||
},
|
||||
};
|
||||
expect(resolveDiscoveryTargetForWorkload(guest)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('app-container branch', () => {
|
||||
const dockerGuest = {
|
||||
id: 'app-container:docker-host-1:grafana',
|
||||
workloadType: 'app-container' as const,
|
||||
type: 'docker',
|
||||
platformType: 'docker',
|
||||
dockerHostId: 'docker-host-1',
|
||||
containerId: 'container-grafana-abc',
|
||||
instance: 'docker-host-1',
|
||||
node: 'docker-host-1',
|
||||
vmid: 0,
|
||||
} as const;
|
||||
|
||||
it('builds an app-container target from the docker host id and container id', () => {
|
||||
expect(resolveDiscoveryTargetForWorkload(dockerGuest)).toEqual({
|
||||
resourceType: 'app-container',
|
||||
agentId: 'docker-host-1',
|
||||
resourceId: 'container-grafana-abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for a TrueNAS app-container (not docker-managed)', () => {
|
||||
const guest = {
|
||||
id: 'app-container:truenas-main:nextcloud',
|
||||
workloadType: 'app-container' as const,
|
||||
type: 'app-container',
|
||||
platformType: 'truenas',
|
||||
dockerHostId: 'truenas-main',
|
||||
containerId: 'nextcloud',
|
||||
instance: 'truenas-main',
|
||||
node: 'truenas-main',
|
||||
vmid: 0,
|
||||
};
|
||||
expect(resolveDiscoveryTargetForWorkload(guest)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the docker host id is missing', () => {
|
||||
expect(
|
||||
resolveDiscoveryTargetForWorkload({ ...dockerGuest, dockerHostId: '' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveDiscoveryTargetForWorkload({ ...dockerGuest, dockerHostId: ' ' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the container id is missing', () => {
|
||||
expect(
|
||||
resolveDiscoveryTargetForWorkload({ ...dockerGuest, containerId: '' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveDiscoveryTargetForWorkload({ ...dockerGuest, containerId: ' ' }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pod branch', () => {
|
||||
it('builds a pod target from the kubernetes agent id and the pod uid parsed from the id', () => {
|
||||
const guest = {
|
||||
id: 'k8s:cluster-a:pod:pod-uid-1',
|
||||
workloadType: 'pod' as const,
|
||||
type: 'k8s',
|
||||
kubernetesAgentId: 'k8s-agent-1',
|
||||
instance: 'cluster-a',
|
||||
node: 'cluster-a',
|
||||
vmid: 0,
|
||||
};
|
||||
expect(resolveDiscoveryTargetForWorkload(guest)).toEqual({
|
||||
resourceType: 'pod',
|
||||
agentId: 'k8s-agent-1',
|
||||
resourceId: 'pod-uid-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to instance then node for the agent id', () => {
|
||||
expect(
|
||||
resolveDiscoveryTargetForWorkload({
|
||||
id: 'k8s:cluster-a:pod:pod-uid-2',
|
||||
workloadType: 'pod' as const,
|
||||
type: 'pod',
|
||||
instance: 'cluster-a',
|
||||
node: '',
|
||||
vmid: 0,
|
||||
})?.agentId,
|
||||
).toBe('cluster-a');
|
||||
|
||||
expect(
|
||||
resolveDiscoveryTargetForWorkload({
|
||||
id: 'k8s:cluster-a:pod:pod-uid-3',
|
||||
workloadType: 'pod' as const,
|
||||
type: 'pod',
|
||||
instance: '',
|
||||
node: 'worker-1',
|
||||
vmid: 0,
|
||||
})?.agentId,
|
||||
).toBe('worker-1');
|
||||
});
|
||||
|
||||
it('uses the raw id when it does not match the k8s pod id shape', () => {
|
||||
const guest = {
|
||||
id: 'pod-456',
|
||||
workloadType: 'pod' as const,
|
||||
type: 'pod',
|
||||
kubernetesAgentId: 'k8s-agent-1',
|
||||
instance: 'cluster-a',
|
||||
node: 'cluster-a',
|
||||
vmid: 0,
|
||||
};
|
||||
expect(resolveDiscoveryTargetForWorkload(guest)?.resourceId).toBe('pod-456');
|
||||
});
|
||||
|
||||
it('returns null when no agent id source resolves', () => {
|
||||
const guest = {
|
||||
id: 'k8s:cluster-a:pod:pod-uid-1',
|
||||
workloadType: 'pod' as const,
|
||||
type: 'pod',
|
||||
kubernetesAgentId: ' ',
|
||||
instance: '',
|
||||
node: '',
|
||||
vmid: 0,
|
||||
};
|
||||
expect(resolveDiscoveryTargetForWorkload(guest)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the id is missing', () => {
|
||||
const guest = {
|
||||
id: '',
|
||||
workloadType: 'pod' as const,
|
||||
type: 'pod',
|
||||
kubernetesAgentId: 'k8s-agent-1',
|
||||
instance: 'cluster-a',
|
||||
node: 'cluster-a',
|
||||
vmid: 0,
|
||||
};
|
||||
expect(resolveDiscoveryTargetForWorkload(guest)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('default branch', () => {
|
||||
it('returns null for a vm without an explicit discovery target', () => {
|
||||
const guest = {
|
||||
id: 'vm:pve1:101',
|
||||
workloadType: 'vm' as const,
|
||||
type: 'vm',
|
||||
instance: 'cluster-a',
|
||||
node: 'pve1',
|
||||
vmid: 101,
|
||||
};
|
||||
expect(resolveDiscoveryTargetForWorkload(guest)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a system-container without an explicit discovery target', () => {
|
||||
const guest = {
|
||||
id: 'lxc:pve1:200',
|
||||
workloadType: 'system-container' as const,
|
||||
type: 'lxc',
|
||||
instance: 'cluster-a',
|
||||
node: 'pve1',
|
||||
vmid: 200,
|
||||
};
|
||||
expect(resolveDiscoveryTargetForWorkload(guest)).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers an explicit discovery target over the resolved workload type', () => {
|
||||
// This app-container would otherwise resolve via the type-based branch,
|
||||
// but the explicit target takes precedence.
|
||||
const guest = {
|
||||
id: 'app-container:docker-host-1:grafana',
|
||||
workloadType: 'app-container' as const,
|
||||
type: 'docker',
|
||||
platformType: 'docker',
|
||||
dockerHostId: 'docker-host-1',
|
||||
containerId: 'container-grafana-abc',
|
||||
instance: 'docker-host-1',
|
||||
node: 'docker-host-1',
|
||||
vmid: 0,
|
||||
discoveryTarget: {
|
||||
resourceType: 'app-container' as const,
|
||||
agentId: 'override-agent',
|
||||
resourceId: 'override-id',
|
||||
},
|
||||
};
|
||||
expect(resolveDiscoveryTargetForWorkload(guest)?.agentId).toBe('override-agent');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasDiscoverySupportForWorkload', () => {
|
||||
it('returns true when an explicit discovery target resolves', () => {
|
||||
const guest = {
|
||||
id: 'vm:pve1:101',
|
||||
workloadType: 'vm' as const,
|
||||
type: 'vm',
|
||||
instance: 'cluster-a',
|
||||
node: 'pve1',
|
||||
vmid: 101,
|
||||
discoveryTarget: {
|
||||
resourceType: 'vm' as const,
|
||||
agentId: 'agent-pve1',
|
||||
resourceId: '101',
|
||||
},
|
||||
};
|
||||
expect(hasDiscoverySupportForWorkload(guest)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a docker-managed app-container with host and container ids', () => {
|
||||
const guest = {
|
||||
id: 'app-container:docker-host-1:grafana',
|
||||
workloadType: 'app-container' as const,
|
||||
type: 'docker',
|
||||
platformType: 'docker',
|
||||
dockerHostId: 'docker-host-1',
|
||||
containerId: 'container-grafana-abc',
|
||||
instance: 'docker-host-1',
|
||||
node: 'docker-host-1',
|
||||
vmid: 0,
|
||||
};
|
||||
expect(hasDiscoverySupportForWorkload(guest)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a pod with a kubernetes agent id', () => {
|
||||
const guest = {
|
||||
id: 'k8s:cluster-a:pod:pod-uid-1',
|
||||
workloadType: 'pod' as const,
|
||||
type: 'k8s',
|
||||
kubernetesAgentId: 'k8s-agent-1',
|
||||
instance: 'cluster-a',
|
||||
node: 'cluster-a',
|
||||
vmid: 0,
|
||||
};
|
||||
expect(hasDiscoverySupportForWorkload(guest)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a vm without an explicit discovery target', () => {
|
||||
const guest = {
|
||||
id: 'vm:pve1:101',
|
||||
workloadType: 'vm' as const,
|
||||
type: 'vm',
|
||||
instance: 'cluster-a',
|
||||
node: 'pve1',
|
||||
vmid: 101,
|
||||
};
|
||||
expect(hasDiscoverySupportForWorkload(guest)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a TrueNAS app-container', () => {
|
||||
const guest = {
|
||||
id: 'app-container:truenas-main:nextcloud',
|
||||
workloadType: 'app-container' as const,
|
||||
type: 'app-container',
|
||||
platformType: 'truenas',
|
||||
dockerHostId: 'truenas-main',
|
||||
containerId: 'nextcloud',
|
||||
instance: 'truenas-main',
|
||||
node: 'truenas-main',
|
||||
vmid: 0,
|
||||
};
|
||||
expect(hasDiscoverySupportForWorkload(guest)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a pod missing every agent id source', () => {
|
||||
const guest = {
|
||||
id: 'k8s:cluster-a:pod:pod-uid-1',
|
||||
workloadType: 'pod' as const,
|
||||
type: 'pod',
|
||||
kubernetesAgentId: '',
|
||||
instance: '',
|
||||
node: '',
|
||||
vmid: 0,
|
||||
};
|
||||
expect(hasDiscoverySupportForWorkload(guest)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when an explicit discovery target is missing required parts', () => {
|
||||
const guest = {
|
||||
id: 'vm:pve1:101',
|
||||
workloadType: 'vm' as const,
|
||||
type: 'vm',
|
||||
instance: 'cluster-a',
|
||||
node: 'pve1',
|
||||
vmid: 101,
|
||||
discoveryTarget: {
|
||||
resourceType: 'vm' as const,
|
||||
agentId: '',
|
||||
resourceId: '101',
|
||||
},
|
||||
};
|
||||
expect(hasDiscoverySupportForWorkload(guest)).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue