From d73c88066a0ef2f70a1d1014150fe76c8350b746 Mon Sep 17 00:00:00 2001 From: Afreen Misbah Date: Thu, 12 Feb 2026 16:06:53 +0530 Subject: [PATCH] mgr/dashboard: Added unit tests Assisted-by: ChatGPT Signed-off-by: Afreen Misbah --- .../ceph/overview/overview.component.spec.ts | 147 ++++++++++++- .../overview-storage-card.component.spec.ts | 200 +++++++++++++++++- .../productive-card.component.spec.ts | 3 +- 3 files changed, 343 insertions(+), 7 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts index 2774459b713..831eb7458b1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts @@ -1,14 +1,38 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of, Subject, throwError } from 'rxjs'; import { OverviewComponent } from './overview.component'; +import { HealthService } from '~/app/shared/api/health.service'; +import { RefreshIntervalService } from '~/app/shared/services/refresh-interval.service'; +import { HealthSnapshotMap } from '~/app/shared/models/health.interface'; -describe('OverviewComponent', () => { +describe('OverviewComponent (Jest)', () => { let component: OverviewComponent; let fixture: ComponentFixture; + let mockHealthService: { + getHealthSnapshot: jest.Mock; + }; + + let mockRefreshIntervalService: { + intervalData$: Subject; + }; + beforeEach(async () => { + mockHealthService = { + getHealthSnapshot: jest.fn() + }; + + mockRefreshIntervalService = { + intervalData$: new Subject() + }; + await TestBed.configureTestingModule({ - imports: [OverviewComponent] + imports: [OverviewComponent], + providers: [ + { provide: HealthService, useValue: mockHealthService }, + { provide: RefreshIntervalService, useValue: mockRefreshIntervalService } + ] }).compileComponents(); fixture = TestBed.createComponent(OverviewComponent); @@ -16,7 +40,126 @@ describe('OverviewComponent', () => { fixture.detectChanges(); }); + afterEach(() => { + jest.clearAllMocks(); + }); + + // -------------------------------------------------- + // CREATION + // -------------------------------------------------- + it('should create', () => { expect(component).toBeTruthy(); }); + + // -------------------------------------------------- + // refreshIntervalObs - success case + // -------------------------------------------------- + + it('should call healthService when interval emits', (done) => { + const mockResponse: HealthSnapshotMap = { status: 'OK' } as any; + + mockHealthService.getHealthSnapshot.mockReturnValue(of(mockResponse)); + + component.healthData$.subscribe((data) => { + expect(data).toEqual(mockResponse); + expect(mockHealthService.getHealthSnapshot).toHaveBeenCalled(); + done(); + }); + + mockRefreshIntervalService.intervalData$.next(); + }); + + // -------------------------------------------------- + // refreshIntervalObs - error case (catchError → EMPTY) + // -------------------------------------------------- + + it('should return EMPTY when healthService throws error', (done) => { + mockHealthService.getHealthSnapshot.mockReturnValue(throwError(() => new Error('API Error'))); + + let emitted = false; + + component.healthData$.subscribe({ + next: () => { + emitted = true; + }, + complete: () => { + expect(emitted).toBe(false); + done(); + } + }); + + mockRefreshIntervalService.intervalData$.next(); + mockRefreshIntervalService.intervalData$.complete(); + }); + + // -------------------------------------------------- + // refreshIntervalObs - exhaustMap behavior + // -------------------------------------------------- + + it('should ignore new interval emissions until previous completes', () => { + const interval$ = new Subject(); + const inner$ = new Subject(); + + const mockRefreshService = { + intervalData$: interval$ + }; + + const testComponent = new OverviewComponent( + mockHealthService as any, + mockRefreshService as any + ); + + mockHealthService.getHealthSnapshot.mockReturnValue(inner$); + + testComponent.healthData$.subscribe(); + + // First emission + interval$.next(); + + // Second emission (should be ignored) + interval$.next(); + + expect(mockHealthService.getHealthSnapshot).toHaveBeenCalledTimes(1); + + // Complete first inner observable + inner$.complete(); + + // Now it should allow another call + interval$.next(); + + expect(mockHealthService.getHealthSnapshot).toHaveBeenCalledTimes(2); + }); + + // -------------------------------------------------- + // ngOnDestroy + // -------------------------------------------------- + + it('should complete destroy$ on destroy', () => { + const nextSpy = jest.spyOn((component as any).destroy$, 'next'); + const completeSpy = jest.spyOn((component as any).destroy$, 'complete'); + + component.ngOnDestroy(); + + expect(nextSpy).toHaveBeenCalled(); + expect(completeSpy).toHaveBeenCalled(); + }); + + // -------------------------------------------------- + // refreshIntervalObs manual test + // -------------------------------------------------- + + it('refreshIntervalObs should pipe intervalData$', (done) => { + const testFn = jest.fn().mockReturnValue(of('TEST')); + + const obs$ = component.refreshIntervalObs(testFn); + + obs$.subscribe((value) => { + expect(value).toBe('TEST'); + expect(testFn).toHaveBeenCalled(); + done(); + }); + + mockRefreshIntervalService.intervalData$.next(); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.spec.ts index ebfa6cccca5..5a8e63011f6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.spec.ts @@ -1,22 +1,216 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; import { OverviewStorageCardComponent } from './overview-storage-card.component'; +import { PrometheusService } from '~/app/shared/api/prometheus.service'; +import { FormatterService } from '~/app/shared/services/formatter.service'; -describe('OverviewStorageCardComponent', () => { +describe('OverviewStorageCardComponent (Jest)', () => { let component: OverviewStorageCardComponent; let fixture: ComponentFixture; + let mockPrometheusService: { + getPrometheusQueryData: jest.Mock; + }; + + let mockFormatterService: { + formatToBinary: jest.Mock; + convertToUnit: jest.Mock; + }; + + const mockPrometheusResponse = { + result: [ + { + metric: { application: 'Block' }, + value: [0, 1024] + }, + { + metric: { application: 'Filesystem' }, + value: [0, 2048] + }, + { + metric: { application: 'Object' }, + value: [0, 0] // should be filtered + } + ] + }; + beforeEach(async () => { + mockPrometheusService = { + getPrometheusQueryData: jest.fn().mockReturnValue(of(mockPrometheusResponse)) + }; + + mockFormatterService = { + formatToBinary: jest.fn().mockReturnValue([10, 'GiB']), + convertToUnit: jest.fn((value: number) => Number(value)) + }; + await TestBed.configureTestingModule({ - imports: [OverviewStorageCardComponent] + imports: [OverviewStorageCardComponent], + providers: [ + { provide: PrometheusService, useValue: mockPrometheusService }, + { provide: FormatterService, useValue: mockFormatterService } + ] }).compileComponents(); fixture = TestBed.createComponent(OverviewStorageCardComponent); component = fixture.componentInstance; - fixture.detectChanges(); + fixture.detectChanges(); // triggers ngOnInit + }); + + afterEach(() => { + jest.clearAllMocks(); }); + // -------------------------------------------------- + // CREATION + // -------------------------------------------------- + it('should create', () => { expect(component).toBeTruthy(); }); + + // -------------------------------------------------- + // TOTAL setter (truthy) + // -------------------------------------------------- + + it('should set total when valid value provided', () => { + component.total = 1024; + + expect(component.totalRaw).toBe(10); + expect(component.totalRawUnit).toBe('GiB'); + }); + + // -------------------------------------------------- + // TOTAL setter (falsy) + // -------------------------------------------------- + + it('should not set total when formatter returns NaN', () => { + mockFormatterService.formatToBinary.mockReturnValue([NaN, 'GiB']); + + component.total = 0; + + expect(component.totalRaw).toBeUndefined(); + }); + + // -------------------------------------------------- + // USED setter + // -------------------------------------------------- + + it('should set used correctly', () => { + component.used = 2048; + + expect(component.usedRaw).toBe(10); + expect(component.usedRawUnit).toBe('GiB'); + }); + + // -------------------------------------------------- + // TOGGLE + // -------------------------------------------------- + + it('should switch to RAW when toggled true', () => { + component.toggleRawCapacity(true); + + expect(component.isRawCapacity).toBe(true); + expect(component.selectedCapacityType).toBe('raw'); + }); + + it('should switch to USED when toggled false', () => { + component.toggleRawCapacity(false); + + expect(component.isRawCapacity).toBe(false); + expect(component.selectedCapacityType).toBe('used'); + }); + + it('should call Prometheus again when toggled', () => { + component.toggleRawCapacity(false); + + expect(mockPrometheusService.getPrometheusQueryData).toHaveBeenCalledTimes(2); + }); + + // -------------------------------------------------- + // ngOnInit data load + // -------------------------------------------------- + + it('should load and filter data on init', () => { + expect(mockPrometheusService.getPrometheusQueryData).toHaveBeenCalled(); + expect(component.allData.length).toBe(2); // Object filtered (0 value) + }); + + // -------------------------------------------------- + // FILTERING + // -------------------------------------------------- + + it('should filter displayData for selected storage type', () => { + component.allData = [ + { group: 'Block', value: 10 }, + { group: 'Filesystem', value: 20 } + ]; + + component.selectedStorageType = 'Block'; + (component as any).setChartData(); + + expect(component.displayData.length).toBe(1); + expect(component.displayData[0].group).toBe('Block'); + }); + + it('should show all data when ALL selected', () => { + component.allData = [ + { group: 'Block', value: 10 }, + { group: 'Filesystem', value: 20 } + ]; + + component.selectedStorageType = 'All'; + (component as any).setChartData(); + + expect(component.displayData.length).toBe(2); + }); + + // -------------------------------------------------- + // DROPDOWN + // -------------------------------------------------- + + it('should update storage type from dropdown selection', () => { + component.onStorageTypeSelect({ + item: { content: 'Block', selected: true } + }); + + expect(component.selectedStorageType).toBe('Block'); + }); + + it('should auto-select single item if only one exists', () => { + component.allData = [{ group: 'Block', value: 10 }]; + + (component as any).setDropdownItemsAndStorageType(); + + expect(component.selectedStorageType).toBe('Block'); + expect(component.dropdownItems.length).toBe(1); + }); + + it('should reset to ALL if previous selection missing', () => { + component.selectedStorageType = 'Block'; + + component.allData = [ + { group: 'Filesystem', value: 20 }, + { group: 'Object', value: 30 } + ]; + + (component as any).setDropdownItemsAndStorageType(); + + expect(component.selectedStorageType).toBe('All'); + }); + + // -------------------------------------------------- + // DESTROY + // -------------------------------------------------- + + it('should clean up on destroy', () => { + const nextSpy = jest.spyOn((component as any).destroy$, 'next'); + const completeSpy = jest.spyOn((component as any).destroy$, 'complete'); + + component.ngOnDestroy(); + + expect(nextSpy).toHaveBeenCalled(); + expect(completeSpy).toHaveBeenCalled(); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.spec.ts index 0fe568b44db..31fbffefaa5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.spec.ts @@ -9,8 +9,7 @@ describe('ProductiveCardComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ProductiveCardComponent], - imports: [GridModule, LayerModule, TilesModule] + imports: [ProductiveCardComponent, GridModule, LayerModule, TilesModule] }).compileComponents(); fixture = TestBed.createComponent(ProductiveCardComponent); -- 2.47.3