From f9da526f0ca3073f3f61c2cbae32b4853791f806 Mon Sep 17 00:00:00 2001 From: futa-ikeda <51409893+futa-ikeda@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:46:18 -0400 Subject: [PATCH 1/5] =?UTF-8?q?[ENG-10063]=20orcid=20integration=C2=A0=20(?= =?UTF-8?q?#939)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(profile-settings): Add query-param to specify a tab (#906) * [ENG-10584][ENG-10585] Allow users to disconnect existing orcid in social tab (#912) * feat(settings): Allow users to disconnect orcid in social tab * feat(settings): Add dummy connect button when no orcid is associated with user * chore(settings): move authenticated identity to own component * refactor(settings): Implement CR suggestions; Update tests * refactor(settings): Update authenticated identity test * refactor(settings): Update authenticated identity test to use OSFTestingModule * feat(settings): Allow user to connect ORCID in profile settings page (#918) * [ENG-10684] Update Authenticated Identity section (#924) * feat(settings): update authenticated identity section * style(settings): Update styles * refactor(settings): Update Authenticated Identity section * chore(settings): Update Authenticated identity section language (#930) * fix(settings): Update connectOrcid to properly logging user out (#934) --- src/app/core/services/auth.service.ts | 4 +- .../profile-information.component.ts | 3 +- .../authenticated-identity.component.html | 40 ++++++++ .../authenticated-identity.component.scss | 0 .../authenticated-identity.component.spec.ts | 71 ++++++++++++++ .../authenticated-identity.component.ts | 92 ++++++++++++++++++ .../components/social/social.component.html | 2 + .../social/social.component.spec.ts | 8 +- .../components/social/social.component.ts | 7 +- .../profile-settings.component.spec.ts | 15 ++- .../profile-settings.component.ts | 19 +++- .../enums/external-identity-status.enum.ts | 5 + .../models/user/external-identity.model.ts | 4 +- src/assets/i18n/en.json | 5 + .../images/integrations/orcid-logotype.png | Bin 0 -> 14455 bytes 15 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.html create mode 100644 src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.scss create mode 100644 src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.spec.ts create mode 100644 src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.ts create mode 100644 src/app/shared/enums/external-identity-status.enum.ts create mode 100644 src/assets/images/integrations/orcid-logotype.png diff --git a/src/app/core/services/auth.service.ts b/src/app/core/services/auth.service.ts index b51cb58b9..074e4d732 100644 --- a/src/app/core/services/auth.service.ts +++ b/src/app/core/services/auth.service.ts @@ -73,13 +73,13 @@ export class AuthService { window.location.href = loginUrl; } - logout(): void { + logout(nextUrl?: string): void { this.loaderService.show(); this.actions.clearCurrentUser(); if (isPlatformBrowser(this.platformId)) { this.cookieService.deleteAll(); - window.location.href = `${this.webUrl}/logout/?next=${encodeURIComponent('/')}`; + window.location.href = `${this.webUrl}/logout/?next=${encodeURIComponent(nextUrl || '/')}`; } } diff --git a/src/app/features/profile/components/profile-information/profile-information.component.ts b/src/app/features/profile/components/profile-information/profile-information.component.ts index da555cac9..68b9edf81 100644 --- a/src/app/features/profile/components/profile-information/profile-information.component.ts +++ b/src/app/features/profile/components/profile-information/profile-information.component.ts @@ -10,6 +10,7 @@ import { RouterLink } from '@angular/router'; import { EducationHistoryComponent } from '@osf/shared/components/education-history/education-history.component'; import { EmploymentHistoryComponent } from '@osf/shared/components/employment-history/employment-history.component'; import { SOCIAL_LINKS } from '@osf/shared/constants/social-links.const'; +import { ExternalIdentityStatus } from '@osf/shared/enums/external-identity-status.enum'; import { IS_MEDIUM } from '@osf/shared/helpers/breakpoints.tokens'; import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { UserModel } from '@osf/shared/models/user/user.model'; @@ -50,7 +51,7 @@ export class ProfileInformationComponent { orcidId = computed(() => { const orcid = this.currentUser()?.external_identity?.ORCID; - return orcid?.status?.toUpperCase() === 'VERIFIED' ? orcid.id : undefined; + return orcid?.status?.toUpperCase() === ExternalIdentityStatus.VERIFIED ? orcid.id : undefined; }); toProfileSettings() { diff --git a/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.html b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.html new file mode 100644 index 000000000..ecd67c295 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.html @@ -0,0 +1,40 @@ +
+
+

+ {{ 'settings.profileSettings.social.labels.authenticatedIdentity' | translate }} +

+
+
+
+ @if (existingOrcid()) { + + } @else { + orcid +

+

{{ 'settings.profileSettings.social.orcidWarning' | translate }}

+
+ + +
+ } +
+
+
diff --git a/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.scss b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.spec.ts b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.spec.ts new file mode 100644 index 000000000..5f2583462 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.spec.ts @@ -0,0 +1,71 @@ +import { MockProvider } from 'ng-mocks'; + +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AccountSettingsSelectors } from '@osf/features/settings/account-settings/store/account-settings.selectors'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; + +import { AuthenticatedIdentityComponent } from './authenticated-identity.component'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('AuthenticatedIdentityComponent', () => { + let component: AuthenticatedIdentityComponent; + let fixture: ComponentFixture; + let customConfirmationServiceMock: CustomConfirmationServiceMockType; + + const mockExternalIdentities = signal([ + { + id: 'ORCID', + externalId: '0001-0002-0003-0004', + status: 'VERIFIED', + }, + ]); + + beforeEach(async () => { + customConfirmationServiceMock = CustomConfirmationServiceMock.simple(); + await TestBed.configureTestingModule({ + imports: [AuthenticatedIdentityComponent, OSFTestingModule], + providers: [ + MockProvider(CustomConfirmationService, customConfirmationServiceMock), + provideMockStore({ + signals: [ + { + selector: AccountSettingsSelectors.getExternalIdentities, + value: mockExternalIdentities, + }, + ], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AuthenticatedIdentityComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show existing user ORCID when present in external identities', () => { + expect(component.existingOrcid()).toEqual('0001-0002-0003-0004'); + expect(component.orcidUrl()).toEqual('https://orcid.org/0001-0002-0003-0004'); + component.disconnectOrcid(); + expect(customConfirmationServiceMock.confirmDelete).toHaveBeenCalled(); + }); + + it('should show connect button when no existing ORCID is present in external identities', () => { + mockExternalIdentities.set([]); + fixture.detectChanges(); + + expect(component.existingOrcid()).toBeUndefined(); + expect(component.orcidUrl()).toBeNull(); + }); +}); diff --git a/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.ts b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.ts new file mode 100644 index 000000000..77619339e --- /dev/null +++ b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.ts @@ -0,0 +1,92 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Tooltip } from 'primeng/tooltip'; + +import { finalize } from 'rxjs'; + +import { NgOptimizedImage } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, OnInit } from '@angular/core'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { AuthService } from '@core/services/auth.service'; +import { ExternalIdentityStatus } from '@osf/shared/enums/external-identity-status.enum'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; +import { ToastService } from '@osf/shared/services/toast.service'; + +import { + AccountSettingsSelectors, + DeleteExternalIdentity, + GetExternalIdentities, +} from '../../../account-settings/store'; +import { ProfileSettingsTabOption } from '../../enums'; + +@Component({ + selector: 'osf-authenticated-identity', + imports: [NgOptimizedImage, Button, Tooltip, TranslatePipe], + templateUrl: './authenticated-identity.component.html', + styleUrl: './authenticated-identity.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AuthenticatedIdentityComponent implements OnInit { + private readonly authService = inject(AuthService); + private readonly environment = inject(ENVIRONMENT); + private readonly customConfirmationService = inject(CustomConfirmationService); + private readonly toastService = inject(ToastService); + private readonly loaderService = inject(LoaderService); + + private readonly ORCID_PROVIDER = 'ORCID'; + + ngOnInit() { + this.actions.getExternalIdentities(); + } + + readonly actions = createDispatchMap({ + deleteExternalIdentity: DeleteExternalIdentity, + getExternalIdentities: GetExternalIdentities, + }); + + readonly externalIdentities = select(AccountSettingsSelectors.getExternalIdentities); + + readonly orcidUrl = computed(() => { + return this.existingOrcid() ? `https://orcid.org/${this.existingOrcid()}` : null; + }); + + readonly existingOrcid = computed( + (): string | undefined => + this.externalIdentities()?.find((i) => i.id === 'ORCID' && i.status === ExternalIdentityStatus.VERIFIED) + ?.externalId + ); + + disconnectOrcid(): void { + this.customConfirmationService.confirmDelete({ + headerKey: 'settings.accountSettings.connectedIdentities.deleteDialog.header', + messageParams: { name: this.ORCID_PROVIDER }, + messageKey: 'settings.accountSettings.connectedIdentities.deleteDialog.message', + onConfirm: () => { + this.loaderService.show(); + this.actions + .deleteExternalIdentity(this.ORCID_PROVIDER) + .pipe(finalize(() => this.loaderService.hide())) + .subscribe(() => this.toastService.showSuccess('settings.accountSettings.connectedIdentities.successDelete')); + }, + }); + } + + connectOrcid(): void { + const webUrl = this.environment.webUrl; + const casUrl = this.environment.casUrl; + const finalDestination = new URL(`${webUrl}/settings/profile`); + finalDestination.searchParams.set('tab', ProfileSettingsTabOption.Social.toString()); + const casLoginUrl = new URL(`${casUrl}/login`); + casLoginUrl.search = new URLSearchParams({ + redirectOrcid: 'true', + service: `${webUrl}/login`, + next: encodeURIComponent(finalDestination.toString()), + }).toString(); + this.authService.logout(casLoginUrl.toString()); + } +} diff --git a/src/app/features/settings/profile-settings/components/social/social.component.html b/src/app/features/settings/profile-settings/components/social/social.component.html index e34553fe1..777d4fd88 100644 --- a/src/app/features/settings/profile-settings/components/social/social.component.html +++ b/src/app/features/settings/profile-settings/components/social/social.component.html @@ -1,3 +1,5 @@ + +
diff --git a/src/app/core/components/request-access/request-access.component.spec.ts b/src/app/core/components/request-access/request-access.component.spec.ts index 2c15a032f..8051fd7f9 100644 --- a/src/app/core/components/request-access/request-access.component.spec.ts +++ b/src/app/core/components/request-access/request-access.component.spec.ts @@ -1,49 +1,127 @@ -import { Store } from '@ngxs/store'; +import { MockProvider } from 'ng-mocks'; -import { TranslatePipe } from '@ngx-translate/core'; -import { MockPipe, MockProvider } from 'ng-mocks'; +import { Observable, of, throwError } from 'rxjs'; -import { of } from 'rxjs'; +import { Mock } from 'vitest'; -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { HttpErrorResponse } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AuthService } from '@core/services/auth.service'; +import { InputLimits } from '@osf/shared/constants/input-limits.const'; +import { RequestAccessService } from '@osf/shared/services/request-access.service'; import { ToastService } from '@osf/shared/services/toast.service'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { AuthServiceMock, AuthServiceMockType } from '@testing/providers/auth-service.mock'; +import { LoaderServiceMock, provideLoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; + import { RequestAccessComponent } from './request-access.component'; -describe.only('RequestAccessComponent', () => { - let component: RequestAccessComponent; +describe('RequestAccessComponent', () => { let fixture: ComponentFixture; + let component: RequestAccessComponent; + let routerMock: RouterMockType; + let requestAccessServiceMock: { requestAccessToProject: Mock }; + let loaderServiceMock: LoaderServiceMock; + let toastServiceMock: ToastServiceMockType; + let authServiceMock: AuthServiceMockType; - const mockStore: jest.Mocked = { - dispatch: jest.fn().mockResolvedValue(undefined) as any, - select: jest.fn().mockReturnValue(of(undefined)) as any, - selectSnapshot: jest.fn() as any, - reset: jest.fn() as any, - } as any; + function setup(overrides?: { + routeId?: string; + requestAccessResult?: Observable; + requestAccessError?: HttpErrorResponse; + }) { + const routeId = overrides?.routeId ?? 'project-1'; + routerMock = RouterMockBuilder.create().withNavigate(vi.fn().mockResolvedValue(true)).build(); + loaderServiceMock = new LoaderServiceMock(); + toastServiceMock = ToastServiceMock.simple(); + authServiceMock = AuthServiceMock.simple(); - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RequestAccessComponent, MockPipe(TranslatePipe)], + requestAccessServiceMock = { + requestAccessToProject: overrides?.requestAccessError + ? vi.fn().mockReturnValue(throwError(() => overrides.requestAccessError)) + : vi.fn().mockReturnValue(overrides?.requestAccessResult ?? of(void 0)), + }; + + const activatedRouteMock = ActivatedRouteMockBuilder.create().withParams({ id: routeId }).build(); + + TestBed.configureTestingModule({ + imports: [RequestAccessComponent], providers: [ - provideHttpClient(), - provideHttpClientTesting(), - MockProvider(ToastService), - MockProvider(ActivatedRoute, { params: of({}) }), + provideOSFCore(), + provideLoaderServiceMock(loaderServiceMock), + MockProvider(ActivatedRoute, activatedRouteMock), + MockProvider(Router, routerMock), + MockProvider(RequestAccessService, requestAccessServiceMock), + MockProvider(ToastService, toastServiceMock), + MockProvider(AuthService, authServiceMock), ], - }).compileComponents(); - - TestBed.overrideProvider(Store, { useValue: mockStore }); + }); fixture = TestBed.createComponent(RequestAccessComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); + + it('should expose support email and comment limit', () => { + setup(); + expect(component.supportEmail).toBe('support@test.com'); + expect(component.commentLimit).toBe(InputLimits.requestAccessComment.maxLength); + }); + + it('should render support email mailto link', () => { + setup(); + const supportLink = fixture.nativeElement.querySelector('a'); + expect(supportLink.getAttribute('href')).toBe(`mailto:${component.supportEmail}`); + expect(supportLink.textContent).toContain(component.supportEmail); + }); + + it('should request access and handle success flow', () => { + setup({ routeId: 'project-123' }); + component.comment.set('please grant access'); + component.requestAccess(); + + expect(loaderServiceMock.show).toHaveBeenCalled(); + expect(requestAccessServiceMock.requestAccessToProject).toHaveBeenCalledWith('project-123', 'please grant access'); + expect(loaderServiceMock.hide).toHaveBeenCalled(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/']); + expect(toastServiceMock.showSuccess).toHaveBeenCalledWith('requestAccess.requestedSuccessMessage'); + }); + + it('should show already requested error on 409', () => { + setup({ + requestAccessError: new HttpErrorResponse({ status: 409 }), + }); + component.requestAccess(); + + expect(loaderServiceMock.show).toHaveBeenCalled(); + expect(toastServiceMock.showError).toHaveBeenCalledWith('requestAccess.alreadyRequestedMessage'); + expect(routerMock.navigate).not.toHaveBeenCalled(); + }); + + it('should not show duplicate error for non-409 failures', () => { + setup({ + requestAccessError: new HttpErrorResponse({ status: 500 }), + }); + component.requestAccess(); + + expect(toastServiceMock.showError).not.toHaveBeenCalled(); + expect(routerMock.navigate).not.toHaveBeenCalled(); + }); + + it('should logout on switch account', () => { + setup(); + component.switchAccount(); + expect(authServiceMock.logout).toHaveBeenCalled(); + }); }); diff --git a/src/app/core/components/resource-is-spammed/resource-is-spammed.component.spec.ts b/src/app/core/components/resource-is-spammed/resource-is-spammed.component.spec.ts index f2f4c8d00..856cfe540 100644 --- a/src/app/core/components/resource-is-spammed/resource-is-spammed.component.spec.ts +++ b/src/app/core/components/resource-is-spammed/resource-is-spammed.component.spec.ts @@ -1,9 +1,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ResourceIsSpammedComponent } from './resource-is-spammed.component'; - import { provideOSFCore } from '@testing/osf.testing.provider'; +import { ResourceIsSpammedComponent } from './resource-is-spammed.component'; + describe('ResourceIsSpammedComponent', () => { let component: ResourceIsSpammedComponent; let fixture: ComponentFixture; diff --git a/src/app/core/components/sidenav/sidenav.component.spec.ts b/src/app/core/components/sidenav/sidenav.component.spec.ts index 0f5064358..e4037a28d 100644 --- a/src/app/core/components/sidenav/sidenav.component.spec.ts +++ b/src/app/core/components/sidenav/sidenav.component.spec.ts @@ -2,18 +2,21 @@ import { MockComponent } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideOSFCore } from '@testing/osf.testing.provider'; + import { NavMenuComponent } from '../nav-menu/nav-menu.component'; import { SidenavComponent } from './sidenav.component'; -describe('SidenavDComponent', () => { +describe('SidenavComponent', () => { let component: SidenavComponent; let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ + beforeEach(() => { + TestBed.configureTestingModule({ imports: [SidenavComponent, MockComponent(NavMenuComponent)], - }).compileComponents(); + providers: [provideOSFCore()], + }); fixture = TestBed.createComponent(SidenavComponent); component = fixture.componentInstance; diff --git a/src/app/core/components/topnav/topnav.component.html b/src/app/core/components/topnav/topnav.component.html index e1e0892ec..8b03afaff 100644 --- a/src/app/core/components/topnav/topnav.component.html +++ b/src/app/core/components/topnav/topnav.component.html @@ -1,11 +1,11 @@ - - + +
+ - + +
- - + +
+ - + +
- - + +
+ - + +
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 5c1caaab4..016753ab2 100644 --- a/src/app/features/my-projects/my-projects.component.spec.ts +++ b/src/app/features/my-projects/my-projects.component.spec.ts @@ -1,11 +1,16 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; -import { BehaviorSubject } from 'rxjs'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { of, Subject } from 'rxjs'; + +import { Mock } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; -import { MyProjectsTab } from '@osf/features/my-projects/enums'; import { MyProjectsTableComponent } from '@osf/shared/components/my-projects-table/my-projects-table.component'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; import { SelectComponent } from '@osf/shared/components/select/select.component'; @@ -14,102 +19,210 @@ import { SortOrder } from '@osf/shared/enums/sort-order.enum'; import { IS_MEDIUM } from '@osf/shared/helpers/breakpoints.tokens'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ProjectRedirectDialogService } from '@osf/shared/services/project-redirect-dialog.service'; -import { BookmarksSelectors } from '@osf/shared/stores/bookmarks'; -import { MyResourcesSelectors } from '@osf/shared/stores/my-resources'; - -import { MyProjectsComponent } from './my-projects.component'; - -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { BookmarksSelectors, GetBookmarksCollectionId } from '@osf/shared/stores/bookmarks'; +import { ClearMyResources, MyResourcesSelectors } from '@osf/shared/stores/my-resources'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { CustomDialogServiceMock, CustomDialogServiceMockType } from '@testing/providers/custom-dialog-provider.mock'; +import { + MyProjectsQueryServiceMock, + MyProjectsQueryServiceMockType, +} from '@testing/providers/my-projects-query.service.mock'; +import { + MyProjectsTableParamsServiceMock, + MyProjectsTableParamsServiceMockType, +} from '@testing/providers/my-projects-table-params.service.mock'; +import { + ProjectRedirectDialogServiceMock, + ProjectRedirectDialogServiceMockType, +} from '@testing/providers/project-redirect-dialog.service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; + +import { PROJECT_FILTER_OPTIONS } from './constants/project-filter-options.const'; +import { MyProjectsQueryService } from './services/my-projects-query.service'; +import { MyProjectsTableParamsService } from './services/my-projects-table-params.service'; +import { CreateProjectDialogComponent } from './components'; +import { MyProjectsTab } from './enums'; +import { MyProjectsComponent } from './my-projects.component'; describe('MyProjectsComponent', () => { let component: MyProjectsComponent; let fixture: ComponentFixture; - let mockRouter: ReturnType; - let mockActivatedRoute: ReturnType; - let isMediumSubject: BehaviorSubject; - - beforeEach(async () => { - isMediumSubject = new BehaviorSubject(false); - mockActivatedRoute = ActivatedRouteMockBuilder.create().withQueryParams({ tab: '1' }).build(); - mockRouter = RouterMockBuilder.create().build(); - - await TestBed.configureTestingModule({ + let store: Store; + let routerMock: RouterMockType; + let customDialogService: CustomDialogServiceMockType; + let projectRedirectDialogService: ProjectRedirectDialogServiceMockType; + let queryServiceMock: MyProjectsQueryServiceMockType; + let tableParamsServiceMock: MyProjectsTableParamsServiceMockType; + + const projectItem = { + id: 'p1', + type: 'nodes', + title: 'Project 1', + dateCreated: '2024-01-01', + dateModified: '2024-01-02', + isPublic: true, + contributors: [], + }; + + const defaultSignals: SignalOverride[] = [ + { selector: MyResourcesSelectors.getProjects, value: [projectItem] }, + { selector: MyResourcesSelectors.getRegistrations, value: [] }, + { selector: MyResourcesSelectors.getPreprints, value: [] }, + { selector: MyResourcesSelectors.getTotalProjects, value: 1 }, + { selector: MyResourcesSelectors.getTotalRegistrations, value: 0 }, + { selector: MyResourcesSelectors.getTotalPreprints, value: 0 }, + { selector: BookmarksSelectors.getBookmarks, value: [] }, + { selector: BookmarksSelectors.getBookmarksCollectionId, value: 'bookmark-collection-id' }, + { selector: BookmarksSelectors.getBookmarksTotalCount, value: 0 }, + ]; + + function setup(selectorOverrides?: SignalOverride[]) { + routerMock = RouterMockBuilder.create().build(); + customDialogService = CustomDialogServiceMock.simple(); + projectRedirectDialogService = ProjectRedirectDialogServiceMock.simple(); + queryServiceMock = MyProjectsQueryServiceMock.create() + .withRawParams({ tab: '1', page: '1', size: '10' }) + .withQueryModel({ + page: 1, + size: 10, + search: '', + sortColumn: '', + sortOrder: SortOrder.Asc, + }) + .withSelectedTab(MyProjectsTab.Projects) + .build(); + tableParamsServiceMock = MyProjectsTableParamsServiceMock.simple(); + const routeMock = ActivatedRouteMockBuilder.create().withQueryParams({ tab: '1', page: '1', size: '10' }).build(); + + TestBed.configureTestingModule({ imports: [ MyProjectsComponent, - OSFTestingModule, - ...MockComponents(SubHeaderComponent, MyProjectsTableComponent, SelectComponent, SearchInputComponent), + ...MockComponents(SubHeaderComponent, MyProjectsTableComponent, SearchInputComponent, SelectComponent), ], providers: [ + provideOSFCore(), + MockProvider(ActivatedRoute, routeMock), + MockProvider(Router, routerMock), + MockProvider(CustomDialogService, customDialogService), + MockProvider(ProjectRedirectDialogService, projectRedirectDialogService), + MockProvider(MyProjectsQueryService, queryServiceMock), + MockProvider(MyProjectsTableParamsService, tableParamsServiceMock), + MockProvider(IS_MEDIUM, of(false)), provideMockStore({ - signals: [ - { selector: MyResourcesSelectors.getTotalProjects, value: 0 }, - { selector: MyResourcesSelectors.getTotalRegistrations, value: 0 }, - { selector: MyResourcesSelectors.getTotalPreprints, value: 0 }, - { selector: BookmarksSelectors.getBookmarksTotalCount, value: 0 }, - { selector: BookmarksSelectors.getBookmarksCollectionId, value: null }, - { selector: MyResourcesSelectors.getProjects, value: [] }, - { selector: MyResourcesSelectors.getRegistrations, value: [] }, - { selector: MyResourcesSelectors.getPreprints, value: [] }, - { selector: BookmarksSelectors.getBookmarks, value: [] }, - ], + signals: mergeSignalOverrides(defaultSignals, selectorOverrides), }), - { provide: ActivatedRoute, useValue: mockActivatedRoute }, - { provide: Router, useValue: mockRouter }, - MockProvider(CustomDialogService), - MockProvider(IS_MEDIUM, isMediumSubject), - MockProvider(ProjectRedirectDialogService), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(MyProjectsComponent); component = fixture.componentInstance; fixture.detectChanges(); + } + + afterEach(() => { + vi.useRealTimers(); }); it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should fetch data for projects tab', () => { - expect(component.selectedTab()).toBe(MyProjectsTab.Projects); - expect(component.isLoading()).toBe(false); + it('should dispatch get bookmarks collection id on init', () => { + setup(); + expect(store.dispatch).toHaveBeenCalledWith(new GetBookmarksCollectionId()); }); - it('should paginate and update query params', () => { - component.onPageChange({ first: 30, rows: 15 } as any); + it('should delegate page changes to query service', () => { + setup(); + component.onPageChange({ first: 20, rows: 10 } as { first: number; rows: number }); - expect(mockRouter.navigate).toHaveBeenCalledWith([], { - relativeTo: mockActivatedRoute, - queryParams: { page: '3', size: '15', tab: '1' }, - }); + expect(queryServiceMock.handlePageChange).toHaveBeenCalledWith(20, 10, { tab: '1', page: '1', size: '10' }, 1); }); - it('should sort and update query params', () => { - component.onSort({ field: 'updated', order: SortOrder.Desc } as any); + it('should delegate sort changes when field exists', () => { + setup(); + component.onSort({ field: 'title', order: SortOrder.Desc } as { field: string; order: SortOrder }); - expect(mockRouter.navigate).toHaveBeenCalledWith([], { - relativeTo: mockActivatedRoute, - queryParams: { sortColumn: 'updated', sortOrder: 'desc', tab: '1' }, - }); + expect(queryServiceMock.handleSort).toHaveBeenCalledWith( + 'title', + SortOrder.Desc, + { tab: '1', page: '1', size: '10' }, + 1 + ); + }); + + it('should not delegate sort when field is missing', () => { + setup(); + component.onSort({ field: undefined, order: SortOrder.Asc } as { field?: string; order: SortOrder }); + + expect(queryServiceMock.handleSort).not.toHaveBeenCalled(); }); - it('should clear and reset on tab change', () => { - component.onTabChange(MyProjectsTab.Registrations); + it('should clear and switch tab when onTabChange receives numeric value', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.onTabChange(String(MyProjectsTab.Registrations)); + + expect(store.dispatch).toHaveBeenCalledWith(new ClearMyResources()); + expect(component.selectedTab()).toBe(MyProjectsTab.Registrations); + expect(component.selectedProjectFilterOption()).toBe(PROJECT_FILTER_OPTIONS[0].value); + expect(queryServiceMock.handleTabSwitch).toHaveBeenCalledWith( + { tab: '1', page: '1', size: '10' }, + MyProjectsTab.Registrations + ); + }); + + it('should ignore invalid tab values', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.onTabChange('not-a-number'); - expect(mockRouter.navigate).toHaveBeenCalledWith([], { - relativeTo: mockActivatedRoute, - queryParams: { page: '1', tab: '2' }, + expect(store.dispatch).not.toHaveBeenCalledWith(new ClearMyResources()); + expect(queryServiceMock.handleTabSwitch).not.toHaveBeenCalled(); + }); + + it('should open create project dialog and redirect after close result', () => { + setup(); + const onClose$ = new Subject<{ project: { id: string } }>(); + customDialogService.open.mockReturnValue({ + close: vi.fn(), + destroy: vi.fn(), + onClose: onClose$.asObservable(), + } as unknown as DynamicDialogRef); + + component.createProject(); + onClose$.next({ project: { id: 'project-123' } }); + + expect(customDialogService.open).toHaveBeenCalledWith(CreateProjectDialogComponent, { + header: 'myProjects.header.createProject', + width: '850px', }); + expect(projectRedirectDialogService.showProjectRedirectDialog).toHaveBeenCalledWith('project-123'); }); - it('should navigate to project', () => { - const project = { id: 'p1' } as any; - component.navigateToProject(project); + it('should navigate to project and set active project', () => { + setup(); + + component.navigateToProject(projectItem); + + expect(component.activeProject()).toEqual(projectItem); + expect(routerMock.navigate).toHaveBeenCalledWith([projectItem.id]); + }); + + it('should delegate search handling after debounce', () => { + vi.useFakeTimers(); + setup(); + + component.searchControl.setValue('alpha'); + vi.advanceTimersByTime(300); - expect(component.activeProject()).toEqual(project); - expect(mockRouter.navigate).toHaveBeenCalledWith(['p1']); + expect(queryServiceMock.handleSearch).toHaveBeenCalledWith('alpha', { tab: '1', page: '1', size: '10' }, 1); }); }); diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts index 8e499b175..eb801ea78 100644 --- a/src/app/features/my-projects/my-projects.component.ts +++ b/src/app/features/my-projects/my-projects.component.ts @@ -147,12 +147,16 @@ export class MyProjectsComponent implements OnInit { } } - onTabChange(tabIndex: number): void { - this.actions.clearMyProjects(); - this.selectedTab.set(tabIndex); - this.selectedProjectFilterOption.set(PROJECT_FILTER_OPTIONS[0].value); - const current = this.queryService.getRawParams(); - this.queryService.handleTabSwitch(current, this.selectedTab()); + onTabChange(event: string | number | undefined): void { + const value = Number(event); + + if (!isNaN(value)) { + this.actions.clearMyProjects(); + this.selectedTab.set(value); + this.selectedProjectFilterOption.set(PROJECT_FILTER_OPTIONS[0].value); + const current = this.queryService.getRawParams(); + this.queryService.handleTabSwitch(current, this.selectedTab()); + } } onProjectFilterChange(): void { diff --git a/src/app/features/preprints/components/advisory-board/advisory-board.component.spec.ts b/src/app/features/preprints/components/advisory-board/advisory-board.component.spec.ts index 8119d7b99..ad630f94a 100644 --- a/src/app/features/preprints/components/advisory-board/advisory-board.component.spec.ts +++ b/src/app/features/preprints/components/advisory-board/advisory-board.component.spec.ts @@ -1,5 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideOSFCore } from '@testing/osf.testing.provider'; + import { AdvisoryBoardComponent } from './advisory-board.component'; describe('AdvisoryBoardComponent', () => { @@ -12,6 +14,7 @@ describe('AdvisoryBoardComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [AdvisoryBoardComponent], + providers: [provideOSFCore()], }); fixture = TestBed.createComponent(AdvisoryBoardComponent); diff --git a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.spec.ts b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.spec.ts index b09b902fa..5b8874622 100644 --- a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.spec.ts +++ b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.spec.ts @@ -1,16 +1,13 @@ -import { MockProvider } from 'ng-mocks'; - import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; +import { provideRouter } from '@angular/router'; import { ResourceType } from '@shared/enums/resource-type.enum'; import { SubjectModel } from '@shared/models/subject/subject.model'; -import { BrowseBySubjectsComponent } from './browse-by-subjects.component'; - import { SUBJECTS_MOCK } from '@testing/mocks/subject.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; + +import { BrowseBySubjectsComponent } from './browse-by-subjects.component'; describe('BrowseBySubjectsComponent', () => { let component: BrowseBySubjectsComponent; @@ -26,7 +23,7 @@ describe('BrowseBySubjectsComponent', () => { }) { TestBed.configureTestingModule({ imports: [BrowseBySubjectsComponent], - providers: [provideOSFCore(), MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().build())], + providers: [provideOSFCore(), provideRouter([])], }); fixture = TestBed.createComponent(BrowseBySubjectsComponent); diff --git a/src/app/features/preprints/components/index.ts b/src/app/features/preprints/components/index.ts deleted file mode 100644 index 17ba1617f..000000000 --- a/src/app/features/preprints/components/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -export { AdvisoryBoardComponent } from './advisory-board/advisory-board.component'; -export { BrowseBySubjectsComponent } from './browse-by-subjects/browse-by-subjects.component'; -export { AdditionalInfoComponent } from './preprint-details/additional-info/additional-info.component'; -export { GeneralInformationComponent } from './preprint-details/general-information/general-information.component'; -export { ModerationStatusBannerComponent } from './preprint-details/moderation-status-banner/moderation-status-banner.component'; -export { PreprintFileSectionComponent } from './preprint-details/preprint-file-section/preprint-file-section.component'; -export { PreprintMakeDecisionComponent } from './preprint-details/preprint-make-decision/preprint-make-decision.component'; -export { PreprintMetricsInfoComponent } from './preprint-details/preprint-metrics-info/preprint-metrics-info.component'; -export { PreprintTombstoneComponent } from './preprint-details/preprint-tombstone/preprint-tombstone.component'; -export { PreprintWarningBannerComponent } from './preprint-details/preprint-warning-banner/preprint-warning-banner.component'; -export { PreprintWithdrawDialogComponent } from './preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component'; -export { ShareAndDownloadComponent } from './preprint-details/share-and-download/share-and-download.component'; -export { StatusBannerComponent } from './preprint-details/status-banner/status-banner.component'; -export { PreprintProviderFooterComponent } from './preprint-provider-footer/preprint-provider-footer.component'; -export { PreprintProviderHeroComponent } from './preprint-provider-hero/preprint-provider-hero.component'; -export { PreprintServicesComponent } from './preprint-services/preprint-services.component'; -export { PreprintsHelpDialogComponent } from './preprints-help-dialog/preprints-help-dialog.component'; -export { AuthorAssertionsStepComponent } from './stepper/author-assertion-step/author-assertions-step.component'; -export { FileStepComponent } from './stepper/file-step/file-step.component'; -export { PreprintsMetadataStepComponent } from './stepper/preprints-metadata-step/preprints-metadata-step.component'; -export { ReviewStepComponent } from './stepper/review-step/review-step.component'; -export { SupplementsStepComponent } from './stepper/supplements-step/supplements-step.component'; -export { TitleAndAbstractStepComponent } from './stepper/title-and-abstract-step/title-and-abstract-step.component'; diff --git a/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.spec.ts b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.spec.ts index 4f5bff759..35951687d 100644 --- a/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.spec.ts @@ -9,14 +9,14 @@ import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; import { LicenseDisplayComponent } from '@osf/shared/components/license-display/license-display.component'; import { SubjectsSelectors } from '@osf/shared/stores/subjects'; -import { CitationSectionComponent } from '../citation-section/citation-section.component'; - -import { AdditionalInfoComponent } from './additional-info.component'; - import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; +import { CitationSectionComponent } from '../citation-section/citation-section.component'; + +import { AdditionalInfoComponent } from './additional-info.component'; + describe('AdditionalInfoComponent', () => { let component: AdditionalInfoComponent; let fixture: ComponentFixture; @@ -52,32 +52,33 @@ describe('AdditionalInfoComponent', () => { fixture.detectChanges(); } - beforeEach(() => { - setup(); - }); - it('should create', () => { + setup(); expect(component).toBeTruthy(); }); it('should return license from preprint when available', () => { + setup(); const license = component.license(); expect(license).toBe(PREPRINT_MOCK.embeddedLicense); }); it('should return license options record from preprint when available', () => { + setup(); const licenseOptionsRecord = component.licenseOptionsRecord(); expect(licenseOptionsRecord).toEqual(PREPRINT_MOCK.licenseOptions); }); it('should have skeleton data array with 5 null elements', () => { + setup(); expect(component.skeletonData).toHaveLength(5); expect(component.skeletonData.every((item) => item === null)).toBe(true); }); it('should navigate to search page with tag when tagClicked is called', () => { + setup(); const router = TestBed.inject(Router); - const navigateSpy = jest.spyOn(router, 'navigate'); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); component.tagClicked('test-tag'); @@ -87,6 +88,7 @@ describe('AdditionalInfoComponent', () => { }); it('should not render DOI link when articleDoiLink is missing', () => { + setup(); const doiLink = fixture.nativeElement.querySelector('a[href*="doi.org"]'); expect(doiLink).toBeNull(); }); diff --git a/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.spec.ts b/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.spec.ts index 614674f66..fbb1be8f3 100644 --- a/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.spec.ts @@ -2,7 +2,9 @@ import { Store } from '@ngxs/store'; import { SelectChangeEvent, SelectFilterEvent } from 'primeng/select'; -import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { Mock } from 'vitest'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ResourceType } from '@shared/enums/resource-type.enum'; import { @@ -12,12 +14,12 @@ import { GetStyledCitation, } from '@shared/stores/citations'; -import { CitationSectionComponent } from './citation-section.component'; - import { CITATION_STYLES_MOCK } from '@testing/mocks/citation-style.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; +import { CitationSectionComponent } from './citation-section.component'; + describe('CitationSectionComponent', () => { let component: CitationSectionComponent; let fixture: ComponentFixture; @@ -62,10 +64,14 @@ describe('CitationSectionComponent', () => { if (overrides.detectChanges ?? true) { fixture.detectChanges(); - (store.dispatch as jest.Mock).mockClear(); + (store.dispatch as Mock).mockClear(); } } + afterEach(() => { + vi.useRealTimers(); + }); + it('should create', () => { setup(); expect(component).toBeTruthy(); @@ -123,9 +129,10 @@ describe('CitationSectionComponent', () => { ); }); - it('should debounce and deduplicate citation style filter dispatches', fakeAsync(() => { + it('should debounce and deduplicate citation style filter dispatches', () => { + vi.useFakeTimers(); setup(); - const preventDefault = jest.fn(); + const preventDefault = vi.fn(); const eventApa: SelectFilterEvent = { originalEvent: { preventDefault } as unknown as Event, filter: 'apa', @@ -136,25 +143,25 @@ describe('CitationSectionComponent', () => { expect(preventDefault).toHaveBeenCalled(); expect(store.dispatch).not.toHaveBeenCalled(); - tick(299); + vi.advanceTimersByTime(299); expect(store.dispatch).not.toHaveBeenCalled(); - tick(1); + vi.advanceTimersByTime(1); expect(store.dispatch).toHaveBeenCalledWith(new GetCitationStyles('apa')); expect(store.dispatch).toHaveBeenCalledTimes(1); - (store.dispatch as jest.Mock).mockClear(); + (store.dispatch as Mock).mockClear(); component.handleCitationStyleFilterSearch(eventApa); - tick(300); + vi.advanceTimersByTime(300); expect(store.dispatch).not.toHaveBeenCalled(); const eventMla: SelectFilterEvent = { - originalEvent: { preventDefault: jest.fn() } as unknown as Event, + originalEvent: { preventDefault: vi.fn() } as unknown as Event, filter: 'mla', }; component.handleCitationStyleFilterSearch(eventMla); - tick(300); + vi.advanceTimersByTime(300); expect(store.dispatch).toHaveBeenCalledWith(new GetCitationStyles('mla')); - })); + }); }); diff --git a/src/app/features/preprints/components/preprint-details/general-information/general-information.component.spec.ts b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.spec.ts index 6598d2107..9b38a987b 100644 --- a/src/app/features/preprints/components/preprint-details/general-information/general-information.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.spec.ts @@ -2,6 +2,8 @@ import { Store } from '@ngxs/store'; import { MockComponents, MockProvider } from 'ng-mocks'; +import { Mock } from 'vitest'; + import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -10,6 +12,7 @@ import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; +import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; import { ResourceType } from '@shared/enums/resource-type.enum'; import { ContributorsSelectors, @@ -19,19 +22,18 @@ import { } from '@shared/stores/contributors'; import { FetchResourceInstitutions, InstitutionsSelectors } from '@shared/stores/institutions'; -import { PreprintAuthorAssertionsComponent } from '../preprint-author-assertions/preprint-author-assertions.component'; -import { PreprintDoiSectionComponent } from '../preprint-doi-section/preprint-doi-section.component'; - -import { GeneralInformationComponent } from './general-information.component'; - import { MOCK_CONTRIBUTOR } from '@testing/mocks/contributors.mock'; import { MOCK_INSTITUTION } from '@testing/mocks/institution.mock'; import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { MockComponentWithSignal } from '@testing/providers/component-provider.mock'; import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; +import { PreprintAuthorAssertionsComponent } from '../preprint-author-assertions/preprint-author-assertions.component'; +import { PreprintDoiSectionComponent } from '../preprint-doi-section/preprint-doi-section.component'; + +import { GeneralInformationComponent } from './general-information.component'; + describe('GeneralInformationComponent', () => { let component: GeneralInformationComponent; let fixture: ComponentFixture; @@ -54,9 +56,9 @@ describe('GeneralInformationComponent', () => { ContributorsListComponent, IconComponent, PreprintDoiSectionComponent, - PreprintAuthorAssertionsComponent + PreprintAuthorAssertionsComponent, + TruncatedTextComponent ), - MockComponentWithSignal('osf-truncated-text'), ], providers: [ provideOSFCore(), @@ -107,7 +109,6 @@ describe('GeneralInformationComponent', () => { it('should dispatch constructor effect actions when preprint id exists', () => { setup(); fixture.detectChanges(); - TestBed.flushEffects(); expect(store.dispatch).toHaveBeenCalledWith( new GetBibliographicContributors(PREPRINT_MOCK.id, ResourceType.Preprint) ); @@ -118,15 +119,14 @@ describe('GeneralInformationComponent', () => { setup({ selectorOverrides: [{ selector: PreprintSelectors.getPreprint, value: undefined }], }); - (store.dispatch as jest.Mock).mockClear(); + (store.dispatch as Mock).mockClear(); fixture.detectChanges(); - TestBed.flushEffects(); expect(store.dispatch).not.toHaveBeenCalled(); }); it('should dispatch load more contributors with preprint id', () => { setup(); - (store.dispatch as jest.Mock).mockClear(); + (store.dispatch as Mock).mockClear(); component.handleLoadMoreContributors(); expect(store.dispatch).toHaveBeenCalledWith( new LoadMoreBibliographicContributors(PREPRINT_MOCK.id, ResourceType.Preprint) @@ -137,7 +137,7 @@ describe('GeneralInformationComponent', () => { setup({ selectorOverrides: [{ selector: PreprintSelectors.getPreprint, value: undefined }], }); - (store.dispatch as jest.Mock).mockClear(); + (store.dispatch as Mock).mockClear(); component.handleLoadMoreContributors(); expect(store.dispatch).toHaveBeenCalledWith( new LoadMoreBibliographicContributors(undefined, ResourceType.Preprint) @@ -146,14 +146,14 @@ describe('GeneralInformationComponent', () => { it('should reset contributors state on destroy in browser', () => { setup({ platformId: 'browser' }); - (store.dispatch as jest.Mock).mockClear(); + (store.dispatch as Mock).mockClear(); component.ngOnDestroy(); expect(store.dispatch).toHaveBeenCalledWith(new ResetContributorsState()); }); it('should not reset contributors state on destroy in server platform', () => { setup({ platformId: 'server' }); - (store.dispatch as jest.Mock).mockClear(); + (store.dispatch as Mock).mockClear(); component.ngOnDestroy(); expect(store.dispatch).not.toHaveBeenCalledWith(new ResetContributorsState()); }); diff --git a/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.spec.ts b/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.spec.ts index 063906282..56eaeeab5 100644 --- a/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.spec.ts @@ -9,8 +9,6 @@ import { PreprintProviderDetails, PreprintRequest } from '@osf/features/preprint import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; -import { ModerationStatusBannerComponent } from './moderation-status-banner.component'; - import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; import { PREPRINT_REQUEST_MOCK } from '@testing/mocks/preprint-request.mock'; @@ -18,6 +16,8 @@ import { REVIEW_ACTION_MOCK } from '@testing/mocks/review-action.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; +import { ModerationStatusBannerComponent } from './moderation-status-banner.component'; + describe('ModerationStatusBannerComponent', () => { let component: ModerationStatusBannerComponent; let fixture: ComponentFixture; diff --git a/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.spec.ts index 5bb0513e0..7b74a91a1 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.spec.ts @@ -2,11 +2,11 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ApplicabilityStatus, PreregLinkInfo } from '@osf/features/preprints/enums'; -import { PreprintAuthorAssertionsComponent } from './preprint-author-assertions.component'; - import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; +import { PreprintAuthorAssertionsComponent } from './preprint-author-assertions.component'; + describe('PreprintAuthorAssertionsComponent', () => { let component: PreprintAuthorAssertionsComponent; let fixture: ComponentFixture; diff --git a/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.spec.ts index 20e2128c2..206ad9772 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.spec.ts @@ -3,13 +3,13 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { PreprintModel } from '@osf/features/preprints/models'; import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; -import { PreprintDoiSectionComponent } from './preprint-doi-section.component'; - import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { PreprintDoiSectionComponent } from './preprint-doi-section.component'; + describe('PreprintDoiSectionComponent', () => { let component: PreprintDoiSectionComponent; let fixture: ComponentFixture; @@ -48,32 +48,32 @@ describe('PreprintDoiSectionComponent', () => { }); it('should return empty array when no version IDs', () => { - jest.spyOn(component, 'preprintVersionIds').mockReturnValue([]); + vi.spyOn(component, 'preprintVersionIds').mockReturnValue([]); const options = component.versionsDropdownOptions(); expect(options).toEqual([]); }); it('should return empty array when version IDs are undefined', () => { - jest.spyOn(component, 'preprintVersionIds').mockReturnValue(undefined as unknown as string[]); + vi.spyOn(component, 'preprintVersionIds').mockReturnValue(undefined as unknown as string[]); const options = component.versionsDropdownOptions(); expect(options).toEqual([]); }); it('should emit preprintVersionSelected when selecting different version', () => { - const emitSpy = jest.spyOn(component.preprintVersionSelected, 'emit'); + const emitSpy = vi.spyOn(component.preprintVersionSelected, 'emit'); component.selectPreprintVersion('version-2'); expect(emitSpy).toHaveBeenCalledWith('version-2'); }); it('should not emit when selecting current preprint version', () => { - const emitSpy = jest.spyOn(component.preprintVersionSelected, 'emit'); + const emitSpy = vi.spyOn(component.preprintVersionSelected, 'emit'); component.selectPreprintVersion('preprint-1'); expect(emitSpy).not.toHaveBeenCalled(); }); it('should not emit when current preprint is unavailable', () => { - jest.spyOn(component, 'preprint').mockReturnValue(undefined as unknown as PreprintModel); - const emitSpy = jest.spyOn(component.preprintVersionSelected, 'emit'); + vi.spyOn(component, 'preprint').mockReturnValue(undefined as unknown as PreprintModel); + const emitSpy = vi.spyOn(component.preprintVersionSelected, 'emit'); component.selectPreprintVersion('version-2'); expect(emitSpy).not.toHaveBeenCalled(); }); diff --git a/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.spec.ts index 04fc5152f..5b90f6653 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.spec.ts @@ -11,13 +11,13 @@ import { IS_LARGE, IS_MEDIUM } from '@osf/shared/helpers/breakpoints.tokens'; import { FileVersionModel } from '@shared/models/files/file-version.model'; import { DataciteService } from '@shared/services/datacite/datacite.service'; -import { PreprintFileSectionComponent } from './preprint-file-section.component'; - import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { DataciteServiceMockBuilder, DataciteServiceMockType } from '@testing/providers/datacite.service.mock'; import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; +import { PreprintFileSectionComponent } from './preprint-file-section.component'; + describe('PreprintFileSectionComponent', () => { let component: PreprintFileSectionComponent; let fixture: ComponentFixture; @@ -127,14 +127,14 @@ describe('PreprintFileSectionComponent', () => { it('should return empty array when no file versions', () => { setup(); - jest.spyOn(component, 'fileVersions').mockReturnValue([]); + vi.spyOn(component, 'fileVersions').mockReturnValue([]); const menuItems = component.versionMenuItems(); expect(menuItems).toEqual([]); }); it('should return empty array when file versions are undefined', () => { setup(); - jest.spyOn(component, 'fileVersions').mockReturnValue(undefined as unknown as typeof mockFileVersions); + vi.spyOn(component, 'fileVersions').mockReturnValue(undefined as unknown as typeof mockFileVersions); const menuItems = component.versionMenuItems(); expect(menuItems).toEqual([]); }); @@ -165,7 +165,7 @@ describe('PreprintFileSectionComponent', () => { expect(menuItems.length).toBeGreaterThan(0); const versionCommand = menuItems[0].command!; - jest.spyOn(component, 'logDownload'); + vi.spyOn(component, 'logDownload'); versionCommand(); diff --git a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.spec.ts index 09eece290..e0e30c6f3 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.spec.ts @@ -1,5 +1,9 @@ import { Store } from '@ngxs/store'; +import { MockProvider } from 'ng-mocks'; + +import { Mock } from 'vitest'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; @@ -10,20 +14,22 @@ import { SubmitReviewsDecision, } from '@osf/features/preprints/store/preprint'; -import { PreprintMakeDecisionComponent } from './preprint-make-decision.component'; - import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; import { PREPRINT_REQUEST_MOCK } from '@testing/mocks/preprint-request.mock'; import { REVIEW_ACTION_MOCK } from '@testing/mocks/review-action.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { PreprintMakeDecisionComponent } from './preprint-make-decision.component'; + describe('PreprintMakeDecisionComponent', () => { let component: PreprintMakeDecisionComponent; let fixture: ComponentFixture; let store: Store; let router: Router; + let routerMock: RouterMockType; const mockPreprint = PREPRINT_MOCK; const mockProvider = PREPRINT_PROVIDER_DETAILS_MOCK; @@ -31,10 +37,13 @@ describe('PreprintMakeDecisionComponent', () => { const mockWithdrawalRequest = PREPRINT_REQUEST_MOCK; beforeEach(() => { + routerMock = RouterMockBuilder.create().build(); + TestBed.configureTestingModule({ imports: [PreprintMakeDecisionComponent], providers: [ provideOSFCore(), + MockProvider(Router, routerMock), provideMockStore({ signals: [{ selector: PreprintSelectors.getPreprint, value: mockPreprint }], }), @@ -67,12 +76,12 @@ describe('PreprintMakeDecisionComponent', () => { }, ])('should compute label decision button for $caseName', ({ preprint, isPendingWithdrawal, expected }) => { fixture.componentRef.setInput('isPendingWithdrawal', isPendingWithdrawal); - jest.spyOn(component, 'preprint').mockReturnValue(preprint); + vi.spyOn(component, 'preprint').mockReturnValue(preprint); expect(component.labelDecisionButton()).toBe(expected); }); it('should compute label decision button for withdrawn preprint', () => { - jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Withdrawn }); + vi.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Withdrawn }); expect(component.labelDecisionButton()).toBe('preprints.details.decision.withdrawalReason'); }); @@ -96,12 +105,12 @@ describe('PreprintMakeDecisionComponent', () => { }, ])('should compute label decision dialog header for $caseName', ({ preprint, isPendingWithdrawal, expected }) => { fixture.componentRef.setInput('isPendingWithdrawal', isPendingWithdrawal); - jest.spyOn(component, 'preprint').mockReturnValue(preprint); + vi.spyOn(component, 'preprint').mockReturnValue(preprint); expect(component.labelDecisionDialogHeader()).toBe(expected); }); it('should compute label decision dialog header for withdrawn preprint', () => { - jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Withdrawn }); + vi.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Withdrawn }); expect(component.labelDecisionDialogHeader()).toBe('preprints.details.decision.header.withdrawalReason'); }); @@ -153,7 +162,7 @@ describe('PreprintMakeDecisionComponent', () => { }); it('should initialize decision and comments for non-pending preprint in constructor effect', () => { - jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Rejected }); + vi.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Rejected }); fixture.componentRef.setInput('latestAction', { ...mockLatestAction, comment: 'Updated moderator comment' }); fixture.detectChanges(); expect(component.decision()).toBe(ReviewsState.Rejected); @@ -165,7 +174,7 @@ describe('PreprintMakeDecisionComponent', () => { component.decision.set(ReviewsState.Withdrawn); component.initialReviewerComment.set('Initial'); component.reviewerComment.set('Current'); - jest.spyOn(component, 'preprint').mockReturnValue(null); + vi.spyOn(component, 'preprint').mockReturnValue(null); fixture.componentRef.setInput('latestAction', { ...mockLatestAction, comment: 'Ignored comment' }); expect(component.decision()).toBe(ReviewsState.Withdrawn); expect(component.initialReviewerComment()).toBe('Initial'); @@ -176,7 +185,7 @@ describe('PreprintMakeDecisionComponent', () => { component.decision.set(ReviewsState.Rejected); component.initialReviewerComment.set('Initial value'); component.reviewerComment.set('Current value'); - jest.spyOn(component, 'preprint').mockReturnValue(null); + vi.spyOn(component, 'preprint').mockReturnValue(null); fixture.componentRef.setInput('latestAction', { ...mockLatestAction, comment: 'Should not apply' }); @@ -243,32 +252,32 @@ describe('PreprintMakeDecisionComponent', () => { }); it('should compute label submit button when decision changed', () => { - jest.spyOn(component, 'isPendingWithdrawal').mockReturnValue(false); - jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Accepted }); - jest.spyOn(component, 'decisionChanged').mockReturnValue(true); - jest.spyOn(component, 'commentEdited').mockReturnValue(false); + vi.spyOn(component, 'isPendingWithdrawal').mockReturnValue(false); + vi.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Accepted }); + vi.spyOn(component, 'decisionChanged').mockReturnValue(true); + vi.spyOn(component, 'commentEdited').mockReturnValue(false); const label = component.labelSubmitButton(); expect(label).toBe('preprints.details.decision.submitButton.modifyDecision'); }); it('should compute label submit button when comment edited', () => { - jest.spyOn(component, 'isPendingWithdrawal').mockReturnValue(false); - jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Accepted }); - jest.spyOn(component, 'decisionChanged').mockReturnValue(false); - jest.spyOn(component, 'commentEdited').mockReturnValue(true); + vi.spyOn(component, 'isPendingWithdrawal').mockReturnValue(false); + vi.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Accepted }); + vi.spyOn(component, 'decisionChanged').mockReturnValue(false); + vi.spyOn(component, 'commentEdited').mockReturnValue(true); const label = component.labelSubmitButton(); expect(label).toBe('preprints.details.decision.submitButton.updateComment'); }); it('should compute label submit button as submit decision for pending withdrawal', () => { - jest.spyOn(component, 'isPendingWithdrawal').mockReturnValue(true); - jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Accepted }); + vi.spyOn(component, 'isPendingWithdrawal').mockReturnValue(true); + vi.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Accepted }); expect(component.labelSubmitButton()).toBe('preprints.details.decision.submitButton.submitDecision'); }); it('should compute submit button disabled when neither decision changed nor comment edited', () => { - jest.spyOn(component, 'decisionChanged').mockReturnValue(false); - jest.spyOn(component, 'commentEdited').mockReturnValue(false); + vi.spyOn(component, 'decisionChanged').mockReturnValue(false); + vi.spyOn(component, 'commentEdited').mockReturnValue(false); const disabled = component.submitButtonDisabled(); expect(disabled).toBe(true); }); @@ -286,7 +295,7 @@ describe('PreprintMakeDecisionComponent', () => { }); it('should compute reject option explanation for pre-moderation with accepted preprint', () => { - jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Accepted }); + vi.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Accepted }); const explanation = component.rejectOptionExplanation(); expect(explanation).toBe('preprints.details.decision.approve.explanation'); }); @@ -305,20 +314,20 @@ describe('PreprintMakeDecisionComponent', () => { }); it('should compute reject radio button value for published preprint', () => { - jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, isPublished: true }); + vi.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, isPublished: true }); const value = component.rejectRadioButtonValue(); expect(value).toBe(ReviewsState.Withdrawn); }); it('should handle submit method', () => { - (store.dispatch as jest.Mock).mockClear(); + (store.dispatch as Mock).mockClear(); expect(() => component.submit()).not.toThrow(); expect(store.dispatch).toHaveBeenCalled(); }); it('should not submit when preprint is missing', () => { - (store.dispatch as jest.Mock).mockClear(); - jest.spyOn(component, 'preprint').mockReturnValue(null); + (store.dispatch as Mock).mockClear(); + vi.spyOn(component, 'preprint').mockReturnValue(null); component.submit(); expect(store.dispatch).not.toHaveBeenCalled(); expect(component.saving()).toBe(false); @@ -328,7 +337,7 @@ describe('PreprintMakeDecisionComponent', () => { fixture.componentRef.setInput('isPendingWithdrawal', true); component.decision.set(ReviewsState.Rejected); component.requestDecisionJustification.set(' '); - (store.dispatch as jest.Mock).mockClear(); + (store.dispatch as Mock).mockClear(); component.submit(); @@ -349,12 +358,12 @@ describe('PreprintMakeDecisionComponent', () => { }); it('should submit pending withdrawal decision and navigate to withdrawals', () => { - const navigateSpy = jest.spyOn(router, 'navigate').mockResolvedValue(true); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); fixture.componentRef.setInput('isPendingWithdrawal', true); fixture.componentRef.setInput('latestWithdrawalRequest', { ...mockWithdrawalRequest, id: 'request-123' }); component.decision.set(ReviewsState.Accepted); component.requestDecisionJustification.set(' valid justification '); - (store.dispatch as jest.Mock).mockClear(); + (store.dispatch as Mock).mockClear(); component.submit(); @@ -366,7 +375,7 @@ describe('PreprintMakeDecisionComponent', () => { }); it('should submit edit_comment trigger when only comment changed on non-pending decision', () => { - jest.spyOn(component, 'preprint').mockReturnValue({ + vi.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Accepted, isPublished: false, @@ -375,7 +384,7 @@ describe('PreprintMakeDecisionComponent', () => { component.decision.set(ReviewsState.Accepted); component.initialReviewerComment.set('Old comment'); component.reviewerComment.set('New comment'); - (store.dispatch as jest.Mock).mockClear(); + (store.dispatch as Mock).mockClear(); component.submit(); @@ -385,14 +394,14 @@ describe('PreprintMakeDecisionComponent', () => { it('should submit reject trigger for published preprint with pending withdrawal and rejected decision', () => { fixture.componentRef.setInput('isPendingWithdrawal', true); fixture.componentRef.setInput('latestWithdrawalRequest', { ...mockWithdrawalRequest, id: 'request-456' }); - jest.spyOn(component, 'preprint').mockReturnValue({ + vi.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Accepted, isPublished: true, }); component.decision.set(ReviewsState.Rejected); component.requestDecisionJustification.set('Valid rejection reason'); - (store.dispatch as jest.Mock).mockClear(); + (store.dispatch as Mock).mockClear(); component.submit(); @@ -403,7 +412,7 @@ describe('PreprintMakeDecisionComponent', () => { it('should submit withdraw trigger for published preprint without pending withdrawal and rejected decision', () => { fixture.componentRef.setInput('isPendingWithdrawal', false); - jest.spyOn(component, 'preprint').mockReturnValue({ + vi.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Accepted, isPublished: true, @@ -411,7 +420,7 @@ describe('PreprintMakeDecisionComponent', () => { component.decision.set(ReviewsState.Rejected); component.initialReviewerComment.set('Original'); component.reviewerComment.set('Updated rejection note'); - (store.dispatch as jest.Mock).mockClear(); + (store.dispatch as Mock).mockClear(); component.submit(); @@ -423,7 +432,7 @@ describe('PreprintMakeDecisionComponent', () => { fixture.componentRef.setInput('latestWithdrawalRequest', null); component.decision.set(ReviewsState.Accepted); component.requestDecisionJustification.set('Valid justification'); - (store.dispatch as jest.Mock).mockClear(); + (store.dispatch as Mock).mockClear(); component.submit(); @@ -435,7 +444,7 @@ describe('PreprintMakeDecisionComponent', () => { component.decision.set(ReviewsState.Rejected); component.initialReviewerComment.set('Initial'); component.reviewerComment.set('Changed'); - jest.spyOn(component, 'preprint').mockReturnValue(null); + vi.spyOn(component, 'preprint').mockReturnValue(null); component.cancel(); diff --git a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.ts b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.ts index dd854f4f4..f58dccfb9 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.ts @@ -228,18 +228,17 @@ export class PreprintMakeDecisionComponent { } submit() { - // Don't remove comments const preprint = this.preprint(); if (!preprint) return; - let trigger = ''; + let trigger; if (preprint.reviewsState !== ReviewsState.Pending && this.commentEdited() && !this.decisionChanged()) { // If the submission is not pending, // the decision has not changed and the comment is edited. // the trigger would be 'edit_comment' trigger = 'edit_comment'; } else { - let actionType = ''; + let actionType; if (preprint.isPublished && this.isPendingWithdrawal()) { // if the submission is published and is pending withdrawal. // actionType would be 'reject' @@ -262,7 +261,7 @@ export class PreprintMakeDecisionComponent { trigger = this.decision() === ReviewsState.Accepted ? 'accept' : actionType; } - let comment: StringOrNull = ''; + let comment: StringOrNull; if (this.isPendingWithdrawal()) { if (trigger === 'reject') { diff --git a/src/app/features/preprints/components/preprint-details/preprint-metrics-info/preprint-metrics-info.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-metrics-info/preprint-metrics-info.component.spec.ts index 09b58dbdf..ce5d9ae38 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-metrics-info/preprint-metrics-info.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-metrics-info/preprint-metrics-info.component.spec.ts @@ -2,10 +2,10 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { PreprintMetrics } from '@osf/features/preprints/models'; -import { PreprintMetricsInfoComponent } from './preprint-metrics-info.component'; - import { provideOSFCore } from '@testing/osf.testing.provider'; +import { PreprintMetricsInfoComponent } from './preprint-metrics-info.component'; + describe('PreprintMetricsInfoComponent', () => { let component: PreprintMetricsInfoComponent; let fixture: ComponentFixture; diff --git a/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.spec.ts index 0fab7b7ff..77c690999 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-tombstone/preprint-tombstone.component.spec.ts @@ -2,6 +2,8 @@ import { Store } from '@ngxs/store'; import { MockComponents, MockProvider } from 'ng-mocks'; +import { Mock } from 'vitest'; + import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; @@ -9,6 +11,7 @@ import { Router } from '@angular/router'; import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { LicenseDisplayComponent } from '@osf/shared/components/license-display/license-display.component'; +import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; import { ResourceType } from '@shared/enums/resource-type.enum'; import { ContributorsSelectors, @@ -18,19 +21,18 @@ import { } from '@shared/stores/contributors'; import { FetchSelectedSubjects, SubjectsSelectors } from '@shared/stores/subjects'; -import { PreprintDoiSectionComponent } from '../preprint-doi-section/preprint-doi-section.component'; - -import { PreprintTombstoneComponent } from './preprint-tombstone.component'; - import { MOCK_CONTRIBUTOR } from '@testing/mocks/contributors.mock'; import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; import { SUBJECTS_MOCK } from '@testing/mocks/subject.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { MockComponentWithSignal } from '@testing/providers/component-provider.mock'; import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; +import { PreprintDoiSectionComponent } from '../preprint-doi-section/preprint-doi-section.component'; + +import { PreprintTombstoneComponent } from './preprint-tombstone.component'; + describe('PreprintTombstoneComponent', () => { let component: PreprintTombstoneComponent; let fixture: ComponentFixture; @@ -52,8 +54,12 @@ describe('PreprintTombstoneComponent', () => { TestBed.configureTestingModule({ imports: [ PreprintTombstoneComponent, - ...MockComponents(ContributorsListComponent, LicenseDisplayComponent, PreprintDoiSectionComponent), - MockComponentWithSignal('osf-truncated-text'), + ...MockComponents( + ContributorsListComponent, + LicenseDisplayComponent, + PreprintDoiSectionComponent, + TruncatedTextComponent + ), ], providers: [ provideOSFCore(), @@ -81,7 +87,7 @@ describe('PreprintTombstoneComponent', () => { store = TestBed.inject(Store); fixture.componentRef.setInput('preprintProvider', mockProvider); - (store.dispatch as jest.Mock).mockClear(); + (store.dispatch as Mock).mockClear(); } it('should create', () => { @@ -112,7 +118,7 @@ describe('PreprintTombstoneComponent', () => { it('should emit preprintVersionSelected when version is selected', () => { setup(); - const emitSpy = jest.spyOn(component.preprintVersionSelected, 'emit'); + const emitSpy = vi.spyOn(component.preprintVersionSelected, 'emit'); component.preprintVersionSelected.emit('version-1'); expect(emitSpy).toHaveBeenCalledWith('version-1'); }); @@ -120,7 +126,6 @@ describe('PreprintTombstoneComponent', () => { it('should dispatch contributor and subject fetch actions when preprint id exists', () => { setup(); fixture.detectChanges(); - TestBed.flushEffects(); expect(store.dispatch).toHaveBeenCalledWith( new GetBibliographicContributors(mockPreprint.id, ResourceType.Preprint) ); @@ -132,7 +137,6 @@ describe('PreprintTombstoneComponent', () => { selectorOverrides: [{ selector: PreprintSelectors.getPreprint, value: undefined }], }); fixture.detectChanges(); - TestBed.flushEffects(); expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetBibliographicContributors)); expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchSelectedSubjects)); }); diff --git a/src/app/features/preprints/components/preprint-details/preprint-warning-banner/preprint-warning-banner.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-warning-banner/preprint-warning-banner.component.spec.ts index 32019eae8..31350d36b 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-warning-banner/preprint-warning-banner.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-warning-banner/preprint-warning-banner.component.spec.ts @@ -1,9 +1,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { PreprintWarningBannerComponent } from './preprint-warning-banner.component'; - import { provideOSFCore } from '@testing/osf.testing.provider'; +import { PreprintWarningBannerComponent } from './preprint-warning-banner.component'; + describe('PreprintWarningBannerComponent', () => { let component: PreprintWarningBannerComponent; let fixture: ComponentFixture; diff --git a/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.spec.ts index 8d0cba677..d9fd5165d 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.spec.ts @@ -4,7 +4,9 @@ import { MockPipe, MockProvider } from 'ng-mocks'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { of, throwError } from 'rxjs'; +import { EMPTY, of } from 'rxjs'; + +import { Mock } from 'vitest'; import { TitleCasePipe } from '@angular/common'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -14,18 +16,18 @@ import { ProviderReviewsWorkflow, ReviewsState } from '@osf/features/preprints/e import { PreprintModel, PreprintProviderDetails } from '@osf/features/preprints/models'; import { WithdrawPreprint } from '@osf/features/preprints/store/preprint'; -import { PreprintWithdrawDialogComponent } from './preprint-withdraw-dialog.component'; - -import { provideDynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; import { provideOSFCore } from '@testing/osf.testing.provider'; +import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { PreprintWithdrawDialogComponent } from './preprint-withdraw-dialog.component'; + describe('PreprintWithdrawDialogComponent', () => { let component: PreprintWithdrawDialogComponent; let fixture: ComponentFixture; - let dialogRefMock: { close: jest.Mock }; + let dialogRefMock: DynamicDialogRef; let store: Store; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; @@ -57,9 +59,9 @@ describe('PreprintWithdrawDialogComponent', () => { fixture = TestBed.createComponent(PreprintWithdrawDialogComponent); component = fixture.componentInstance; store = TestBed.inject(Store); - dialogRefMock = TestBed.inject(DynamicDialogRef) as unknown as { close: jest.Mock }; + dialogRefMock = TestBed.inject(DynamicDialogRef); fixture.detectChanges(); - (store.dispatch as jest.Mock).mockClear(); + (store.dispatch as Mock).mockClear(); } it('should create', () => { @@ -132,7 +134,7 @@ describe('PreprintWithdrawDialogComponent', () => { it('should dispatch withdraw and close dialog on success', () => { setup(); component.withdrawalJustificationFormControl.setValue('Valid withdrawal justification'); - (store.dispatch as jest.Mock).mockReturnValue(of(true)); + (store.dispatch as Mock).mockReturnValue(of(true)); component.withdraw(); expect(store.dispatch).toHaveBeenCalledWith( new WithdrawPreprint(mockPreprint.id, 'Valid withdrawal justification') @@ -144,7 +146,7 @@ describe('PreprintWithdrawDialogComponent', () => { it('should reset loading and not close dialog on withdraw error', () => { setup(); component.withdrawalJustificationFormControl.setValue('Valid withdrawal justification'); - (store.dispatch as jest.Mock).mockReturnValue(throwError(() => new Error('withdraw failed'))); + (store.dispatch as Mock).mockReturnValue(EMPTY); component.withdraw(); expect(component.withdrawRequestInProgress()).toBe(false); expect(dialogRefMock.close).not.toHaveBeenCalled(); diff --git a/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.ts b/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.ts index f51a091ad..a682de294 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-withdraw-dialog/preprint-withdraw-dialog.component.ts @@ -91,7 +91,7 @@ export class PreprintWithdrawDialogComponent implements OnInit { .withdrawPreprint(this.preprint.id, withdrawalJustification) .pipe(finalize(() => this.withdrawRequestInProgress.set(false))) .subscribe({ - complete: () => this.dialogRef.close(true), + next: () => this.dialogRef.close(true), }); } diff --git a/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.spec.ts b/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.spec.ts index 753ee402d..f88b9b6f2 100644 --- a/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.spec.ts @@ -1,5 +1,7 @@ import { MockComponent, MockProvider } from 'ng-mocks'; +import { Mock } from 'vitest'; + import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -8,19 +10,19 @@ import { SocialsShareButtonComponent } from '@osf/shared/components/socials-shar import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { SocialShareService } from '@osf/shared/services/social-share.service'; -import { ShareAndDownloadComponent } from './share-and-download.component'; - import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { DataciteServiceMockBuilder, DataciteServiceMockType } from '@testing/providers/datacite.service.mock'; import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; +import { ShareAndDownloadComponent } from './share-and-download.component'; + describe('ShareAndDownloadComponent', () => { let component: ShareAndDownloadComponent; let fixture: ComponentFixture; let dataciteService: DataciteServiceMockType; - let socialShareService: { createDownloadUrl: jest.Mock }; + let socialShareService: { createDownloadUrl: Mock }; const mockPreprint = PREPRINT_MOCK; const mockProvider = PREPRINT_PROVIDER_DETAILS_MOCK; @@ -31,7 +33,7 @@ describe('ShareAndDownloadComponent', () => { function setup(overrides: SetupOverrides = {}) { dataciteService = DataciteServiceMockBuilder.create().build(); - socialShareService = { createDownloadUrl: jest.fn().mockReturnValue('https://example.com/download') }; + socialShareService = { createDownloadUrl: vi.fn().mockReturnValue('https://example.com/download') }; TestBed.configureTestingModule({ imports: [ShareAndDownloadComponent, MockComponent(SocialsShareButtonComponent)], @@ -74,8 +76,8 @@ describe('ShareAndDownloadComponent', () => { it('should open download link and log identifiable download', () => { setup(); - const focus = jest.fn(); - const openSpy = jest.spyOn(window, 'open').mockReturnValue({ focus } as unknown as Window); + const focus = vi.fn(); + const openSpy = vi.spyOn(window, 'open').mockReturnValue({ focus } as unknown as Window); component.download(); @@ -90,7 +92,7 @@ describe('ShareAndDownloadComponent', () => { setup({ selectorOverrides: [{ selector: PreprintSelectors.getPreprint, value: null }], }); - const openSpy = jest.spyOn(window, 'open'); + const openSpy = vi.spyOn(window, 'open'); component.download(); @@ -102,7 +104,7 @@ describe('ShareAndDownloadComponent', () => { it('should not open or log when not running in browser', () => { setup({ platformId: 'server' }); - const openSpy = jest.spyOn(window, 'open'); + const openSpy = vi.spyOn(window, 'open'); component.download(); @@ -114,7 +116,7 @@ describe('ShareAndDownloadComponent', () => { it('should not log when window.open fails', () => { setup(); - const openSpy = jest.spyOn(window, 'open').mockReturnValue(null); + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null); component.download(); diff --git a/src/app/features/preprints/components/preprint-details/status-banner/status-banner.component.html b/src/app/features/preprints/components/preprint-details/status-banner/status-banner.component.html index f17c38f9d..99cacaa44 100644 --- a/src/app/features/preprints/components/preprint-details/status-banner/status-banner.component.html +++ b/src/app/features/preprints/components/preprint-details/status-banner/status-banner.component.html @@ -1,4 +1,4 @@ - +