mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-15 16:42:15 +00:00
Add branch-coverage tests for four alert and settings presentation modules
These presentation helpers had partial coverage and had grown functions their existing tests never reached. alertWebhookPresentation goes from 14 to 23 covered functions, organizationSettingsPresentation from 34 to 42, alertIncidentPresentation from 37 to 39 with its remaining branches, and ssoProviderPresentation closes its last four uncovered branches. The new cases exercise the fallback, boundary, per-role and error-precedence arms the primary tests omit. Four new test-only files with distinct branchcov0717 suffixes alongside the existing suites. No source changed. Verified with vitest and type-check, and each measured to raise branch or function coverage over the pre-existing suite.
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getAlertHistoryStatusPresentation,
|
||||
getAlertIncidentEventFilterActionButtonClass,
|
||||
getAlertIncidentEventFilterChipClass,
|
||||
getAlertIncidentEventFilterContainerClass,
|
||||
getAlertIncidentEventFilterLabelClass,
|
||||
getAlertIncidentLevelBadgeClass,
|
||||
getAlertIncidentNoteSaveButtonClass,
|
||||
getAlertIncidentNoteTextareaClass,
|
||||
getAlertIncidentStatusPresentation,
|
||||
getAlertIncidentTimelineCommandClass,
|
||||
getAlertIncidentTimelineDetailClass,
|
||||
getAlertIncidentTimelineEventCardClass,
|
||||
getAlertIncidentTimelineHeadingClass,
|
||||
getAlertIncidentTimelineMetaRowClass,
|
||||
getAlertIncidentTimelineOutputClass,
|
||||
getAlertResourceIncidentAcknowledgedByLabel,
|
||||
getAlertIncidentAcknowledgedBadgeClass,
|
||||
getAlertResourceIncidentActivityChipClass,
|
||||
getAlertResourceIncidentActivitySummaryClass,
|
||||
getAlertResourceIncidentCardClass,
|
||||
getAlertResourceIncidentCountLabel,
|
||||
getAlertResourceIncidentEmptyState,
|
||||
getAlertResourceIncidentFilteredEventsEmptyState,
|
||||
getAlertResourceIncidentLoadFailure,
|
||||
getAlertResourceIncidentLoadingState,
|
||||
getAlertResourceIncidentNotePlaceholder,
|
||||
getAlertResourceIncidentNoteSaveFailure,
|
||||
getAlertResourceIncidentNoteSavedLabel,
|
||||
getAlertResourceIncidentPanelTitle,
|
||||
getAlertResourceIncidentRecentEventsSummary,
|
||||
getAlertResourceIncidentRefreshLabel,
|
||||
getAlertResourceIncidentSaveNoteLabel,
|
||||
getAlertResourceIncidentSummaryRowClass,
|
||||
getAlertResourceIncidentTimelineFailure,
|
||||
getAlertResourceIncidentToggleButtonClass,
|
||||
getAlertResourceIncidentToggleLabel,
|
||||
getAlertResourceIncidentTruncatedEventsLabel,
|
||||
getAlertResourceIncidentViewTitle,
|
||||
normalizeAlertIncidentStatus,
|
||||
} from '@/utils/alertIncidentPresentation';
|
||||
|
||||
// Supplemental branch-coverage suite. The pre-existing branchcov suite already
|
||||
// exercises the happy paths; this file targets the nullish / empty / boundary /
|
||||
// falsy-guard / switch-default arms that those tests leave open. Every assertion
|
||||
// pins a concrete return value computed from the source so a regression flips it.
|
||||
|
||||
describe('normalizeAlertIncidentStatus — branch coverage', () => {
|
||||
it('coerces an undefined status through the ??-coalesce arm onto "unknown"', () => {
|
||||
// (undefined ?? '') -> '' -> trim/lower -> '' -> '' || 'unknown' -> 'unknown'.
|
||||
expect(normalizeAlertIncidentStatus(undefined)).toBe('unknown');
|
||||
// acknowledged flag is ignored once the normalized status is empty.
|
||||
expect(normalizeAlertIncidentStatus(undefined, true)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('coerces a null status through the ??-coalesce arm onto "unknown"', () => {
|
||||
expect(normalizeAlertIncidentStatus(null)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('treats a whitespace-only status as empty (trim -> "" -> `|| "unknown"`) ', () => {
|
||||
expect(normalizeAlertIncidentStatus(' ')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('reaches "acknowledged" via `normalized === "open" && acknowledged` true arm', () => {
|
||||
expect(normalizeAlertIncidentStatus('open', true)).toBe('acknowledged');
|
||||
});
|
||||
|
||||
it('honours the &&-short-circuit false arm: open + falsy acknowledged stays "open"', () => {
|
||||
// acknowledged === false -> `&&` yields false -> falls to the bare `=== 'open'` branch.
|
||||
expect(normalizeAlertIncidentStatus('open', false)).toBe('open');
|
||||
// acknowledged === undefined (omitted) is also falsy.
|
||||
expect(normalizeAlertIncidentStatus('open')).toBe('open');
|
||||
// A truthy-looking-but-falsy 0 also keeps the bare-open branch.
|
||||
expect(normalizeAlertIncidentStatus('open', 0 as unknown as boolean)).toBe('open');
|
||||
});
|
||||
|
||||
it('maps the "closed" alias onto "resolved" via the `||` second operand', () => {
|
||||
expect(normalizeAlertIncidentStatus('closed')).toBe('resolved');
|
||||
});
|
||||
|
||||
it('maps an exact "resolved" through the `||` first operand', () => {
|
||||
expect(normalizeAlertIncidentStatus('resolved')).toBe('resolved');
|
||||
});
|
||||
|
||||
it('returns the trimmed/lowercased token verbatim for an unrecognised status (truthy `||` left)', () => {
|
||||
// toLowerCase() arm: 'INVESTIGATING' -> 'investigating', which is truthy.
|
||||
expect(normalizeAlertIncidentStatus('INVESTIGATING')).toBe('investigating');
|
||||
});
|
||||
|
||||
it('normalises cased/whitespace-padded "open" before matching', () => {
|
||||
// Confirms trim().toLowerCase() runs before the `=== 'open'` comparison.
|
||||
expect(normalizeAlertIncidentStatus(' OPEN ', true)).toBe('acknowledged');
|
||||
expect(normalizeAlertIncidentStatus(' OPEN ', false)).toBe('open');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertIncidentStatusPresentation — switch branch coverage', () => {
|
||||
it('hits the "acknowledged" case directly and via the open+acknowledged normalisation', () => {
|
||||
const expected = {
|
||||
label: 'acknowledged',
|
||||
className:
|
||||
'px-2 py-0.5 rounded bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300',
|
||||
};
|
||||
expect(getAlertIncidentStatusPresentation('acknowledged')).toStrictEqual(expected);
|
||||
// open + acknowledged flag normalises to 'acknowledged' label -> same case.
|
||||
expect(getAlertIncidentStatusPresentation('open', true)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('hits the "open" case', () => {
|
||||
expect(getAlertIncidentStatusPresentation('open')).toStrictEqual({
|
||||
label: 'open',
|
||||
className:
|
||||
'px-2 py-0.5 rounded bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes "resolved" through the default case', () => {
|
||||
expect(getAlertIncidentStatusPresentation('resolved')).toStrictEqual({
|
||||
label: 'resolved',
|
||||
className: 'px-2 py-0.5 rounded bg-surface-hover text-base-content',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes a nullish status onto the default case with the "unknown" label', () => {
|
||||
expect(getAlertIncidentStatusPresentation(undefined)).toStrictEqual({
|
||||
label: 'unknown',
|
||||
className: 'px-2 py-0.5 rounded bg-surface-hover text-base-content',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes an unrecognised status onto the default case, echoing the normalised label', () => {
|
||||
expect(getAlertIncidentStatusPresentation('fizzing')).toStrictEqual({
|
||||
label: 'fizzing',
|
||||
className: 'px-2 py-0.5 rounded bg-surface-hover text-base-content',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertIncidentLevelBadgeClass — branch coverage', () => {
|
||||
it('returns the critical palette for level === "critical"', () => {
|
||||
expect(getAlertIncidentLevelBadgeClass('critical')).toBe(
|
||||
'px-2 py-0.5 rounded bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the warning palette for the canonical "warning" level', () => {
|
||||
expect(getAlertIncidentLevelBadgeClass('warning')).toBe(
|
||||
'px-2 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the warning palette for a null level', () => {
|
||||
expect(getAlertIncidentLevelBadgeClass(null)).toBe(
|
||||
'px-2 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the warning palette for an undefined level', () => {
|
||||
expect(getAlertIncidentLevelBadgeClass(undefined)).toBe(
|
||||
'px-2 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the warning palette for any non-"critical" string', () => {
|
||||
expect(getAlertIncidentLevelBadgeClass('info')).toBe(
|
||||
'px-2 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertHistoryStatusPresentation — branch coverage', () => {
|
||||
it('returns the active presentation for "active"', () => {
|
||||
expect(getAlertHistoryStatusPresentation('active')).toStrictEqual({
|
||||
label: 'active',
|
||||
className:
|
||||
'text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300 font-medium',
|
||||
rowClassName: 'bg-red-50 dark:bg-red-900',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalises a cased "ACTIVE" to the active branch (toLowerCase arm)', () => {
|
||||
expect(getAlertHistoryStatusPresentation('ACTIVE')).toStrictEqual(
|
||||
getAlertHistoryStatusPresentation('active'),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the acknowledged presentation with an empty rowClassName', () => {
|
||||
expect(getAlertHistoryStatusPresentation('acknowledged')).toStrictEqual({
|
||||
label: 'acknowledged',
|
||||
className:
|
||||
'text-xs px-2 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300',
|
||||
rowClassName: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the resolved presentation for "resolved" (falls through both guards)', () => {
|
||||
expect(getAlertHistoryStatusPresentation('resolved')).toStrictEqual({
|
||||
label: 'resolved',
|
||||
className: 'text-xs px-2 py-0.5 rounded bg-surface-hover text-base-content',
|
||||
rowClassName: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the truthy `||` left operand for an unrecognised non-empty status', () => {
|
||||
expect(getAlertHistoryStatusPresentation('suppressed')).toStrictEqual({
|
||||
label: 'suppressed',
|
||||
className: 'text-xs px-2 py-0.5 rounded bg-surface-hover text-base-content',
|
||||
rowClassName: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('substitutes "resolved" for an empty normalised status via the `|| "resolved"` fallback', () => {
|
||||
// undefined -> '' -> '' || 'resolved'.
|
||||
expect(getAlertHistoryStatusPresentation(undefined)).toStrictEqual({
|
||||
label: 'resolved',
|
||||
className: 'text-xs px-2 py-0.5 rounded bg-surface-hover text-base-content',
|
||||
rowClassName: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('substitutes "resolved" for a null status via the ??-coalesce arm then || fallback', () => {
|
||||
expect(getAlertHistoryStatusPresentation(null)).toStrictEqual({
|
||||
label: 'resolved',
|
||||
className: 'text-xs px-2 py-0.5 rounded bg-surface-hover text-base-content',
|
||||
rowClassName: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('substitutes "resolved" for a whitespace-only status (trim -> "" -> || fallback)', () => {
|
||||
expect(getAlertHistoryStatusPresentation(' ')).toStrictEqual({
|
||||
label: 'resolved',
|
||||
className: 'text-xs px-2 py-0.5 rounded bg-surface-hover text-base-content',
|
||||
rowClassName: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ternary label helpers — both arms', () => {
|
||||
it('getAlertResourceIncidentCountLabel pluralises on count === 1 boundary', () => {
|
||||
expect(getAlertResourceIncidentCountLabel(0)).toBe('0 incidents');
|
||||
expect(getAlertResourceIncidentCountLabel(1)).toBe('1 incident');
|
||||
expect(getAlertResourceIncidentCountLabel(2)).toBe('2 incidents');
|
||||
});
|
||||
|
||||
it('getAlertResourceIncidentRefreshLabel toggles on isLoading', () => {
|
||||
expect(getAlertResourceIncidentRefreshLabel(true)).toBe('Refreshing...');
|
||||
expect(getAlertResourceIncidentRefreshLabel(false)).toBe('Refresh');
|
||||
});
|
||||
|
||||
it('getAlertResourceIncidentToggleLabel toggles on isExpanded and interpolates filteredLabel', () => {
|
||||
expect(getAlertResourceIncidentToggleLabel(true, '3 of 5')).toBe('Hide events');
|
||||
expect(getAlertResourceIncidentToggleLabel(false, '3 of 5')).toBe('Events (3 of 5)');
|
||||
});
|
||||
|
||||
it('getAlertResourceIncidentSaveNoteLabel toggles on isSaving', () => {
|
||||
expect(getAlertResourceIncidentSaveNoteLabel(true)).toBe('Saving...');
|
||||
expect(getAlertResourceIncidentSaveNoteLabel(false)).toBe('Save Note');
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-branching label/state helpers — concrete value pins', () => {
|
||||
it('getAlertResourceIncidentAcknowledgedByLabel interpolates the user', () => {
|
||||
expect(getAlertResourceIncidentAcknowledgedByLabel('ada')).toBe('Acknowledged by ada');
|
||||
});
|
||||
|
||||
it('getAlertResourceIncidentRecentEventsSummary interpolates the count', () => {
|
||||
expect(getAlertResourceIncidentRecentEventsSummary(7)).toBe('Showing last 7 events');
|
||||
});
|
||||
|
||||
it('getAlertResourceIncidentLoadingState returns the loading const', () => {
|
||||
expect(getAlertResourceIncidentLoadingState()).toStrictEqual({
|
||||
text: 'Loading incidents...',
|
||||
});
|
||||
});
|
||||
|
||||
it('getAlertResourceIncidentEmptyState returns the empty const', () => {
|
||||
expect(getAlertResourceIncidentEmptyState()).toStrictEqual({
|
||||
text: 'No incidents recorded for this resource yet.',
|
||||
});
|
||||
});
|
||||
|
||||
it('getAlertResourceIncidentFilteredEventsEmptyState returns the filtered-empty const', () => {
|
||||
expect(getAlertResourceIncidentFilteredEventsEmptyState()).toStrictEqual({
|
||||
text: 'No events match the selected filters.',
|
||||
});
|
||||
});
|
||||
|
||||
it('getAlertResourceIncidentPanelTitle returns the panel title const', () => {
|
||||
expect(getAlertResourceIncidentPanelTitle()).toBe('Resource incidents');
|
||||
});
|
||||
|
||||
it('getAlertResourceIncidentNotePlaceholder returns the placeholder const', () => {
|
||||
expect(getAlertResourceIncidentNotePlaceholder()).toBe('Add a note for this incident...');
|
||||
});
|
||||
|
||||
it('getAlertResourceIncidentLoadFailure returns the load-failure const', () => {
|
||||
expect(getAlertResourceIncidentLoadFailure()).toBe('Failed to load resource incidents');
|
||||
});
|
||||
|
||||
it('getAlertResourceIncidentTimelineFailure returns the timeline-failure const', () => {
|
||||
expect(getAlertResourceIncidentTimelineFailure()).toBe('Failed to load incident timeline');
|
||||
});
|
||||
|
||||
it('getAlertResourceIncidentNoteSavedLabel returns the note-saved const', () => {
|
||||
expect(getAlertResourceIncidentNoteSavedLabel()).toBe('Incident note saved');
|
||||
});
|
||||
|
||||
it('getAlertResourceIncidentNoteSaveFailure returns the note-save-failure const', () => {
|
||||
expect(getAlertResourceIncidentNoteSaveFailure()).toBe('Failed to save incident note');
|
||||
});
|
||||
|
||||
it('getAlertResourceIncidentViewTitle returns the view title const', () => {
|
||||
expect(getAlertResourceIncidentViewTitle()).toBe('View incidents for this resource');
|
||||
});
|
||||
});
|
||||
|
||||
describe('event-filter class helpers — variant + selected branch coverage', () => {
|
||||
it('getAlertIncidentEventFilterContainerClass returns the panel layout for variant "panel"', () => {
|
||||
expect(getAlertIncidentEventFilterContainerClass('panel')).toBe(
|
||||
'flex flex-wrap items-center gap-1.5 rounded border border-border bg-surface-alt/50 p-2',
|
||||
);
|
||||
});
|
||||
|
||||
it('getAlertIncidentEventFilterContainerClass returns the compact layout for variant "compact"', () => {
|
||||
expect(getAlertIncidentEventFilterContainerClass('compact')).toBe(
|
||||
'flex flex-wrap items-center gap-2 text-[10px] text-muted',
|
||||
);
|
||||
});
|
||||
|
||||
it('getAlertIncidentEventFilterLabelClass returns the panel label class for variant "panel"', () => {
|
||||
expect(getAlertIncidentEventFilterLabelClass('panel')).toBe(
|
||||
'mr-1 text-xs font-medium text-muted',
|
||||
);
|
||||
});
|
||||
|
||||
it('getAlertIncidentEventFilterLabelClass returns the compact label class for variant "compact"', () => {
|
||||
expect(getAlertIncidentEventFilterLabelClass('compact')).toBe(
|
||||
'uppercase tracking-wide text-[9px] text-muted',
|
||||
);
|
||||
});
|
||||
|
||||
it('getAlertIncidentEventFilterActionButtonClass returns the constant action class', () => {
|
||||
expect(getAlertIncidentEventFilterActionButtonClass()).toBe(
|
||||
'px-2 py-0.5 rounded border border-border text-muted hover:bg-surface-hover',
|
||||
);
|
||||
});
|
||||
|
||||
it('getAlertIncidentEventFilterChipClass returns the selected (blue) chip regardless of variant', () => {
|
||||
const selected = 'px-2 py-0.5 rounded border text-[10px] transition-colors border-blue-300 bg-blue-100 text-blue-700 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-300';
|
||||
// selected short-circuits before the variant check, so both variants are equal.
|
||||
expect(getAlertIncidentEventFilterChipClass(true, 'compact')).toBe(selected);
|
||||
expect(getAlertIncidentEventFilterChipClass(true, 'panel')).toBe(selected);
|
||||
});
|
||||
|
||||
it('getAlertIncidentEventFilterChipClass returns the unselected panel chip (variant === "panel")', () => {
|
||||
expect(getAlertIncidentEventFilterChipClass(false, 'panel')).toBe(
|
||||
'px-2 py-0.5 rounded border text-[10px] transition-colors font-medium border-border text-muted hover:bg-surface-alt',
|
||||
);
|
||||
});
|
||||
|
||||
it('getAlertIncidentEventFilterChipClass returns the unselected compact chip (variant !== "panel")', () => {
|
||||
expect(getAlertIncidentEventFilterChipClass(false, 'compact')).toBe(
|
||||
'px-2 py-0.5 rounded border text-[10px] transition-colors border-border text-slate-500',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeline + card class helpers — branch coverage', () => {
|
||||
it('getAlertIncidentTimelineEventCardClass uses bg-surface-alt for variant "alt"', () => {
|
||||
expect(getAlertIncidentTimelineEventCardClass('alt')).toBe(
|
||||
'rounded border border-border bg-surface-alt p-2',
|
||||
);
|
||||
});
|
||||
|
||||
it('getAlertIncidentTimelineEventCardClass uses bg-surface for variant "surface"', () => {
|
||||
expect(getAlertIncidentTimelineEventCardClass('surface')).toBe(
|
||||
'rounded border border-border bg-surface p-2',
|
||||
);
|
||||
});
|
||||
|
||||
it('pins the remaining constant class helpers (regression guards)', () => {
|
||||
expect(getAlertIncidentAcknowledgedBadgeClass()).toBe(
|
||||
'px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
|
||||
);
|
||||
expect(getAlertIncidentNoteTextareaClass()).toBe(
|
||||
'w-full rounded border border-border bg-surface p-2 text-xs text-base-content',
|
||||
);
|
||||
expect(getAlertIncidentNoteSaveButtonClass()).toBe(
|
||||
'px-3 py-1.5 text-xs font-medium border rounded-md transition-all bg-surface text-base-content border-border hover:bg-surface-hover disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
);
|
||||
expect(getAlertIncidentTimelineMetaRowClass()).toBe(
|
||||
'flex flex-wrap items-center gap-2 text-xs text-muted',
|
||||
);
|
||||
expect(getAlertIncidentTimelineHeadingClass()).toBe('font-medium text-base-content');
|
||||
expect(getAlertIncidentTimelineDetailClass()).toBe('mt-1 text-xs text-base-content');
|
||||
expect(getAlertIncidentTimelineCommandClass()).toBe('mt-1 font-mono text-xs text-base-content');
|
||||
expect(getAlertIncidentTimelineOutputClass()).toBe('mt-1 text-xs text-muted');
|
||||
expect(getAlertResourceIncidentCardClass()).toBe('rounded border border-border bg-surface p-3');
|
||||
expect(getAlertResourceIncidentSummaryRowClass()).toBe(
|
||||
'mt-2 flex flex-wrap items-center justify-between gap-2 text-xs text-muted',
|
||||
);
|
||||
expect(getAlertResourceIncidentActivitySummaryClass()).toBe(
|
||||
'flex flex-wrap items-center gap-1.5',
|
||||
);
|
||||
expect(getAlertResourceIncidentActivityChipClass()).toBe(
|
||||
'rounded bg-surface-alt px-2 py-0.5 text-[10px] font-medium text-base-content',
|
||||
);
|
||||
expect(getAlertResourceIncidentToggleButtonClass()).toBe(
|
||||
'px-2 py-1 text-[10px] border rounded-md border-border text-muted hover:bg-surface-hover',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertResourceIncidentTruncatedEventsLabel — branch coverage', () => {
|
||||
it('returns the bare "Showing last N events" when totalCount is undefined (typeof false arm)', () => {
|
||||
expect(getAlertResourceIncidentTruncatedEventsLabel(5)).toBe('Showing last 5 events');
|
||||
});
|
||||
|
||||
it('returns the bare form when totalCount is a non-number type (typeof false arm)', () => {
|
||||
expect(
|
||||
getAlertResourceIncidentTruncatedEventsLabel(5, 'many' as unknown as number),
|
||||
).toBe('Showing last 5 events');
|
||||
});
|
||||
|
||||
it('returns "Showing N events" when totalCount is a number <= count (singular at 1)', () => {
|
||||
// totalCount <= count true arm; totalCount === 1 true arm -> 'event'.
|
||||
expect(getAlertResourceIncidentTruncatedEventsLabel(5, 1)).toBe('Showing 1 event');
|
||||
});
|
||||
|
||||
it('pluralises within the totalCount <= count branch when totalCount > 1', () => {
|
||||
// totalCount === 1 false arm -> 'events'.
|
||||
expect(getAlertResourceIncidentTruncatedEventsLabel(5, 3)).toBe('Showing 3 events');
|
||||
});
|
||||
|
||||
it('honours the <= boundary at equality (count === totalCount)', () => {
|
||||
expect(getAlertResourceIncidentTruncatedEventsLabel(5, 5)).toBe('Showing 5 events');
|
||||
});
|
||||
|
||||
it('returns "Showing last N events" for totalCount 0 (0 <= count true arm, plural)', () => {
|
||||
expect(getAlertResourceIncidentTruncatedEventsLabel(3, 0)).toBe('Showing 0 events');
|
||||
});
|
||||
|
||||
it('returns "Showing last count of totalCount events" when totalCount > count', () => {
|
||||
// totalCount <= count false arm.
|
||||
expect(getAlertResourceIncidentTruncatedEventsLabel(5, 12)).toBe(
|
||||
'Showing last 5 of 12 events',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getAlertWebhookCustomFieldInputs,
|
||||
getAlertWebhookMutationSuccess,
|
||||
getAlertWebhookNamePlaceholder,
|
||||
getAlertWebhookSetupInstructionsTitle,
|
||||
getAlertWebhookSubmitLabel,
|
||||
getAlertWebhookSummaryLabel,
|
||||
getAlertWebhookTestLabel,
|
||||
getAlertWebhookToggleAllLabel,
|
||||
getAlertWebhookToggleLabel,
|
||||
getAlertWebhookUrlPlaceholder,
|
||||
normalizeAlertWebhookCustomFields,
|
||||
} from '@/utils/alertWebhookPresentation';
|
||||
|
||||
// Supplemental branch coverage for alertWebhookPresentation.
|
||||
// Targets the simple getter functions (fn9-fn16, fn21) that were never invoked
|
||||
// by the existing tests, plus three previously-uncovered branches:
|
||||
// - b33: the `?? ''` default in getAlertWebhookCustomFieldInputs (preset key
|
||||
// absent from the normalized existing map).
|
||||
// - b39: the `&&` right operand / true arm of the pushover app_token -> token
|
||||
// migration in normalizeAlertWebhookCustomFields.
|
||||
// - b42: the analogous user_token -> user migration arm.
|
||||
// Each new function exercised below drives BOTH arms of every ternary, ||, and
|
||||
// switch it contains so that covering them never leaves a fresh uncovered arm.
|
||||
|
||||
describe('alertWebhookPresentation branch coverage (supplemental 0717)', () => {
|
||||
describe('getAlertWebhookSetupInstructionsTitle', () => {
|
||||
it('returns the canonical section title constant', () => {
|
||||
expect(getAlertWebhookSetupInstructionsTitle()).toBe('Setup Instructions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertWebhookNamePlaceholder', () => {
|
||||
it('echoes a provided template name (|| left operand)', () => {
|
||||
expect(getAlertWebhookNamePlaceholder('Discord Incidents')).toBe(
|
||||
'Discord Incidents',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the default placeholder when the name is undefined (|| right operand)', () => {
|
||||
expect(getAlertWebhookNamePlaceholder(undefined)).toBe('My Webhook');
|
||||
});
|
||||
|
||||
it('falls back to the default placeholder when the name is an empty string (falsy || arm)', () => {
|
||||
expect(getAlertWebhookNamePlaceholder('')).toBe('My Webhook');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertWebhookUrlPlaceholder', () => {
|
||||
it('echoes a provided url pattern (|| left operand)', () => {
|
||||
expect(
|
||||
getAlertWebhookUrlPlaceholder('https://discord.com/api/webhooks/123'),
|
||||
).toBe('https://discord.com/api/webhooks/123');
|
||||
});
|
||||
|
||||
it('falls back to the default URL placeholder when undefined (|| right operand)', () => {
|
||||
expect(getAlertWebhookUrlPlaceholder(undefined)).toBe(
|
||||
'https://example.com/webhook',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the default URL placeholder when the pattern is empty (falsy || arm)', () => {
|
||||
expect(getAlertWebhookUrlPlaceholder('')).toBe(
|
||||
'https://example.com/webhook',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertWebhookSummaryLabel', () => {
|
||||
it('interpolates both counts into the "<n> of <m> webhooks enabled" template', () => {
|
||||
expect(getAlertWebhookSummaryLabel(2, 5)).toBe('2 of 5 webhooks enabled');
|
||||
});
|
||||
|
||||
it('renders the zero-enabled boundary without any special casing', () => {
|
||||
expect(getAlertWebhookSummaryLabel(0, 3)).toBe('0 of 3 webhooks enabled');
|
||||
});
|
||||
|
||||
it('renders the all-enabled boundary verbatim', () => {
|
||||
expect(getAlertWebhookSummaryLabel(4, 4)).toBe('4 of 4 webhooks enabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertWebhookToggleAllLabel', () => {
|
||||
it('returns "Enable All" when the group is currently disabled (true arm)', () => {
|
||||
expect(getAlertWebhookToggleAllLabel(true)).toBe('Enable All');
|
||||
});
|
||||
|
||||
it('returns "Disable All" when the group is currently enabled (false arm)', () => {
|
||||
expect(getAlertWebhookToggleAllLabel(false)).toBe('Disable All');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertWebhookToggleLabel', () => {
|
||||
it('returns "Enabled" for an on webhook (true arm)', () => {
|
||||
expect(getAlertWebhookToggleLabel(true)).toBe('Enabled');
|
||||
});
|
||||
|
||||
it('returns "Disabled" for an off webhook (false arm)', () => {
|
||||
expect(getAlertWebhookToggleLabel(false)).toBe('Disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertWebhookTestLabel', () => {
|
||||
it('returns "Test" when not testing (early-return arm, !isTesting truthy)', () => {
|
||||
expect(getAlertWebhookTestLabel(false)).toBe('Test');
|
||||
});
|
||||
|
||||
it('returns "Test" even when ascii is requested but the button is idle (idle dominates ascii)', () => {
|
||||
expect(getAlertWebhookTestLabel(false, true)).toBe('Test');
|
||||
});
|
||||
|
||||
it('returns the Unicode ellipsis label while testing with the default ascii flag (false arm)', () => {
|
||||
// ALERT_WEBHOOK_TESTING_LABEL uses a single U+2026 character, distinct
|
||||
// from the ASCII three-dot variant asserted below.
|
||||
expect(getAlertWebhookTestLabel(true)).toBe('Testing…');
|
||||
});
|
||||
|
||||
it('returns the ASCII three-dot label while testing with ascii=true (true arm)', () => {
|
||||
expect(getAlertWebhookTestLabel(true, true)).toBe('Testing...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertWebhookSubmitLabel', () => {
|
||||
it('returns "Update Webhook" when editing (true arm)', () => {
|
||||
expect(getAlertWebhookSubmitLabel(true)).toBe('Update Webhook');
|
||||
});
|
||||
|
||||
it('returns "Add Webhook" when creating (false arm)', () => {
|
||||
expect(getAlertWebhookSubmitLabel(false)).toBe('Add Webhook');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertWebhookMutationSuccess', () => {
|
||||
it('maps the "add" action to the add-success message', () => {
|
||||
expect(getAlertWebhookMutationSuccess('add')).toBe(
|
||||
'Webhook added successfully',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps the "update" action to the update-success message', () => {
|
||||
expect(getAlertWebhookMutationSuccess('update')).toBe(
|
||||
'Webhook updated successfully',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps the "delete" action to the delete-success message', () => {
|
||||
expect(getAlertWebhookMutationSuccess('delete')).toBe(
|
||||
'Webhook deleted successfully',
|
||||
);
|
||||
});
|
||||
|
||||
it('routes any unrecognised action through the default arm to the delete-success message', () => {
|
||||
expect(
|
||||
getAlertWebhookMutationSuccess(
|
||||
'nuke' as unknown as 'add' | 'update' | 'delete',
|
||||
),
|
||||
).toBe('Webhook deleted successfully');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeAlertWebhookCustomFields — pushover token/user migration arms', () => {
|
||||
it('migrates app_token to token when token is missing (b39 true arm)', () => {
|
||||
const normalized = normalizeAlertWebhookCustomFields('pushover', {
|
||||
app_token: 'app-key-123',
|
||||
});
|
||||
expect(normalized.token).toBe('app-key-123');
|
||||
expect(normalized.app_token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT overwrite an existing token with app_token (b40 false arm / short-circuit)', () => {
|
||||
const normalized = normalizeAlertWebhookCustomFields('pushover', {
|
||||
token: 'existing-token',
|
||||
app_token: 'legacy-app-token',
|
||||
});
|
||||
expect(normalized.token).toBe('existing-token');
|
||||
expect(normalized.app_token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips migration when app_token is whitespace-only (right operand falsy)', () => {
|
||||
const normalized = normalizeAlertWebhookCustomFields('pushover', {
|
||||
app_token: ' ',
|
||||
});
|
||||
expect(normalized.token).toBeUndefined();
|
||||
expect(normalized.app_token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('migrates user_token to user when user is missing (b42 true arm)', () => {
|
||||
const normalized = normalizeAlertWebhookCustomFields('pushover', {
|
||||
user_token: 'user-key-456',
|
||||
});
|
||||
expect(normalized.user).toBe('user-key-456');
|
||||
expect(normalized.user_token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT overwrite an existing user with user_token (false arm / short-circuit)', () => {
|
||||
const normalized = normalizeAlertWebhookCustomFields('pushover', {
|
||||
user: 'existing-user',
|
||||
user_token: 'legacy-user-token',
|
||||
});
|
||||
expect(normalized.user).toBe('existing-user');
|
||||
expect(normalized.user_token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips user migration when user_token is whitespace-only (right operand falsy)', () => {
|
||||
const normalized = normalizeAlertWebhookCustomFields('pushover', {
|
||||
user_token: ' ',
|
||||
});
|
||||
expect(normalized.user).toBeUndefined();
|
||||
expect(normalized.user_token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips both legacy keys and migrates both values in a single pass', () => {
|
||||
const normalized = normalizeAlertWebhookCustomFields('pushover', {
|
||||
app_token: 'a-tok',
|
||||
user_token: 'u-tok',
|
||||
});
|
||||
expect(normalized).toStrictEqual({ token: 'a-tok', user: 'u-tok' });
|
||||
});
|
||||
|
||||
it('returns a shallow copy unchanged for non-pushover services (early return)', () => {
|
||||
const fields = { token: 'should-stay', custom: 'x' };
|
||||
const normalized = normalizeAlertWebhookCustomFields('slack', fields);
|
||||
expect(normalized).toStrictEqual(fields);
|
||||
expect(normalized).not.toBe(fields);
|
||||
});
|
||||
|
||||
it('treats Pushover with mixed case/whitespace as pushover (trim().toLowerCase())', () => {
|
||||
const normalized = normalizeAlertWebhookCustomFields(' PushOver ', {
|
||||
app_token: 'mixed',
|
||||
});
|
||||
expect(normalized.token).toBe('mixed');
|
||||
expect(normalized.app_token).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertWebhookCustomFieldInputs — preset value default arm (b33)', () => {
|
||||
it('defaults each pushover preset value to empty string when no existing values are supplied (?? right operand)', () => {
|
||||
const inputs = getAlertWebhookCustomFieldInputs('pushover', {});
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs).toStrictEqual([
|
||||
{
|
||||
key: 'token',
|
||||
value: '',
|
||||
label: 'Application Token',
|
||||
placeholder: 'Your Pushover application token',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
value: '',
|
||||
label: 'User Key',
|
||||
placeholder: 'Primary user key or group key',
|
||||
required: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('pulls each preset value from the normalized existing map when present (?? left operand)', () => {
|
||||
const inputs = getAlertWebhookCustomFieldInputs('pushover', {
|
||||
token: 'tok-1',
|
||||
user: 'usr-1',
|
||||
});
|
||||
expect(inputs.map((i) => [i.key, i.value])).toStrictEqual([
|
||||
['token', 'tok-1'],
|
||||
['user', 'usr-1'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends extra non-preset fields after the pushover presets (extras filter arm)', () => {
|
||||
const inputs = getAlertWebhookCustomFieldInputs('pushover', {
|
||||
token: 'tok',
|
||||
user: 'usr',
|
||||
priority: '1',
|
||||
});
|
||||
// Presets first, then the filtered extras (priority is not a preset key).
|
||||
expect(inputs.map((i) => i.key)).toStrictEqual([
|
||||
'token',
|
||||
'user',
|
||||
'priority',
|
||||
]);
|
||||
const priority = inputs.find((i) => i.key === 'priority');
|
||||
expect(priority).toStrictEqual({ key: 'priority', value: '1' });
|
||||
});
|
||||
|
||||
it('migrates pushover app_token/user_token before building preset inputs', () => {
|
||||
// Confirms the composition: getAlertWebhookCustomFieldInputs runs the
|
||||
// normalizer first, so app_token feeds the token preset's value.
|
||||
const inputs = getAlertWebhookCustomFieldInputs('pushover', {
|
||||
app_token: 'via-app-token',
|
||||
user_token: 'via-user-token',
|
||||
});
|
||||
expect(inputs.map((i) => [i.key, i.value])).toStrictEqual([
|
||||
['token', 'via-app-token'],
|
||||
['user', 'via-user-token'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns bare {key,value} inputs for a service with no presets (presets-absent branch)', () => {
|
||||
const inputs = getAlertWebhookCustomFieldInputs('discord', {
|
||||
sound: 'ping',
|
||||
priority: '5',
|
||||
});
|
||||
expect(inputs).toStrictEqual([
|
||||
{ key: 'sound', value: 'ping' },
|
||||
{ key: 'priority', value: '5' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults to an empty existing map and yields no inputs for a preset-less service', () => {
|
||||
expect(getAlertWebhookCustomFieldInputs('slack')).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { OrganizationRole, OrganizationShareStatus } from '@/api/orgs';
|
||||
import {
|
||||
getOrganizationAccessInvitationAcceptedMessage,
|
||||
getOrganizationAccessInvitationDeclinedMessage,
|
||||
getOrganizationAccessInvitationRevokedMessage,
|
||||
getOrganizationAccessInvitationSentMessage,
|
||||
getOrganizationAccessOwnerTransferMemberRequiredMessage,
|
||||
getOrganizationAccessPendingInvitationsEmptyState,
|
||||
getOrganizationAccessRoleUpdatedMessage,
|
||||
getOrganizationAccessYourInvitationsEmptyState,
|
||||
getOrganizationAddMemberErrorMessage,
|
||||
getOrganizationDisplayNameUpdateErrorMessage,
|
||||
getOrganizationIncomingShareAcceptErrorMessage,
|
||||
getOrganizationIncomingShareDeclineConfirmMessage,
|
||||
getOrganizationIncomingShareDeclineErrorMessage,
|
||||
getOrganizationIncomingShareDeclineSuccessMessage,
|
||||
getOrganizationInvitationActionErrorMessage,
|
||||
getOrganizationMemberRoleUpdateErrorMessage,
|
||||
getOrganizationRemoveMemberErrorMessage,
|
||||
getOrganizationSettingsLoadErrorMessage,
|
||||
getOrganizationShareCreateErrorMessage,
|
||||
getOrganizationShareDeleteErrorMessage,
|
||||
getOrganizationShareStatusDescription,
|
||||
getOrganizationShareStatusLabel,
|
||||
} from '@/utils/organizationSettingsPresentation';
|
||||
|
||||
// Supplemental branch coverage. The primary test exercises the happy path for
|
||||
// most helpers but leaves the arms below open: the two-`if` precedence in the
|
||||
// load-error helper, every fully-uncovered invitation helper, the `message ||`
|
||||
// truthy/falsy arms of several error helpers, and the acceptedAt/acceptedBy
|
||||
// branch matrix of getOrganizationShareStatusDescription.
|
||||
|
||||
describe('organizationSettingsPresentation branch coverage (supplemental)', () => {
|
||||
describe('getOrganizationSettingsLoadErrorMessage', () => {
|
||||
it('honours the 402 check before the 501 check when a message carries both tokens', () => {
|
||||
// Two sequential `if (message.includes(...))` guards: the 402 arm is checked
|
||||
// first, so a message containing both substrings must resolve to the license
|
||||
// message, never the not-enabled message.
|
||||
expect(
|
||||
getOrganizationSettingsLoadErrorMessage('gateway returned 402 then 501', 'sharing'),
|
||||
).toBe('Organization settings require an Enterprise license.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrganizationAccessPendingInvitationsEmptyState', () => {
|
||||
it('returns the canonical pending-invitations empty state', () => {
|
||||
expect(getOrganizationAccessPendingInvitationsEmptyState()).toBe(
|
||||
'No pending invitations for this organization.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrganizationAccessYourInvitationsEmptyState', () => {
|
||||
it('returns the canonical your-invitations empty state', () => {
|
||||
expect(getOrganizationAccessYourInvitationsEmptyState()).toBe(
|
||||
'No invitations are waiting for you.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrganizationAccessOwnerTransferMemberRequiredMessage', () => {
|
||||
it('returns the canonical owner-transfer validation message', () => {
|
||||
expect(getOrganizationAccessOwnerTransferMemberRequiredMessage()).toBe(
|
||||
'Ownership can only be transferred to an existing member.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrganizationAccessInvitationSentMessage', () => {
|
||||
it('formats the invitation-sent copy for every non-owner role', () => {
|
||||
// role is typed `Exclude<OrganizationRole, 'owner'>`; exercise each arm.
|
||||
const roles: Exclude<OrganizationRole, 'owner'>[] = ['admin', 'editor', 'viewer'];
|
||||
for (const role of roles) {
|
||||
expect(getOrganizationAccessInvitationSentMessage('bob', role)).toBe(
|
||||
`Sent bob an invitation for the ${role} role.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrganizationAccessInvitationAcceptedMessage', () => {
|
||||
it('formats the invitation-accepted copy', () => {
|
||||
expect(getOrganizationAccessInvitationAcceptedMessage('carol')).toBe(
|
||||
'carol joined the organization.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrganizationAccessInvitationDeclinedMessage', () => {
|
||||
it('formats the invitation-declined copy with the org id', () => {
|
||||
expect(getOrganizationAccessInvitationDeclinedMessage('org-42')).toBe(
|
||||
'Declined the invitation for org-42.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrganizationAccessInvitationRevokedMessage', () => {
|
||||
it('formats the invitation-revoked copy with the user id', () => {
|
||||
expect(getOrganizationAccessInvitationRevokedMessage('dave')).toBe(
|
||||
"Revoked dave's pending invitation.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrganizationAccessRoleUpdatedMessage', () => {
|
||||
it('formats the role-updated copy for the owner and editor roles the primary test omits', () => {
|
||||
expect(getOrganizationAccessRoleUpdatedMessage('alice', 'owner')).toBe(
|
||||
'Updated alice to the owner role.',
|
||||
);
|
||||
expect(getOrganizationAccessRoleUpdatedMessage('alice', 'editor')).toBe(
|
||||
'Updated alice to the editor role.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrganizationInvitationActionErrorMessage', () => {
|
||||
it('returns the provided message on the truthy arm of `message ||`', () => {
|
||||
expect(getOrganizationInvitationActionErrorMessage('invitation already revoked')).toBe(
|
||||
'invitation already revoked',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the fallback on the falsy arm when message is undefined', () => {
|
||||
expect(getOrganizationInvitationActionErrorMessage()).toBe(
|
||||
'Unable to update the invitation.',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats an empty-string message as falsy and falls back', () => {
|
||||
expect(getOrganizationInvitationActionErrorMessage('')).toBe(
|
||||
'Unable to update the invitation.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrganizationIncomingShareAcceptErrorMessage', () => {
|
||||
it('returns the provided message on the truthy arm of `message ||`', () => {
|
||||
expect(getOrganizationIncomingShareAcceptErrorMessage('target rejected')).toBe(
|
||||
'target rejected',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats an empty-string message as falsy and falls back', () => {
|
||||
expect(getOrganizationIncomingShareAcceptErrorMessage('')).toBe(
|
||||
'Unable to accept the incoming share.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrganizationIncomingShareDeclineErrorMessage', () => {
|
||||
it('returns the provided message on the truthy arm of `message ||`', () => {
|
||||
expect(getOrganizationIncomingShareDeclineErrorMessage('still in use')).toBe(
|
||||
'still in use',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats an empty-string message as falsy and falls back', () => {
|
||||
expect(getOrganizationIncomingShareDeclineErrorMessage('')).toBe(
|
||||
'Unable to remove the incoming share.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error helpers — empty-string falsy arm of `message || fallback`', () => {
|
||||
// The primary test only feeds `undefined` for these, hitting the same falsy
|
||||
// arm; exercising the empty-string variant pins that any falsy message
|
||||
// (not only undefined) routes to the fallback.
|
||||
it('getOrganizationDisplayNameUpdateErrorMessage falls back for an empty string', () => {
|
||||
expect(getOrganizationDisplayNameUpdateErrorMessage('')).toBe(
|
||||
'Unable to update the organization name.',
|
||||
);
|
||||
});
|
||||
|
||||
it('getOrganizationMemberRoleUpdateErrorMessage falls back for an empty string', () => {
|
||||
expect(getOrganizationMemberRoleUpdateErrorMessage('')).toBe(
|
||||
'Unable to update the member role.',
|
||||
);
|
||||
});
|
||||
|
||||
it('getOrganizationAddMemberErrorMessage falls back for an empty string', () => {
|
||||
expect(getOrganizationAddMemberErrorMessage('')).toBe('Unable to add the member.');
|
||||
});
|
||||
|
||||
it('getOrganizationRemoveMemberErrorMessage falls back for an empty string', () => {
|
||||
expect(getOrganizationRemoveMemberErrorMessage('')).toBe('Unable to remove the member.');
|
||||
});
|
||||
|
||||
it('getOrganizationShareCreateErrorMessage falls back for an empty string', () => {
|
||||
expect(getOrganizationShareCreateErrorMessage('')).toBe('Unable to create the share.');
|
||||
});
|
||||
|
||||
it('getOrganizationShareDeleteErrorMessage falls back for an empty string', () => {
|
||||
expect(getOrganizationShareDeleteErrorMessage('')).toBe('Unable to remove the share.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrganizationShareStatusLabel', () => {
|
||||
it('routes any non-pending status (including an unrecognised value) to the else arm "Active"', () => {
|
||||
// Ternary `status === 'pending' ? 'Pending approval' : 'Active'`: a
|
||||
// deliberately invalid status must take the else arm.
|
||||
expect(
|
||||
getOrganizationShareStatusLabel('rejected' as unknown as OrganizationShareStatus),
|
||||
).toBe('Active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrganizationShareStatusDescription', () => {
|
||||
it('keeps the pending copy when status is pending even if accepted metadata is supplied', () => {
|
||||
// status === 'pending' short-circuits before the acceptedAt/acceptedBy logic.
|
||||
expect(
|
||||
getOrganizationShareStatusDescription('pending', '2026-04-22T10:30:00Z', 'alice'),
|
||||
).toBe('Waiting for a target organization admin to accept.');
|
||||
});
|
||||
|
||||
it('uses formatOrgDate(acceptedAt) and omits the "by ..." segment when acceptedBy is absent', () => {
|
||||
// acceptedAt truthy arm + acceptedBy falsy arm. Use a value formatOrgDate
|
||||
// returns verbatim (Invalid Date) so the assertion is deterministic.
|
||||
expect(getOrganizationShareStatusDescription('accepted', 'not-a-real-date')).toBe(
|
||||
'Accepted not-a-real-date.',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses formatOrgDate(acceptedAt) and appends "by <acceptedBy>" when acceptedBy is set', () => {
|
||||
// acceptedAt truthy arm + acceptedBy truthy arm with a real (locale-formatted)
|
||||
// date: assert structure rather than the locale-dependent date text.
|
||||
const description = getOrganizationShareStatusDescription(
|
||||
'accepted',
|
||||
'2026-04-22T10:30:00Z',
|
||||
'alice',
|
||||
);
|
||||
expect(description).toMatch(/^Accepted .+ by alice\.$/);
|
||||
expect(description).not.toContain('the target organization');
|
||||
});
|
||||
|
||||
it('falls back to the generic "Accepted by the target organization" when acceptedAt is undefined', () => {
|
||||
// acceptedAt falsy arm + acceptedBy truthy arm.
|
||||
expect(getOrganizationShareStatusDescription('accepted', undefined, 'alice')).toBe(
|
||||
'Accepted by the target organization by alice.',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the "by ..." segment entirely when both acceptedAt and acceptedBy are undefined', () => {
|
||||
// acceptedAt falsy arm + acceptedBy falsy arm.
|
||||
expect(getOrganizationShareStatusDescription('accepted')).toBe(
|
||||
'Accepted by the target organization.',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats an empty-string acceptedAt as falsy', () => {
|
||||
// acceptedAt is `''` (falsy) -> generic accepted copy, no "by" segment.
|
||||
expect(getOrganizationShareStatusDescription('accepted', '')).toBe(
|
||||
'Accepted by the target organization.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrganizationIncomingShareDeclineConfirmMessage', () => {
|
||||
it('routes any non-pending status (including an unrecognised value) to the remove arm', () => {
|
||||
// `status === 'pending' ? ... : ...` else arm via an invalid status.
|
||||
expect(
|
||||
getOrganizationIncomingShareDeclineConfirmMessage(
|
||||
'High CPU',
|
||||
'expired' as unknown as OrganizationShareStatus,
|
||||
),
|
||||
).toBe('Remove shared access to High CPU?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrganizationIncomingShareDeclineSuccessMessage', () => {
|
||||
it('routes any non-pending status (including an unrecognised value) to the removed arm', () => {
|
||||
// `status === 'pending' ? ... : ...` else arm via an invalid status.
|
||||
expect(
|
||||
getOrganizationIncomingShareDeclineSuccessMessage(
|
||||
'High CPU',
|
||||
'expired' as unknown as OrganizationShareStatus,
|
||||
),
|
||||
).toBe('Removed shared access to High CPU.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getSSOCertificatePresentation,
|
||||
getSSOCopySuccessMessage,
|
||||
getSSOConnectionTestErrorMessage,
|
||||
getSSOConnectionTestFailureMessage,
|
||||
getSSOConnectionTestSuccessMessage,
|
||||
getSSOProviderAddButtonLabel,
|
||||
getSSOProviderCardClass,
|
||||
getSSOProviderDeleteErrorMessage,
|
||||
getSSOProviderDeleteSuccessMessage,
|
||||
getSSOProviderDetailsLoadErrorMessage,
|
||||
getSSOProviderEmptyStateDescription,
|
||||
getSSOProviderEmptyStateTitle,
|
||||
getSSOMetadataFetchErrorMessage,
|
||||
getSSOMetadataUrlRequiredMessage,
|
||||
getSSOProviderModalTitle,
|
||||
getSSOProviderSaveErrorMessage,
|
||||
getSSOProviderSaveSuccessMessage,
|
||||
getSSOProviderSummary,
|
||||
getSSOProvidersLoadingState,
|
||||
getSSOProvidersLoadErrorMessage,
|
||||
getSSOProviderToggleErrorMessage,
|
||||
getSSOProviderToggleSuccessMessage,
|
||||
getSSOProviderTypeBadgeClass,
|
||||
getSSOProviderTypeLabel,
|
||||
getSSOSamlFeatureGateCopy,
|
||||
getSSOTestResultPresentation,
|
||||
type SSOProviderSummaryLike,
|
||||
type SSOProviderType,
|
||||
} from '@/utils/ssoProviderPresentation';
|
||||
|
||||
// This supplemental suite targets the branch arms the canonical
|
||||
// `ssoProviderPresentation.test.ts` leaves open: the `editing === true` arm of
|
||||
// `getSSOProviderModalTitle`, the falsy `oidcIssuerUrl || ''` arm, and both
|
||||
// falsy arms of the nested `samlIdpEntityId || samlMetadataUrl || ''` chain in
|
||||
// `getSSOProviderSummary`. It also pins the full concrete return values and
|
||||
// `String(error)` coercion behaviour that the sibling suite only spot-checks.
|
||||
|
||||
describe('ssoProviderPresentation branch coverage (supplemental)', () => {
|
||||
describe('getSSOProviderTypeLabel', () => {
|
||||
it('returns "OIDC" on the oidc arm of the ternary', () => {
|
||||
expect(getSSOProviderTypeLabel('oidc')).toBe('OIDC');
|
||||
});
|
||||
|
||||
it('returns "SAML" on the non-oidc arm of the ternary', () => {
|
||||
expect(getSSOProviderTypeLabel('saml')).toBe('SAML');
|
||||
});
|
||||
|
||||
it('routes a deliberately-invalid type through the else arm onto "SAML"', () => {
|
||||
// The ternary is `type === 'oidc' ? 'OIDC' : 'SAML'`; anything that is not
|
||||
// strictly the string 'oidc' falls to the else arm regardless of the
|
||||
// declared union type.
|
||||
expect(getSSOProviderTypeLabel('unknown' as unknown as SSOProviderType)).toBe('SAML');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSSOProviderAddButtonLabel', () => {
|
||||
it('composes "Add OIDC" from the oidc label arm', () => {
|
||||
expect(getSSOProviderAddButtonLabel('oidc')).toBe('Add OIDC');
|
||||
});
|
||||
|
||||
it('composes "Add SAML" from the saml label arm', () => {
|
||||
expect(getSSOProviderAddButtonLabel('saml')).toBe('Add SAML');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSSOProviderModalTitle', () => {
|
||||
it('uses the "Edit" arm when editing is true (oidc label arm)', () => {
|
||||
// Covers the previously-open `editing ? 'Edit' : 'Add'` true arm, and the
|
||||
// oidc arm of the nested getSSOProviderTypeLabel call.
|
||||
expect(getSSOProviderModalTitle(true, 'oidc')).toBe('Edit OIDC Provider');
|
||||
});
|
||||
|
||||
it('uses the "Edit" arm with the saml label', () => {
|
||||
expect(getSSOProviderModalTitle(true, 'saml')).toBe('Edit SAML Provider');
|
||||
});
|
||||
|
||||
it('uses the "Add" arm with the oidc label', () => {
|
||||
// The sibling suite only pins (false, 'saml'); this pins the (false,
|
||||
// 'oidc') combination.
|
||||
expect(getSSOProviderModalTitle(false, 'oidc')).toBe('Add OIDC Provider');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSSOSamlFeatureGateCopy', () => {
|
||||
it('returns the full feature-gate copy verbatim', () => {
|
||||
expect(getSSOSamlFeatureGateCopy()).toStrictEqual({
|
||||
title: 'Single Sign-On',
|
||||
subtitle: 'Single Sign-On',
|
||||
body: 'OIDC, SAML, and multi-provider SSO are included with Community and higher tiers.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSSOProviderEmptyStateTitle / Description / LoadingState', () => {
|
||||
it('returns the canonical empty-state copy and loading object', () => {
|
||||
expect(getSSOProviderEmptyStateTitle()).toBe('No SSO providers configured');
|
||||
expect(getSSOProviderEmptyStateDescription()).toBe(
|
||||
'Add an OIDC or SAML provider to get started.',
|
||||
);
|
||||
expect(getSSOProvidersLoadingState()).toStrictEqual({ text: 'Loading SSO providers…' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSSOProvidersLoadErrorMessage / DetailsLoadErrorMessage', () => {
|
||||
it('returns the canonical load-error strings', () => {
|
||||
expect(getSSOProvidersLoadErrorMessage()).toBe('Unable to load SSO providers.');
|
||||
expect(getSSOProviderDetailsLoadErrorMessage()).toBe(
|
||||
'Unable to load SSO provider details.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSSOProviderSaveSuccessMessage', () => {
|
||||
it('returns the "updated" copy on the isEdit true arm', () => {
|
||||
expect(getSSOProviderSaveSuccessMessage(true)).toBe('SSO provider has been updated.');
|
||||
});
|
||||
|
||||
it('returns the "created" copy on the isEdit false arm', () => {
|
||||
expect(getSSOProviderSaveSuccessMessage(false)).toBe('SSO provider has been created.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSSOProviderSaveErrorMessage', () => {
|
||||
it('coerces an undefined error to the literal string "undefined" via String()', () => {
|
||||
// `String(undefined)` => 'undefined' — documents the runtime behaviour of
|
||||
// the optional `error` parameter when callers omit it.
|
||||
expect(getSSOProviderSaveErrorMessage()).toBe(
|
||||
'Unable to save the SSO provider: undefined',
|
||||
);
|
||||
});
|
||||
|
||||
it('coerces null to "null"', () => {
|
||||
expect(getSSOProviderSaveErrorMessage(null)).toBe(
|
||||
'Unable to save the SSO provider: null',
|
||||
);
|
||||
});
|
||||
|
||||
it('coerces a plain object to "[object Object]"', () => {
|
||||
expect(getSSOProviderSaveErrorMessage({ status: 500 })).toBe(
|
||||
'Unable to save the SSO provider: [object Object]',
|
||||
);
|
||||
});
|
||||
|
||||
it('coerces an Error instance to "Error: <message>"', () => {
|
||||
expect(getSSOProviderSaveErrorMessage(new Error('boom'))).toBe(
|
||||
'Unable to save the SSO provider: Error: boom',
|
||||
);
|
||||
});
|
||||
|
||||
it('coerces a number to its decimal string form', () => {
|
||||
expect(getSSOProviderSaveErrorMessage(42)).toBe('Unable to save the SSO provider: 42');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSSOProviderDeleteSuccessMessage / DeleteErrorMessage / ToggleErrorMessage', () => {
|
||||
it('returns the canonical delete/toggle strings verbatim', () => {
|
||||
expect(getSSOProviderDeleteSuccessMessage()).toBe('SSO provider has been removed.');
|
||||
expect(getSSOProviderDeleteErrorMessage()).toBe('Unable to remove the SSO provider.');
|
||||
expect(getSSOProviderToggleErrorMessage()).toBe('Unable to update the SSO provider.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSSOProviderToggleSuccessMessage', () => {
|
||||
it('returns the "enabled" copy on the true arm', () => {
|
||||
expect(getSSOProviderToggleSuccessMessage(true)).toBe('SSO provider has been enabled.');
|
||||
});
|
||||
|
||||
it('returns the "disabled" copy on the false arm', () => {
|
||||
expect(getSSOProviderToggleSuccessMessage(false)).toBe('SSO provider has been disabled.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSSOCopySuccessMessage', () => {
|
||||
it('interpolates the label into the clipboard confirmation', () => {
|
||||
expect(getSSOCopySuccessMessage('XML')).toBe('XML has been copied to the clipboard.');
|
||||
});
|
||||
|
||||
it('renders an empty label verbatim (leading space kept by the template literal)', () => {
|
||||
// Edge case: template literal does not trim, so an empty label yields a
|
||||
// leading space then the fixed copy.
|
||||
expect(getSSOCopySuccessMessage('')).toBe(' has been copied to the clipboard.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSSOConnectionTestSuccessMessage / FailureMessage / ErrorMessage', () => {
|
||||
it('returns the canonical connection-test strings', () => {
|
||||
expect(getSSOConnectionTestSuccessMessage()).toBe(
|
||||
'Connection test completed successfully.',
|
||||
);
|
||||
expect(getSSOConnectionTestFailureMessage('Bad certificate')).toBe(
|
||||
'Connection test failed: Bad certificate',
|
||||
);
|
||||
expect(getSSOConnectionTestErrorMessage()).toBe('Unable to run the connection test.');
|
||||
});
|
||||
|
||||
it('renders an empty failure message after the colon', () => {
|
||||
expect(getSSOConnectionTestFailureMessage('')).toBe('Connection test failed: ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSSOMetadataUrlRequiredMessage / MetadataFetchErrorMessage', () => {
|
||||
it('returns the canonical metadata-required string', () => {
|
||||
expect(getSSOMetadataUrlRequiredMessage()).toBe('Enter an IdP metadata URL.');
|
||||
});
|
||||
|
||||
it('coerces an undefined error to "undefined" via String()', () => {
|
||||
expect(getSSOMetadataFetchErrorMessage()).toBe('Unable to fetch metadata: undefined');
|
||||
});
|
||||
|
||||
it('coerces null to "null"', () => {
|
||||
expect(getSSOMetadataFetchErrorMessage(null)).toBe('Unable to fetch metadata: null');
|
||||
});
|
||||
|
||||
it('coerces a plain object to "[object Object]"', () => {
|
||||
expect(getSSOMetadataFetchErrorMessage({ code: 'ECONNRESET' })).toBe(
|
||||
'Unable to fetch metadata: [object Object]',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSSOProviderSummary', () => {
|
||||
it('returns the oidcIssuerUrl on the oidc truthy arm', () => {
|
||||
expect(
|
||||
getSSOProviderSummary({
|
||||
type: 'oidc',
|
||||
oidcIssuerUrl: 'https://login.example.com/realms/pulse',
|
||||
}),
|
||||
).toBe('https://login.example.com/realms/pulse');
|
||||
});
|
||||
|
||||
it('falls back to "" when an oidc provider has no issuer url (|| falsy arm)', () => {
|
||||
// Covers the previously-open falsy arm of `provider.oidcIssuerUrl || ''`.
|
||||
expect(getSSOProviderSummary({ type: 'oidc' })).toBe('');
|
||||
});
|
||||
|
||||
it('falls back to "" when the oidc issuer url is the empty string', () => {
|
||||
// Same `|| ''` falsy arm, exercised with a present-but-falsy value.
|
||||
expect(getSSOProviderSummary({ type: 'oidc', oidcIssuerUrl: '' })).toBe('');
|
||||
});
|
||||
|
||||
it('prefers samlIdpEntityId on the saml arm when it is set', () => {
|
||||
expect(
|
||||
getSSOProviderSummary({
|
||||
type: 'saml',
|
||||
samlIdpEntityId: 'https://idp.example.com/entity',
|
||||
samlMetadataUrl: 'https://idp.example.com/metadata',
|
||||
}),
|
||||
).toBe('https://idp.example.com/entity');
|
||||
});
|
||||
|
||||
it('falls back to samlMetadataUrl when samlIdpEntityId is absent (first || falsy arm)', () => {
|
||||
// Covers the previously-open falsy arm of the first `||` and the truthy
|
||||
// arm of the second `||`.
|
||||
expect(
|
||||
getSSOProviderSummary({
|
||||
type: 'saml',
|
||||
samlMetadataUrl: 'https://idp.example.com/metadata',
|
||||
}),
|
||||
).toBe('https://idp.example.com/metadata');
|
||||
});
|
||||
|
||||
it('falls back to samlMetadataUrl when samlIdpEntityId is the empty string', () => {
|
||||
// Same first-|| falsy arm, exercised with a present-but-falsy entity id.
|
||||
expect(
|
||||
getSSOProviderSummary({
|
||||
type: 'saml',
|
||||
samlIdpEntityId: '',
|
||||
samlMetadataUrl: 'https://idp.example.com/metadata',
|
||||
}),
|
||||
).toBe('https://idp.example.com/metadata');
|
||||
});
|
||||
|
||||
it('falls back to "" when a saml provider has neither id nor metadata url (final || falsy arm)', () => {
|
||||
// Covers the previously-open final `|| ''` falsy arm of the nested chain.
|
||||
expect(getSSOProviderSummary({ type: 'saml' })).toBe('');
|
||||
});
|
||||
|
||||
it('falls back to "" when both saml fields are the empty string', () => {
|
||||
// Final `|| ''` falsy arm, exercised with present-but-falsy values.
|
||||
expect(
|
||||
getSSOProviderSummary({
|
||||
type: 'saml',
|
||||
samlIdpEntityId: '',
|
||||
samlMetadataUrl: '',
|
||||
}),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('treats a non-oidc/non-saml type as the saml summary path', () => {
|
||||
// The `if (provider.type === 'oidc')` guard only special-cases oidc;
|
||||
// anything else (including an invalid type) flows to the saml fallback
|
||||
// chain. With nothing set, that resolves to "".
|
||||
expect(
|
||||
getSSOProviderSummary({
|
||||
type: 'unknown' as unknown as SSOProviderType,
|
||||
samlMetadataUrl: 'https://only.example.com/metadata',
|
||||
} as unknown as SSOProviderSummaryLike),
|
||||
).toBe('https://only.example.com/metadata');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSSOProviderCardClass', () => {
|
||||
it('returns the full enabled-card class string', () => {
|
||||
expect(getSSOProviderCardClass(true)).toBe(
|
||||
'p-4 rounded-md border bg-surface border-border',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the full disabled-card class string', () => {
|
||||
expect(getSSOProviderCardClass(false)).toBe(
|
||||
'p-4 rounded-md border bg-surface-alt border-border opacity-60',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSSOProviderTypeBadgeClass', () => {
|
||||
it('returns the canonical badge class string verbatim', () => {
|
||||
expect(getSSOProviderTypeBadgeClass()).toBe(
|
||||
'px-1.5 py-0.5 text-xs font-medium rounded bg-surface-hover',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSSOTestResultPresentation', () => {
|
||||
it('returns the full success presentation object', () => {
|
||||
expect(getSSOTestResultPresentation(true)).toStrictEqual({
|
||||
panelClass:
|
||||
'p-4 rounded-md border bg-green-50 dark:bg-green-900 border-green-200 dark:border-green-800',
|
||||
iconClass: 'w-5 h-5 text-emerald-500 dark:text-emerald-400 flex-shrink-0 mt-0.5',
|
||||
titleClass: 'text-sm font-medium text-green-800 dark:text-green-200',
|
||||
errorClass: 'text-xs text-red-600 dark:text-red-400 mt-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the full failure presentation object', () => {
|
||||
expect(getSSOTestResultPresentation(false)).toStrictEqual({
|
||||
panelClass:
|
||||
'p-4 rounded-md border bg-red-50 dark:bg-red-900 border-red-200 dark:border-red-800',
|
||||
iconClass: 'w-5 h-5 text-rose-500 dark:text-rose-400 flex-shrink-0 mt-0.5',
|
||||
titleClass: 'text-sm font-medium text-red-800 dark:text-red-200',
|
||||
errorClass: 'text-xs text-red-600 dark:text-red-400 mt-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSSOCertificatePresentation', () => {
|
||||
it('returns the full expired presentation object', () => {
|
||||
expect(getSSOCertificatePresentation(true)).toStrictEqual({
|
||||
containerClass:
|
||||
'text-xs px-2 py-1 rounded bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300',
|
||||
expiredLabelClass: 'ml-1 text-red-600 dark:text-red-400 font-medium',
|
||||
expiredLabel: '(Expired!)',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the full valid presentation object', () => {
|
||||
expect(getSSOCertificatePresentation(false)).toStrictEqual({
|
||||
containerClass: 'text-xs px-2 py-1 rounded bg-surface-hover text-base-content',
|
||||
expiredLabelClass: 'ml-1 text-red-600 dark:text-red-400 font-medium',
|
||||
expiredLabel: '(Expired!)',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user