diff --git a/frontend-modern/src/api/__tests__/responseUtils.coverage.test.ts b/frontend-modern/src/api/__tests__/responseUtils.coverage.test.ts new file mode 100644 index 0000000000..4926b52e6b --- /dev/null +++ b/frontend-modern/src/api/__tests__/responseUtils.coverage.test.ts @@ -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[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([]); + }); + }); +}); diff --git a/frontend-modern/src/content/help/__tests__/index.test.ts b/frontend-modern/src/content/help/__tests__/index.test.ts new file mode 100644 index 0000000000..5aa7aeab57 --- /dev/null +++ b/frontend-modern/src/content/help/__tests__/index.test.ts @@ -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); + } + } + }); + }); +}); diff --git a/frontend-modern/src/features/alerts/__tests__/helpers.coverage.test.ts b/frontend-modern/src/features/alerts/__tests__/helpers.coverage.test.ts new file mode 100644 index 0000000000..291fc1ad7a --- /dev/null +++ b/frontend-modern/src/features/alerts/__tests__/helpers.coverage.test.ts @@ -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 => + ({ 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({}); + }); + }); +}); diff --git a/frontend-modern/src/features/storageBackups/__tests__/storageMetricsIdentity.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storageMetricsIdentity.test.ts new file mode 100644 index 0000000000..7f7238d1f7 --- /dev/null +++ b/frontend-modern/src/features/storageBackups/__tests__/storageMetricsIdentity.test.ts @@ -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 => ({ + 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 => + ({ + 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), + ), + ).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), + ), + ).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), + ), + ).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'); + }); +}); diff --git a/frontend-modern/src/features/storageBackups/__tests__/storageSearchQuery.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storageSearchQuery.test.ts new file mode 100644 index 0000000000..3375b6c11e --- /dev/null +++ b/frontend-modern/src/features/storageBackups/__tests__/storageSearchQuery.test.ts @@ -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); + }); +}); diff --git a/frontend-modern/src/stores/__tests__/sessionCapabilities.test.ts b/frontend-modern/src/stores/__tests__/sessionCapabilities.test.ts new file mode 100644 index 0000000000..e1c9a158e7 --- /dev/null +++ b/frontend-modern/src/stores/__tests__/sessionCapabilities.test.ts @@ -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[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'); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/agentResources.coverage.test.ts b/frontend-modern/src/utils/__tests__/agentResources.coverage.test.ts new file mode 100644 index 0000000000..a90b3ead61 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/agentResources.coverage.test.ts @@ -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 => + ({ + 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 }), + ), + ).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); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/alerts.coverage.test.ts b/frontend-modern/src/utils/__tests__/alerts.coverage.test.ts new file mode 100644 index 0000000000..e436d666cd --- /dev/null +++ b/frontend-modern/src/utils/__tests__/alerts.coverage.test.ts @@ -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)); + } + }); +}); diff --git a/frontend-modern/src/utils/__tests__/cloudPlans.coverage.test.ts b/frontend-modern/src/utils/__tests__/cloudPlans.coverage.test.ts new file mode 100644 index 0000000000..46b28e0297 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/cloudPlans.coverage.test.ts @@ -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_ 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); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/format.coverage.test.ts b/frontend-modern/src/utils/__tests__/format.coverage.test.ts new file mode 100644 index 0000000000..7022563237 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/format.coverage.test.ts @@ -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']); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/platformSupportManifest.test.ts b/frontend-modern/src/utils/__tests__/platformSupportManifest.test.ts new file mode 100644 index 0000000000..e4f565be5c --- /dev/null +++ b/frontend-modern/src/utils/__tests__/platformSupportManifest.test.ts @@ -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, + ); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/workloads.coverage.test.ts b/frontend-modern/src/utils/__tests__/workloads.coverage.test.ts new file mode 100644 index 0000000000..356cda91a4 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/workloads.coverage.test.ts @@ -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; + + +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); + }); +});