diff --git a/src/app/features/analytics/analytics.component.spec.ts b/src/app/features/analytics/analytics.component.spec.ts index d349d6611..787c7976c 100644 --- a/src/app/features/analytics/analytics.component.spec.ts +++ b/src/app/features/analytics/analytics.component.spec.ts @@ -20,21 +20,31 @@ import { IS_WEB } from '@shared/helpers'; import { MOCK_ANALYTICS_METRICS, MOCK_RELATED_COUNTS, MOCK_RESOURCE_OVERVIEW } from '@shared/mocks'; import { OSFTestingModule } from '@testing/osf.testing.module'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('AnalyticsComponent', () => { let component: AnalyticsComponent; let fixture: ComponentFixture; + let routerMock: ReturnType; + let activatedRouteMock: ReturnType; const resourceId = MOCK_RESOURCE_OVERVIEW.id; const metrics = { ...MOCK_ANALYTICS_METRICS, id: resourceId }; const relatedCounts = { ...MOCK_RELATED_COUNTS, id: resourceId }; - const metricsSelector = AnalyticsSelectors.getMetrics(resourceId); const relatedCountsSelector = AnalyticsSelectors.getRelatedCounts(resourceId); beforeEach(async () => { + routerMock = RouterMockBuilder.create().build(); + activatedRouteMock = ActivatedRouteMockBuilder.create() + .withParams({ id: resourceId }) + .withData({ resourceType: undefined }) + .build(); + jest.clearAllMocks(); + await TestBed.configureTestingModule({ imports: [ AnalyticsComponent, @@ -67,14 +77,8 @@ describe('AnalyticsComponent', () => { ], }), { provide: IS_WEB, useValue: of(true) }, - MockProvider(Router, { navigate: jest.fn(), url: '/' }), - { - provide: ActivatedRoute, - useValue: { - parent: { params: of({ id: resourceId }) }, - data: of({ resourceType: undefined }), - }, - }, + MockProvider(Router, routerMock), + MockProvider(ActivatedRoute, activatedRouteMock), ], }).compileComponents(); diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts index cf06a6ea6..511a75ddd 100644 --- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts +++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts @@ -25,14 +25,24 @@ import { MOCK_PROJECT_OVERVIEW } from '@shared/mocks'; import { ViewDuplicatesComponent } from './view-duplicates.component'; import { OSFTestingModule } from '@testing/osf.testing.module'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('ViewDuplicatesComponent', () => { let component: ViewDuplicatesComponent; let fixture: ComponentFixture; let dialogService: DialogService; + let routerMock: ReturnType; + let activatedRouteMock: ReturnType; beforeEach(async () => { + routerMock = RouterMockBuilder.create().build(); + activatedRouteMock = ActivatedRouteMockBuilder.create() + .withParams({ id: 'rid' }) + .withData({ resourceType: ResourceType.Project }) + .build(); + await TestBed.configureTestingModule({ imports: [ ViewDuplicatesComponent, @@ -57,15 +67,9 @@ describe('ViewDuplicatesComponent', () => { { selector: RegistryOverviewSelectors.isRegistryAnonymous, value: false }, ], }), - MockProvider(Router, { navigate: jest.fn() }), + MockProvider(Router, routerMock), + MockProvider(ActivatedRoute, activatedRouteMock), { provide: IS_SMALL, useValue: of(false) }, - { - provide: ActivatedRoute, - useValue: { - parent: { params: of({ id: 'rid' }) }, - data: of({ resourceType: ResourceType.Project }), - }, - }, ], }).compileComponents(); diff --git a/src/app/features/home/home.component.spec.ts b/src/app/features/home/home.component.spec.ts index 24e6ac510..3b01b0907 100644 --- a/src/app/features/home/home.component.spec.ts +++ b/src/app/features/home/home.component.spec.ts @@ -1,14 +1,29 @@ +import { MockComponents, MockProvider } from 'ng-mocks'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { IconComponent, SearchInputComponent } from '@shared/components'; import { HomeComponent } from './home.component'; -describe.skip('HomeComponent', () => { +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; + +describe('HomeComponent', () => { let component: HomeComponent; let fixture: ComponentFixture; + let routerMock: ReturnType; + let activatedRouteMock: ReturnType; beforeEach(async () => { + routerMock = RouterMockBuilder.create().build(); + activatedRouteMock = ActivatedRouteMockBuilder.create().build(); + await TestBed.configureTestingModule({ - imports: [HomeComponent], + imports: [HomeComponent, OSFTestingModule, ...MockComponents(SearchInputComponent, IconComponent)], + providers: [MockProvider(Router, routerMock), MockProvider(ActivatedRoute, activatedRouteMock)], }).compileComponents(); fixture = TestBed.createComponent(HomeComponent); @@ -19,4 +34,33 @@ describe.skip('HomeComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should navigate to search page with empty string when redirectToSearchPageWithValue is called with no value', () => { + component.redirectToSearchPageWithValue(); + + expect(routerMock.navigate).toHaveBeenCalledWith(['/search'], { + queryParams: { search: '' }, + }); + }); + + it('should navigate to search page with search value when searchControl has a value', () => { + const searchValue = 'test search query'; + component.searchControl.setValue(searchValue); + + component.redirectToSearchPageWithValue(); + + expect(routerMock.navigate).toHaveBeenCalledWith(['/search'], { + queryParams: { search: searchValue }, + }); + }); + + it('should navigate to search page with null when searchControl is set to null', () => { + component.searchControl.setValue(null); + + component.redirectToSearchPageWithValue(); + + expect(routerMock.navigate).toHaveBeenCalledWith(['/search'], { + queryParams: { search: null }, + }); + }); }); diff --git a/src/app/features/home/home.component.ts b/src/app/features/home/home.component.ts index 7df01f953..5013b55ce 100644 --- a/src/app/features/home/home.component.ts +++ b/src/app/features/home/home.component.ts @@ -21,7 +21,7 @@ import { INTEGRATION_ICONS, SLIDES } from './constants'; export class HomeComponent { private readonly router = inject(Router); - protected searchControl = new FormControl(''); + searchControl = new FormControl(''); readonly icons = INTEGRATION_ICONS; readonly slides = SLIDES; diff --git a/src/app/features/home/pages/dashboard/dashboard.component.spec.ts b/src/app/features/home/pages/dashboard/dashboard.component.spec.ts index 5cd268eb8..e73a4ff1a 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.spec.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.spec.ts @@ -1,14 +1,50 @@ +import { MockComponents, MockProvider } from 'ng-mocks'; + +import { TablePageEvent } from 'primeng/table'; + +import { of } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { MyResourcesSelectors } from '@osf/shared/stores'; +import { IconComponent, MyProjectsTableComponent, SubHeaderComponent } from '@shared/components'; +import { IS_MEDIUM } from '@shared/helpers'; +import { MyResourcesItem } from '@shared/models'; import { DashboardComponent } from './dashboard.component'; -describe.skip('DashboardComponent', () => { +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { ActivatedRouteMock } from '@testing/providers/route-provider.mock'; +import { RouterMock, RouterMockType } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('DashboardComponent', () => { let component: DashboardComponent; let fixture: ComponentFixture; + let routerMock: RouterMockType; beforeEach(async () => { + routerMock = RouterMock.create().build(); + await TestBed.configureTestingModule({ - imports: [DashboardComponent], + imports: [ + DashboardComponent, + ...MockComponents(SubHeaderComponent, MyProjectsTableComponent, IconComponent), + OSFTestingModule, + ], + providers: [ + provideMockStore({ + signals: [ + { selector: MyResourcesSelectors.getProjects, value: [] }, + { selector: MyResourcesSelectors.getTotalProjects, value: 0 }, + { selector: MyResourcesSelectors.getProjectsLoading, value: false }, + ], + }), + MockProvider(Router, routerMock), + MockProvider(IS_MEDIUM, of(false)), + MockProvider(ActivatedRoute, ActivatedRouteMock.withQueryParams({}).build()), + ], }).compileComponents(); fixture = TestBed.createComponent(DashboardComponent); @@ -19,4 +55,120 @@ describe.skip('DashboardComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should navigate to project on navigateToProject', () => { + const navigateSpy = routerMock.navigate as jest.Mock; + component.navigateToProject({ id: 'p1', title: 'T' } as MyResourcesItem); + expect(navigateSpy).toHaveBeenCalledWith(['p1']); + }); + + it('should open create project dialog with width 95vw when not medium', () => { + const dialogOpenSpy = jest.spyOn((component as any).dialogService, 'open'); + component.createProject(); + expect(dialogOpenSpy).toHaveBeenCalledWith(expect.any(Function), expect.objectContaining({ width: '95vw' })); + }); + + it('should update query params on page change', () => { + const navigateSpy = routerMock.navigate as jest.Mock; + component.onPageChange({ first: 20, rows: 10 } as TablePageEvent); + expect(navigateSpy).toHaveBeenCalledWith([], expect.objectContaining({ queryParamsHandling: 'merge' })); + }); + + it('should update query params on sort', () => { + const navigateSpy = routerMock.navigate as jest.Mock; + component.onSort({ field: 'title', order: -1 }); + expect(navigateSpy).toHaveBeenCalled(); + }); + + it('updateQueryParams should send expected query params (isSearch=false)', () => { + const navigateSpy = routerMock.navigate as jest.Mock; + + component.tableParams.update((c) => ({ ...c, rows: 10, firstRowIndex: 20 })); + component.sortColumn.set('title'); + component.sortOrder.set(-1); + component.searchControl.setValue('hello'); + + component.updateQueryParams(false); + + expect(navigateSpy).toHaveBeenCalledWith( + [], + expect.objectContaining({ + queryParamsHandling: 'merge', + queryParams: expect.objectContaining({ + page: 3, + rows: 10, + search: 'hello', + sortField: 'title', + sortOrder: -1, + }), + }) + ); + }); + + it('updateQueryParams should reset page to 1 when isSearch=true', () => { + const navigateSpy = routerMock.navigate as jest.Mock; + + component.tableParams.update((c) => ({ ...c, rows: 25, firstRowIndex: 50 })); + component.updateQueryParams(true); + + expect(navigateSpy).toHaveBeenCalledWith( + [], + expect.objectContaining({ queryParams: expect.objectContaining({ page: 1, rows: 25 }) }) + ); + }); + + it('createFilters should map control and sort signals', () => { + component.searchControl.setValue('query'); + component.sortColumn.set('title'); + component.sortOrder.set(-1); + + const filters = component.createFilters(); + expect(filters).toEqual({ + searchValue: 'query', + searchFields: ['title'], + sortColumn: 'title', + sortOrder: -1, + }); + }); + + it('fetchProjects should dispatch getMyProjects with computed page and filters', () => { + (component as any).actions = { ...component['actions'], getMyProjects: jest.fn() }; + + component.tableParams.update((c) => ({ ...c, rows: 15, firstRowIndex: 30 })); + + const mockFilters = { searchValue: '', searchFields: [], sortColumn: undefined, sortOrder: 1 }; + const filtersSpy = jest.spyOn(component, 'createFilters').mockReturnValue(mockFilters); + + component.fetchProjects(); + + expect(filtersSpy).toHaveBeenCalled(); + expect((component as any).actions.getMyProjects).toHaveBeenCalledWith(3, 15, mockFilters); + }); + + it('setupTotalRecordsEffect should update totalRecords from selector value', () => { + expect(component.tableParams().totalRecords).toBe(0); + }); + + it('setupQueryParamsSubscription should parse params and call fetchProjects', () => { + const fetchSpy = jest.spyOn(component, 'fetchProjects'); + + const routeMock = ActivatedRouteMock.withQueryParams({ + page: 2, + rows: 5, + sortField: 'title', + sortOrder: -1, + search: 'abc', + }).build(); + + (component as any).route = routeMock as any; + + component.setupQueryParamsSubscription(); + + expect(component.tableParams().firstRowIndex).toBe(5); + expect(component.tableParams().rows).toBe(5); + expect(component.sortColumn()).toBe('title'); + expect(component.sortOrder()).toBe(-1); + expect(component.searchControl.value).toBe('abc'); + expect(fetchSpy).toHaveBeenCalled(); + }); }); diff --git a/src/testing/providers/route-provider.mock.ts b/src/testing/providers/route-provider.mock.ts new file mode 100644 index 000000000..53436b08b --- /dev/null +++ b/src/testing/providers/route-provider.mock.ts @@ -0,0 +1,77 @@ +import { BehaviorSubject, of } from 'rxjs'; + +import { ActivatedRoute } from '@angular/router'; + +export class ActivatedRouteMockBuilder { + private paramsObj: Record = {}; + private queryParamsObj: Record = {}; + private dataObj: Record = {}; + + private params$ = new BehaviorSubject>({}); + private queryParams$ = new BehaviorSubject>({}); + private data$ = new BehaviorSubject>({}); + + static create(): ActivatedRouteMockBuilder { + return new ActivatedRouteMockBuilder(); + } + + withId(id: string): ActivatedRouteMockBuilder { + this.paramsObj = { ...this.paramsObj, id }; + this.params$.next(this.paramsObj); + return this; + } + + withParams(params: Record): ActivatedRouteMockBuilder { + this.paramsObj = { ...this.paramsObj, ...params }; + this.params$.next(this.paramsObj); + return this; + } + + withQueryParams(query: Record): ActivatedRouteMockBuilder { + this.queryParamsObj = { ...this.queryParamsObj, ...query }; + this.queryParams$.next(this.queryParamsObj); + return this; + } + + withData(data: Record): ActivatedRouteMockBuilder { + this.dataObj = { ...this.dataObj, ...data }; + this.data$.next(this.dataObj); + return this; + } + + build(): Partial { + const parent = { + params: of(this.paramsObj), + snapshot: { params: this.paramsObj }, + } as Partial; + + const route: Partial = { + parent: parent as ActivatedRoute, + snapshot: { params: this.paramsObj, queryParams: this.queryParamsObj, data: this.dataObj } as any, + params: this.params$.asObservable(), + queryParams: this.queryParams$.asObservable(), + data: this.data$.asObservable(), + }; + + this.params$.next(this.paramsObj); + this.queryParams$.next(this.queryParamsObj); + this.data$.next(this.dataObj); + + return route; + } +} + +export const ActivatedRouteMock = { + withId(id: string) { + return ActivatedRouteMockBuilder.create().withId(id); + }, + withParams(params: Record) { + return ActivatedRouteMockBuilder.create().withParams(params); + }, + withQueryParams(query: Record) { + return ActivatedRouteMockBuilder.create().withQueryParams(query); + }, + withData(data: Record) { + return ActivatedRouteMockBuilder.create().withData(data); + }, +}; diff --git a/src/testing/providers/router-provider.mock.ts b/src/testing/providers/router-provider.mock.ts new file mode 100644 index 000000000..b13d86b59 --- /dev/null +++ b/src/testing/providers/router-provider.mock.ts @@ -0,0 +1,62 @@ +import { Observable, Subject } from 'rxjs'; + +import { Router, UrlTree } from '@angular/router'; + +export type RouterMockType = Partial & { events: Observable }; + +export class RouterMockBuilder { + private currentUrl = '/'; + private events$ = new Subject(); + + private navigateMock: jest.Mock, any[]> = jest.fn().mockResolvedValue(true); + private navigateByUrlMock: jest.Mock, any[]> = jest.fn().mockResolvedValue(true); + private createUrlTreeMock: jest.Mock = jest.fn(() => ({}) as UrlTree); + + static create(): RouterMockBuilder { + return new RouterMockBuilder(); + } + + withUrl(url: string): RouterMockBuilder { + this.currentUrl = url; + return this; + } + + withNavigate(mockImpl: jest.Mock, any[]>): RouterMockBuilder { + this.navigateMock = mockImpl; + return this; + } + + withNavigateByUrl(mockImpl: jest.Mock, any[]>): RouterMockBuilder { + this.navigateByUrlMock = mockImpl; + return this; + } + + withCreateUrlTree(mockImpl: jest.Mock): RouterMockBuilder { + this.createUrlTreeMock = mockImpl; + return this; + } + + emit(event: any): RouterMockBuilder { + this.events$.next(event); + return this; + } + + build(): RouterMockType { + return { + url: this.currentUrl, + events: this.events$.asObservable(), + navigate: this.navigateMock, + navigateByUrl: this.navigateByUrlMock, + createUrlTree: this.createUrlTreeMock, + } as RouterMockType; + } +} + +export const RouterMock = { + withUrl(url: string) { + return RouterMockBuilder.create().withUrl(url); + }, + create() { + return RouterMockBuilder.create(); + }, +};