diff --git a/src/app/features/meetings/components/meetings-feature-card/meetings-feature-card.component.spec.ts b/src/app/features/meetings/components/meetings-feature-card/meetings-feature-card.component.spec.ts index 479bb16c6..dbc686b94 100644 --- a/src/app/features/meetings/components/meetings-feature-card/meetings-feature-card.component.spec.ts +++ b/src/app/features/meetings/components/meetings-feature-card/meetings-feature-card.component.spec.ts @@ -1,22 +1,51 @@ +import { TranslatePipe } from '@ngx-translate/core'; +import { MockPipe } from 'ng-mocks'; + +import { ComponentRef } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MeetingsFeatureCardComponent } from './meetings-feature-card.component'; describe('MeetingsFeatureCardComponent', () => { let component: MeetingsFeatureCardComponent; + let componentRef: ComponentRef; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [MeetingsFeatureCardComponent], + imports: [MeetingsFeatureCardComponent, MockPipe(TranslatePipe, (value) => value)], }).compileComponents(); fixture = TestBed.createComponent(MeetingsFeatureCardComponent); component = fixture.componentInstance; + componentRef = fixture.componentRef; + + componentRef.setInput('iconSrc', 'meeting-icon.svg'); + componentRef.setInput('iconAlt', 'Meeting icon'); + componentRef.setInput('titleKey', 'meetings.title'); + componentRef.setInput('descriptionKey', 'meetings.description'); + fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should render icon with correct src and alt attributes', () => { + const img: HTMLImageElement = fixture.nativeElement.querySelector('img'); + expect(img).toBeTruthy(); + expect(img.src).toContain('meeting-icon.svg'); + expect(img.alt).toBe('Meeting icon'); + }); + + it('should update icon inputs when setInput is called again', () => { + componentRef.setInput('iconAlt', 'New meeting icon'); + componentRef.setInput('iconSrc', 'new-meeting-icon.svg'); + fixture.detectChanges(); + + const img: HTMLImageElement = fixture.nativeElement.querySelector('img'); + expect(img.src).toContain('new-meeting-icon.svg'); + expect(img.alt).toBe('New meeting icon'); + }); }); diff --git a/src/app/features/meetings/meetings.component.spec.ts b/src/app/features/meetings/meetings.component.spec.ts index b51d5ddfb..083b7b0ad 100644 --- a/src/app/features/meetings/meetings.component.spec.ts +++ b/src/app/features/meetings/meetings.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { MeetingsComponent } from './meetings.component'; @@ -19,4 +20,9 @@ describe('MeetingsComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should render router outlet', () => { + const routerOutlet = fixture.debugElement.query(By.css('router-outlet')); + expect(routerOutlet).toBeTruthy(); + }); }); diff --git a/src/app/features/meetings/pages/meeting-details/meeting-details.component.spec.ts b/src/app/features/meetings/pages/meeting-details/meeting-details.component.spec.ts index d84d035a9..d1cb299a7 100644 --- a/src/app/features/meetings/pages/meeting-details/meeting-details.component.spec.ts +++ b/src/app/features/meetings/pages/meeting-details/meeting-details.component.spec.ts @@ -1,18 +1,76 @@ -import { TranslateModule } from '@ngx-translate/core'; -import { MockModule } from 'ng-mocks'; +import { Store } from '@ngxs/store'; +import { TranslatePipe } from '@ngx-translate/core'; +import { MockComponents, MockPipe, MockProvider } from 'ng-mocks'; + +import { SortEvent } from 'primeng/api'; +import { TablePageEvent } from 'primeng/table'; + +import { of } from 'rxjs'; + +import { DatePipe } from '@angular/common'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { RouterModule } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { MeetingsSelectors } from '@osf/features/meetings/store'; +import { SearchInputComponent, SubHeaderComponent } from '@shared/components'; +import { MOCK_MEETING, MOCK_MEETING_SUBMISSIONS, MOCK_STORE } from '@shared/mocks'; import { MeetingDetailsComponent } from './meeting-details.component'; +const mockActivatedRoute = { + params: of({ id: 'test-meeting-id' }), + queryParams: of({}), + snapshot: { + params: { id: 'test-meeting-id' }, + queryParams: {}, + }, +}; + +const mockRouter = { + navigate: jest.fn(), + url: '/', + createUrlTree: jest.fn(), + navigateByUrl: jest.fn(), + events: { + subscribe: jest.fn(), + }, +}; + describe('MeetingDetailsComponent', () => { let component: MeetingDetailsComponent; let fixture: ComponentFixture; beforeEach(async () => { + (MOCK_STORE.selectSignal as jest.Mock).mockImplementation((selector) => { + if (selector === MeetingsSelectors.getAllMeetingSubmissions) return () => MOCK_MEETING_SUBMISSIONS; + if (selector === MeetingsSelectors.getMeetingSubmissionsTotalCount) return () => MOCK_MEETING_SUBMISSIONS.length; + if (selector === MeetingsSelectors.isMeetingSubmissionsLoading) return () => false; + if (selector === MeetingsSelectors.getMeetingById) { + return () => (id: string) => (id === MOCK_MEETING.id ? MOCK_MEETING : null); + } + return () => null; + }); + + (MOCK_STORE.selectSnapshot as jest.Mock).mockImplementation((selector) => { + if (selector === MeetingsSelectors.getMeetingById) { + return (id: string) => (id === MOCK_MEETING.id ? MOCK_MEETING : null); + } + return () => null; + }); + await TestBed.configureTestingModule({ - imports: [MeetingDetailsComponent, MockModule(TranslateModule), MockModule(RouterModule)], + imports: [ + MeetingDetailsComponent, + ...MockComponents(SubHeaderComponent, SearchInputComponent), + MockPipe(TranslatePipe), + MockPipe(DatePipe), + ], + providers: [ + MockProvider(Store, MOCK_STORE), + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: Router, useValue: mockRouter }, + ], }).compileComponents(); fixture = TestBed.createComponent(MeetingDetailsComponent); @@ -23,4 +81,51 @@ describe('MeetingDetailsComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should initialize with default table params', () => { + expect(component.tableParams().rows).toBeDefined(); + expect(component.tableParams().firstRowIndex).toBe(0); + }); + + it('should open download link if present', () => { + const openSpy = jest.spyOn(window, 'open').mockImplementation(); + const event = { stopPropagation: jest.fn() } as unknown as Event; + component.downloadSubmission(event, MOCK_MEETING_SUBMISSIONS[0]); + expect(openSpy).toHaveBeenCalledWith('https://example.com/file.pdf', '_blank'); + openSpy.mockRestore(); + }); + + it('should not open download link if not present', () => { + const openSpy = jest.spyOn(window, 'open').mockImplementation(); + const event = { stopPropagation: jest.fn() } as unknown as Event; + component.downloadSubmission(event, MOCK_MEETING_SUBMISSIONS[1]); + expect(openSpy).not.toHaveBeenCalled(); + openSpy.mockRestore(); + }); + + it('should update query params in router on page change', () => { + const router = TestBed.inject(Router); + const navigateSpy = jest.spyOn(router, 'navigate'); + component.onPageChange({ first: 10, rows: 10 } as TablePageEvent); + expect(navigateSpy).toHaveBeenCalledWith( + [], + expect.objectContaining({ + queryParams: expect.objectContaining({ page: '2', size: '10' }), + queryParamsHandling: 'merge', + }) + ); + }); + + it('should update query params in router on sort', () => { + const router = TestBed.inject(Router); + const navigateSpy = jest.spyOn(router, 'navigate'); + component.onSort({ field: 'title', order: 1 } as SortEvent); + expect(navigateSpy).toHaveBeenCalledWith( + [], + expect.objectContaining({ + queryParams: expect.objectContaining({ sortColumn: 'title', sortOrder: 'asc' }), + queryParamsHandling: 'merge', + }) + ); + }); }); diff --git a/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.spec.ts b/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.spec.ts index 52eeb2c6e..21d1816ba 100644 --- a/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.spec.ts +++ b/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.spec.ts @@ -1,40 +1,205 @@ -import { provideStates } from '@ngxs/store'; +import { provideStore } from '@ngxs/store'; -import { TranslateModule } from '@ngx-translate/core'; -import { MockComponents, MockModule } from 'ng-mocks'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { MockComponents, MockPipe, MockProvider } from 'ng-mocks'; + +import { of } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { RouterModule } from '@angular/router'; +import { FormControl } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; -import { SearchInputComponent, SubHeaderComponent } from '@osf/shared/components'; +import { parseQueryFilterParams } from '@core/helpers'; +import { MeetingsState } from '@osf/features/meetings/store'; +import { MOCK_MEETING } from '@osf/shared/mocks'; +import { SearchInputComponent, SubHeaderComponent } from '@shared/components'; +import { TABLE_PARAMS } from '@shared/constants'; +import { SortOrder } from '@shared/enums'; -import { MeetingsState } from '../../store'; +import { MeetingsFeatureCardComponent } from '../../components'; +import { MEETINGS_FEATURE_CARDS, PARTNER_ORGANIZATIONS } from '../../constants'; import { MeetingsLandingComponent } from './meetings-landing.component'; +const mockQueryParams = { + page: 1, + size: 10, + search: '', + sortColumn: 'name', + sortOrder: SortOrder.Asc, +}; + +const mockActivatedRoute = { + queryParams: of(mockQueryParams), +}; + +const mockRouter = { + navigate: jest.fn(), +}; + describe('MeetingsLandingComponent', () => { let component: MeetingsLandingComponent; let fixture: ComponentFixture; + let router: Router; + const mockMeeting = MOCK_MEETING; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ MeetingsLandingComponent, - MockModule(TranslateModule), - MockModule(RouterModule), - ...MockComponents(SubHeaderComponent, SearchInputComponent), + ...MockComponents(SubHeaderComponent, SearchInputComponent, MeetingsFeatureCardComponent), + MockPipe(TranslatePipe), + ], + providers: [ + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, mockRouter), + MockProvider(TranslateService), + provideStore([MeetingsState]), + provideHttpClient(), + provideHttpClientTesting(), ], - providers: [provideStates([MeetingsState]), provideHttpClient(), provideHttpClientTesting()], }).compileComponents(); fixture = TestBed.createComponent(MeetingsLandingComponent); component = fixture.componentInstance; - fixture.detectChanges(); + router = TestBed.inject(Router); }); - it('should create', () => { + it('should create and have correct initial signals', () => { expect(component).toBeTruthy(); + expect(component.searchControl).toBeInstanceOf(FormControl); + expect(component.partnerOrganizations).toEqual(PARTNER_ORGANIZATIONS); + expect(component.meetingsFeatureCards).toEqual(MEETINGS_FEATURE_CARDS); + expect(component.skeletonData).toHaveLength(10); + expect(component.tableParams().rows).toBe(TABLE_PARAMS.rows); + expect(component.tableParams().firstRowIndex).toBe(0); + expect(component.currentPage()).toBe(1); + expect(component.currentPageSize()).toBe(TABLE_PARAMS.rows); + expect(component.sortColumn()).toBe(''); + expect(component.sortOrder()).toBe(SortOrder.Asc); + }); + + it('should navigate to meeting when navigateToMeeting is called', () => { + component.navigateToMeeting(mockMeeting); + expect(router.navigate).toHaveBeenCalledWith(['/meetings', '1']); + }); + + describe('router.navigate scenarios', () => { + const cases = [ + { + name: 'onPageChange', + action: (c: MeetingsLandingComponent) => c.onPageChange({ first: 40, rows: 20 }), + expected: { page: '3', size: '20' }, + }, + { + name: 'onSort ascending', + action: (c: MeetingsLandingComponent) => c.onSort({ field: 'location', order: 1 }), + expected: { sortColumn: 'location', sortOrder: 'asc' }, + }, + { + name: 'onSort descending', + action: (c: MeetingsLandingComponent) => c.onSort({ field: 'location', order: -1 }), + expected: { sortColumn: 'location', sortOrder: 'desc' }, + }, + { + name: 'onSort with bad params (order=undefined)', + action: (c: MeetingsLandingComponent) => c.onSort({ field: 'location', order: undefined }), + expected: { sortColumn: 'location', sortOrder: 'asc' }, + }, + ]; + cases.forEach(({ name, action, expected }) => { + it(`should call router.navigate with correct params: ${name}`, () => { + jest.clearAllMocks(); + action(component); + if (expected) { + expect(router.navigate).toHaveBeenCalledWith( + [], + expect.objectContaining({ + queryParams: expect.objectContaining(expected), + queryParamsHandling: 'merge', + }) + ); + } else { + expect(router.navigate).not.toHaveBeenCalled(); + } + }); + }); + }); + + it('should call router.navigate with correct params on search', () => { + jest.useFakeTimers(); + jest.clearAllMocks(); + + component.searchControl.setValue('test search'); + jest.advanceTimersByTime(450); + + expect(router.navigate).toHaveBeenCalledWith( + [], + expect.objectContaining({ + queryParams: expect.objectContaining({ search: 'test search', page: '1' }), + queryParamsHandling: 'merge', + }) + ); + }); + + it('should call router.navigate only once on second input', () => { + jest.useFakeTimers(); + jest.clearAllMocks(); + + component.searchControl.setValue('first'); + + jest.advanceTimersByTime(100); + + component.searchControl.setValue('second'); + + jest.advanceTimersByTime(350); + + expect(router.navigate).toHaveBeenCalledTimes(1); + }); + + it('should not call router.navigate if onSort called with field undefined', () => { + jest.clearAllMocks(); + component.onSort({ field: undefined, order: 1 }); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('should not update query params when sort field is undefined', () => { + jest.clearAllMocks(); + component.onSort({ field: undefined, order: 1 }); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('should call router.navigate with only provided queryParams', () => { + jest.clearAllMocks(); + component.onPageChange({ first: 0, rows: 10 }); + expect(router.navigate).toHaveBeenCalledWith( + [], + expect.objectContaining({ + queryParams: expect.objectContaining({ page: '1', size: '10' }), + queryParamsHandling: 'merge', + }) + ); + jest.clearAllMocks(); + component.onSort({ field: 'name', order: 1 }); + expect(router.navigate).toHaveBeenCalledWith( + [], + expect.objectContaining({ + queryParams: expect.objectContaining({ sortColumn: 'name', sortOrder: 'asc' }), + queryParamsHandling: 'merge', + }) + ); + }); + + it('should do nothing when queryParams is undefined', () => { + const parseQueryFilterParamsSpy = jest.spyOn({ parseQueryFilterParams }, 'parseQueryFilterParams'); + jest.spyOn(component, 'queryParams').mockReturnValue(undefined); + + fixture.detectChanges(); + + expect(parseQueryFilterParamsSpy).not.toHaveBeenCalled(); + + parseQueryFilterParamsSpy.mockRestore(); }); }); diff --git a/src/app/shared/mocks/index.ts b/src/app/shared/mocks/index.ts index abc4199b6..23d8a20fe 100644 --- a/src/app/shared/mocks/index.ts +++ b/src/app/shared/mocks/index.ts @@ -1,3 +1,4 @@ export { MOCK_USER } from './data.mock'; +export * from './meeting.mock'; export { MOCK_STORE } from './mock-store.mock'; export { TranslateServiceMock } from './translate.service.mock'; diff --git a/src/app/shared/mocks/meeting.mock.ts b/src/app/shared/mocks/meeting.mock.ts new file mode 100644 index 000000000..6a60bfcfd --- /dev/null +++ b/src/app/shared/mocks/meeting.mock.ts @@ -0,0 +1,31 @@ +import { Meeting, MeetingSubmission } from '@osf/features/meetings/models'; + +export const MOCK_MEETING: Meeting = { + id: '1', + name: 'Test Meeting', + submissionsCount: 10, + location: 'New York', + startDate: new Date('2024-01-15'), + endDate: new Date('2024-01-16'), +}; + +export const MOCK_MEETING_SUBMISSIONS: MeetingSubmission[] = [ + { + id: '1', + title: 'The Impact of Open Science on Research Collaboration', + authorName: 'John Doe', + meetingCategory: 'Open Science', + dateCreated: new Date('2024-01-15'), + downloadCount: 5, + downloadLink: 'https://example.com/file.pdf', + }, + { + id: '2', + title: "'Data Sharing Practices in Modern Biology", + authorName: 'Jane Smith', + meetingCategory: 'Biology', + dateCreated: new Date('2024-01-19'), + downloadCount: 0, + downloadLink: null, + }, +]; diff --git a/src/app/shared/mocks/mock-store.mock.ts b/src/app/shared/mocks/mock-store.mock.ts index 252a1717e..db678a7b4 100644 --- a/src/app/shared/mocks/mock-store.mock.ts +++ b/src/app/shared/mocks/mock-store.mock.ts @@ -1,4 +1,5 @@ export const MOCK_STORE = { selectSignal: jest.fn(), + selectSnapshot: jest.fn(), dispatch: jest.fn(), };