diff --git a/jest.config.js b/jest.config.js index 49577581b..ccef4bc7e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -70,7 +70,6 @@ module.exports = { '/src/app/features/files/components', '/src/app/features/files/pages/community-metadata', '/src/app/features/files/pages/file-detail', - '/src/app/features/my-projects/', '/src/app/features/preprints/', '/src/app/features/project/contributors/', '/src/app/features/project/overview/', diff --git a/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.spec.ts b/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.spec.ts index 4887cb925..8352284b6 100644 --- a/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.spec.ts +++ b/src/app/features/my-projects/components/create-project-dialog/create-project-dialog.component.spec.ts @@ -1,43 +1,89 @@ -import { provideStore } from '@ngxs/store'; +import { Store } from '@ngxs/store'; -import { TranslatePipe } from '@ngx-translate/core'; -import { MockPipe, MockProvider } from 'ng-mocks'; +import { MockComponent, MockProvider } from 'ng-mocks'; import { DynamicDialogRef } from 'primeng/dynamicdialog'; -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { of } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TranslateServiceMock } from '@shared/mocks'; -import { MyResourcesState } from '@shared/stores'; -import { InstitutionsState } from '@shared/stores/institutions'; -import { RegionsState } from '@shared/stores/regions'; +import { MY_PROJECTS_TABLE_PARAMS } from '@osf/shared/constants'; +import { ProjectFormControls } from '@osf/shared/enums'; +import { MOCK_STORE } from '@osf/shared/mocks'; +import { CreateProject, GetMyProjects, MyResourcesSelectors } from '@osf/shared/stores'; +import { AddProjectFormComponent } from '@shared/components'; import { CreateProjectDialogComponent } from './create-project-dialog.component'; +import { OSFTestingModule } from '@testing/osf.testing.module'; + describe('CreateProjectDialogComponent', () => { let component: CreateProjectDialogComponent; let fixture: ComponentFixture; + let store: Store; + let dialogRef: DynamicDialogRef; + + const fillValidForm = ( + title = 'My Project', + description = 'Some description', + template = 'tmpl-1', + storageLocation = 'osfstorage', + affiliations: string[] = ['aff-1', 'aff-2'] + ) => { + component.projectForm.patchValue({ + [ProjectFormControls.Title]: title, + [ProjectFormControls.Description]: description, + [ProjectFormControls.Template]: template, + [ProjectFormControls.StorageLocation]: storageLocation, + [ProjectFormControls.Affiliations]: affiliations, + }); + }; beforeEach(async () => { + (MOCK_STORE.selectSignal as jest.Mock).mockImplementation((selector) => { + if (selector === MyResourcesSelectors.isProjectSubmitting) return () => false; + return () => undefined; + }); + await TestBed.configureTestingModule({ - imports: [CreateProjectDialogComponent, MockPipe(TranslatePipe)], - providers: [ - provideStore([MyResourcesState, InstitutionsState, RegionsState]), - provideHttpClient(), - provideHttpClientTesting(), - TranslateServiceMock, - MockProvider(DynamicDialogRef), - ], + imports: [CreateProjectDialogComponent, OSFTestingModule, MockComponent(AddProjectFormComponent)], + providers: [MockProvider(Store, MOCK_STORE)], }).compileComponents(); fixture = TestBed.createComponent(CreateProjectDialogComponent); component = fixture.componentInstance; + + store = TestBed.inject(Store); + dialogRef = TestBed.inject(DynamicDialogRef); + fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should mark all controls touched and not dispatch when form is invalid', () => { + const markAllSpy = jest.spyOn(component.projectForm, 'markAllAsTouched'); + + (store.dispatch as unknown as jest.Mock).mockClear(); + + component.submitForm(); + + expect(markAllSpy).toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should submit, refresh list and close dialog when form is valid', () => { + fillValidForm('Title', 'Desc', 'Tpl', 'Storage', ['a1']); + + (MOCK_STORE.dispatch as jest.Mock).mockReturnValue(of(undefined)); + + component.submitForm(); + + expect(MOCK_STORE.dispatch).toHaveBeenCalledWith(new CreateProject('Title', 'Desc', 'Tpl', 'Storage', ['a1'])); + expect(MOCK_STORE.dispatch).toHaveBeenCalledWith(new GetMyProjects(1, MY_PROJECTS_TABLE_PARAMS.rows, {})); + expect((dialogRef as any).close).toHaveBeenCalled(); + }); }); diff --git a/src/app/features/my-projects/my-projects.component.spec.ts b/src/app/features/my-projects/my-projects.component.spec.ts index 7f1b2b689..037918721 100644 --- a/src/app/features/my-projects/my-projects.component.spec.ts +++ b/src/app/features/my-projects/my-projects.component.spec.ts @@ -1,50 +1,202 @@ -import { provideStore } from '@ngxs/store'; +import { Store } from '@ngxs/store'; -import { TranslateModule } from '@ngx-translate/core'; -import { MockProvider } from 'ng-mocks'; +import { MockComponents, MockProvider } from 'ng-mocks'; import { DialogService } from 'primeng/dynamicdialog'; import { BehaviorSubject, of } from 'rxjs'; -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MyProjectsTab } from '@osf/features/my-projects/enums'; +import { SortOrder } from '@osf/shared/enums'; import { IS_MEDIUM } from '@osf/shared/helpers'; -import { MyResourcesState } from '@shared/stores/my-resources/my-resources.state'; - -import { InstitutionsState } from '../../shared/stores/institutions'; +import { MOCK_STORE } from '@osf/shared/mocks'; +import { BookmarksSelectors, GetMyProjects, MyResourcesSelectors } from '@osf/shared/stores'; +import { MyProjectsTableComponent, SelectComponent, SubHeaderComponent } from '@shared/components'; import { MyProjectsComponent } from './my-projects.component'; -describe.skip('MyProjectsComponent', () => { +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('MyProjectsComponent', () => { let component: MyProjectsComponent; let fixture: ComponentFixture; let isMediumSubject: BehaviorSubject; + let queryParamsSubject: BehaviorSubject>; + let store: jest.Mocked; + let router: jest.Mocked; beforeEach(async () => { isMediumSubject = new BehaviorSubject(false); + queryParamsSubject = new BehaviorSubject>({}); + + (MOCK_STORE.selectSignal as jest.Mock).mockImplementation((selector) => { + if ( + selector === MyResourcesSelectors.getTotalProjects || + selector === MyResourcesSelectors.getTotalRegistrations || + selector === MyResourcesSelectors.getTotalPreprints || + selector === MyResourcesSelectors.getTotalBookmarks + ) + return () => 0; + if (selector === BookmarksSelectors.getBookmarksCollectionId) return () => null; + if ( + selector === MyResourcesSelectors.getProjects || + selector === MyResourcesSelectors.getRegistrations || + selector === MyResourcesSelectors.getPreprints || + selector === MyResourcesSelectors.getBookmarks + ) + return () => []; + return () => undefined; + }); await TestBed.configureTestingModule({ - imports: [MyProjectsComponent, TranslateModule.forRoot()], + imports: [ + MyProjectsComponent, + OSFTestingModule, + ...MockComponents(SubHeaderComponent, MyProjectsTableComponent, SelectComponent), + ], providers: [ - provideStore([MyResourcesState, InstitutionsState]), - provideHttpClient(), - provideHttpClientTesting(), - MockProvider(DialogService), - MockProvider(ActivatedRoute, { queryParams: of({}) }), + MockProvider(Store, MOCK_STORE), + MockProvider(DialogService, { open: jest.fn() }), + MockProvider(ActivatedRoute, { queryParams: queryParamsSubject.asObservable() }), + MockProvider(Router, { navigate: jest.fn() }), MockProvider(IS_MEDIUM, isMediumSubject), ], }).compileComponents(); fixture = TestBed.createComponent(MyProjectsComponent); component = fixture.componentInstance; + store = TestBed.inject(Store) as jest.Mocked; + router = TestBed.inject(Router) as jest.Mocked; + + store.dispatch.mockReturnValue(of(undefined)); + fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should update component state from query params', () => { + component.updateComponentState({ page: 2, size: 20, search: 'q', sortColumn: 'name', sortOrder: SortOrder.Desc }); + + expect(component.currentPage()).toBe(2); + expect(component.currentPageSize()).toBe(20); + expect(component.searchControl.value).toBe('q'); + expect(component.sortColumn()).toBe('name'); + expect(component.sortOrder()).toBe(SortOrder.Desc); + expect(component.tableParams().firstRowIndex).toBe(20); + expect(component.tableParams().rows).toBe(20); + }); + + it('should create filters depending on tab', () => { + const filtersProjects = component.createFilters({ + page: 1, + size: 10, + search: 's', + sortColumn: 'name', + sortOrder: SortOrder.Asc, + }); + expect(filtersProjects.searchValue).toBe('s'); + expect(filtersProjects.searchFields).toEqual(['title', 'tags', 'description']); + + component.selectedTab.set(MyProjectsTab.Preprints); + const filtersPreprints = component.createFilters({ + page: 2, + size: 25, + search: 's2', + sortColumn: 'date', + sortOrder: SortOrder.Desc, + }); + expect(filtersPreprints.searchFields).toEqual(['title', 'tags']); + }); + + it('should fetch data for projects tab and stop loading', () => { + jest.clearAllMocks(); + store.dispatch.mockReturnValue(of(undefined)); + + component.fetchDataForCurrentTab({ page: 1, size: 10, search: 's', sortColumn: 'name', sortOrder: SortOrder.Asc }); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetMyProjects)); + expect(component.isLoading()).toBe(false); + }); + + it('should handle search and update query params', () => { + jest.clearAllMocks(); + queryParamsSubject.next({ sortColumn: 'name', sortOrder: 'desc', size: '25' }); + + component.handleSearch('query'); + + expect(router.navigate).toHaveBeenCalledWith([], { + relativeTo: expect.anything(), + queryParams: { page: '1', size: '25', search: 'query', sortColumn: 'name', sortOrder: 'desc' }, + }); + }); + + it('should paginate and update query params', () => { + jest.clearAllMocks(); + queryParamsSubject.next({ sortColumn: 'title', sortOrder: 'asc' }); + + component.onPageChange({ first: 30, rows: 15 } as any); + + expect(router.navigate).toHaveBeenCalledWith([], { + relativeTo: expect.anything(), + queryParams: { page: '3', size: '15', sortColumn: 'title', sortOrder: 'asc' }, + }); + }); + + it('should sort and update query params', () => { + jest.clearAllMocks(); + + component.onSort({ field: 'updated', order: SortOrder.Desc } as any); + + expect(router.navigate).toHaveBeenCalledWith([], { + relativeTo: expect.anything(), + queryParams: { sortColumn: 'updated', sortOrder: 'desc' }, + }); + }); + + it('should clear and reset on tab change', () => { + jest.clearAllMocks(); + queryParamsSubject.next({ size: '50' }); + + component.onTabChange(1); + + expect(router.navigate).toHaveBeenCalledWith([], { + relativeTo: expect.anything(), + queryParams: { page: '1', size: '50' }, + }); + + expect(store.dispatch).toHaveBeenCalled(); + }); + + it('should open create project dialog with responsive width', () => { + const openSpy = jest.spyOn(component.dialogService, 'open'); + + isMediumSubject.next(false); + component.createProject(); + expect(openSpy).toHaveBeenCalledWith(expect.any(Function), expect.objectContaining({ width: '95vw' })); + + openSpy.mockClear(); + isMediumSubject.next(true); + component.createProject(); + expect(openSpy).toHaveBeenCalledWith(expect.any(Function), expect.objectContaining({ width: '850px' })); + }); + + it('should navigate to project and set active project', () => { + const project = { id: 'p1' } as any; + component.navigateToProject(project); + expect(component.activeProject()).toEqual(project); + expect(router.navigate).toHaveBeenCalledWith(['p1']); + }); + + it('should navigate to registry and set active project', () => { + const reg = { id: 'r1' } as any; + component.navigateToRegistry(reg); + expect(component.activeProject()).toEqual(reg); + expect(router.navigate).toHaveBeenCalledWith(['r1']); + }); }); diff --git a/src/app/shared/models/view-only-links/view-only-link-response.model.ts b/src/app/shared/models/view-only-links/view-only-link-response.model.ts index 9a90111f7..15f1bb65c 100644 --- a/src/app/shared/models/view-only-links/view-only-link-response.model.ts +++ b/src/app/shared/models/view-only-links/view-only-link-response.model.ts @@ -1,6 +1,6 @@ import { MetaJsonApi } from '../common'; -import { UserDataJsonApi } from '../user'; import { BaseNodeDataJsonApi } from '../nodes'; +import { UserDataJsonApi } from '../user'; export interface ViewOnlyLinksResponseJsonApi { data: ViewOnlyLinkJsonApi[]; diff --git a/src/testing/mocks/dynamic-dialog-ref.mock.ts b/src/testing/mocks/dynamic-dialog-ref.mock.ts new file mode 100644 index 000000000..091508d9e --- /dev/null +++ b/src/testing/mocks/dynamic-dialog-ref.mock.ts @@ -0,0 +1,8 @@ +import { DynamicDialogRef } from 'primeng/dynamicdialog'; + +export const DynamicDialogRefMock = { + provide: DynamicDialogRef, + useValue: { + close: jest.fn(), + }, +}; diff --git a/src/testing/osf.testing.module.ts b/src/testing/osf.testing.module.ts index ccd079e07..a4e376233 100644 --- a/src/testing/osf.testing.module.ts +++ b/src/testing/osf.testing.module.ts @@ -8,6 +8,7 @@ import { BrowserModule } from '@angular/platform-browser'; import { NoopAnimationsModule, provideNoopAnimations } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; +import { DynamicDialogRefMock } from './mocks/dynamic-dialog-ref.mock'; import { EnvironmentTokenMock } from './mocks/environment.token.mock'; import { StoreMock } from './mocks/store.mock'; import { ToastServiceMock } from './mocks/toast.service.mock'; @@ -31,6 +32,7 @@ import { TranslationServiceMock } from './mocks/translation.service.mock'; provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), TranslationServiceMock, + DynamicDialogRefMock, EnvironmentTokenMock, ToastServiceMock, ],