From 6a7b5575670cf8cea58e122cb014035c8b4ccb27 Mon Sep 17 00:00:00 2001 From: nsemets Date: Tue, 10 Feb 2026 19:17:39 +0200 Subject: [PATCH 1/4] test(registries): added unit tests for pages components in registries --- ...registration-custom-step.component.spec.ts | 68 ++++- .../justification.component.spec.ts | 242 ++++++++++++++---- .../justification/justification.component.ts | 127 ++++----- .../registries-landing.component.ts | 2 +- ...gistries-provider-search.component.spec.ts | 95 +++++-- .../revisions-custom-step.component.ts | 31 +-- 6 files changed, 408 insertions(+), 157 deletions(-) diff --git a/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts b/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts index b446b84b5..3c07dc4bb 100644 --- a/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts +++ b/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts @@ -1,4 +1,4 @@ -import { MockComponent } from 'ng-mocks'; +import { MockComponent, ngMocks } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; @@ -15,7 +15,10 @@ import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.moc import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -describe.skip('DraftRegistrationCustomStepComponent', () => { +const MOCK_DRAFT = { id: 'draft-1', providerId: 'prov-1', branchedFrom: { id: 'node-1', filesLink: '/files' } }; +const MOCK_STEPS_DATA = { 'question-1': 'answer-1' }; + +describe('DraftRegistrationCustomStepComponent', () => { let component: DraftRegistrationCustomStepComponent; let fixture: ComponentFixture; let mockActivatedRoute: ReturnType; @@ -32,11 +35,8 @@ describe.skip('DraftRegistrationCustomStepComponent', () => { { provide: Router, useValue: mockRouter }, provideMockStore({ signals: [ - { selector: RegistriesSelectors.getStepsData, value: {} }, - { - selector: RegistriesSelectors.getDraftRegistration, - value: { id: 'draft-1', providerId: 'prov-1', branchedFrom: { id: 'node-1', filesLink: '/files' } }, - }, + { selector: RegistriesSelectors.getStepsData, value: MOCK_STEPS_DATA }, + { selector: RegistriesSelectors.getDraftRegistration, value: MOCK_DRAFT }, { selector: RegistriesSelectors.getPagesSchema, value: [MOCK_REGISTRIES_PAGE] }, { selector: RegistriesSelectors.getStepsState, value: { 1: { invalid: false } } }, ], @@ -78,4 +78,58 @@ describe.skip('DraftRegistrationCustomStepComponent', () => { component.onNext(); expect(navigateSpy).toHaveBeenCalledWith(['../', 'review'], { relativeTo: TestBed.inject(ActivatedRoute) }); }); + + it('should pass stepsData to custom step component', () => { + const customStep = ngMocks.find(CustomStepComponent).componentInstance; + expect(customStep.stepsData).toEqual(MOCK_STEPS_DATA); + }); + + it('should pass filesLink, projectId, and provider to custom step component', () => { + const customStep = ngMocks.find(CustomStepComponent).componentInstance; + expect(customStep.filesLink).toBe('/files'); + expect(customStep.projectId).toBe('node-1'); + expect(customStep.provider).toBe('prov-1'); + }); + + it('should return empty strings when draftRegistration is null', async () => { + TestBed.resetTestingModule(); + mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1', step: '1' }).build(); + mockRouter = RouterMockBuilder.create().withUrl('/registries/prov-1/draft/draft-1/custom').build(); + + await TestBed.configureTestingModule({ + imports: [DraftRegistrationCustomStepComponent, OSFTestingModule, MockComponent(CustomStepComponent)], + providers: [ + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: Router, useValue: mockRouter }, + provideMockStore({ + signals: [ + { selector: RegistriesSelectors.getStepsData, value: {} }, + { selector: RegistriesSelectors.getDraftRegistration, value: null }, + { selector: RegistriesSelectors.getPagesSchema, value: [MOCK_REGISTRIES_PAGE] }, + { selector: RegistriesSelectors.getStepsState, value: {} }, + ], + }), + ], + }).compileComponents(); + + const nullFixture = TestBed.createComponent(DraftRegistrationCustomStepComponent); + const nullComponent = nullFixture.componentInstance; + nullFixture.detectChanges(); + + expect(nullComponent.filesLink()).toBe(''); + expect(nullComponent.provider()).toBe(''); + expect(nullComponent.projectId()).toBe(''); + }); + + it('should wrap attributes in registration_responses on update', () => { + const actionsMock = { updateDraft: jest.fn() } as any; + Object.defineProperty(component, 'actions', { value: actionsMock }); + + const attributes = { field1: 'value1', field2: ['a', 'b'] }; + component.onUpdateAction(attributes as any); + + expect(actionsMock.updateDraft).toHaveBeenCalledWith('draft-1', { + registration_responses: { field1: 'value1', field2: ['a', 'b'] }, + }); + }); }); diff --git a/src/app/features/registries/pages/justification/justification.component.spec.ts b/src/app/features/registries/pages/justification/justification.component.spec.ts index ddbee0e57..3efa9f79c 100644 --- a/src/app/features/registries/pages/justification/justification.component.spec.ts +++ b/src/app/features/registries/pages/justification/justification.component.spec.ts @@ -1,87 +1,239 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; -import { RegistriesSelectors } from '@osf/features/registries/store'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; +import { PageSchema } from '@osf/shared/models/registration/page-schema.model'; +import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; import { LoaderService } from '@osf/shared/services/loader.service'; +import { RegistriesSelectors } from '../../store'; + import { JustificationComponent } from './justification.component'; +import { createMockSchemaResponse } from '@testing/mocks/schema-response.mock'; import { OSFTestingModule } from '@testing/osf.testing.module'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +const MOCK_SCHEMA_RESPONSE = createMockSchemaResponse('resp-1', RevisionReviewStates.RevisionInProgress); + +const MOCK_PAGES: PageSchema[] = [ + { id: 'page-1', title: 'Page One', questions: [{ id: 'q1', displayText: 'Q1', required: true, responseKey: 'q1' }] }, + { id: 'page-2', title: 'Page Two', questions: [{ id: 'q2', displayText: 'Q2', required: false, responseKey: 'q2' }] }, +]; + +function buildActivatedRoute(params: Record = {}) { + return { + snapshot: { firstChild: { params } }, + firstChild: { snapshot: { params } }, + } as unknown as ActivatedRoute; +} + describe('JustificationComponent', () => { let component: JustificationComponent; let fixture: ComponentFixture; - let mockActivatedRoute: Partial; - let mockRouter: ReturnType; - - beforeEach(async () => { - mockActivatedRoute = { - snapshot: { - firstChild: { params: { id: 'rev-1', step: '0' } } as any, - } as any, - firstChild: { snapshot: { params: { id: 'rev-1', step: '0' } } } as any, - } as Partial; - mockRouter = RouterMockBuilder.create().withUrl('/registries/revisions/rev-1/justification').build(); - - await TestBed.configureTestingModule({ + let mockRouter: RouterMockType; + let routerBuilder: RouterMockBuilder; + let loaderService: jest.Mocked; + let actionsMock: { + getSchemaBlocks: jest.Mock; + clearState: jest.Mock; + getSchemaResponse: jest.Mock; + updateStepState: jest.Mock; + }; + + function setup( + options: { + routeParams?: Record; + routerUrl?: string; + schemaResponse?: SchemaResponse | null; + pages?: PageSchema[]; + stepsState?: Record; + revisionData?: Record; + } = {} + ) { + const { + routeParams = { id: 'rev-1' }, + routerUrl = '/registries/revisions/rev-1/justification', + schemaResponse = MOCK_SCHEMA_RESPONSE, + pages = MOCK_PAGES, + stepsState = {}, + revisionData = MOCK_SCHEMA_RESPONSE.revisionResponses, + } = options; + + fixture?.destroy(); + TestBed.resetTestingModule(); + + routerBuilder = RouterMockBuilder.create().withUrl(routerUrl); + mockRouter = routerBuilder.build(); + loaderService = { show: jest.fn(), hide: jest.fn() } as unknown as jest.Mocked; + + TestBed.configureTestingModule({ imports: [JustificationComponent, OSFTestingModule, ...MockComponents(StepperComponent, SubHeaderComponent)], providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), - MockProvider(Router, mockRouter), - MockProvider(LoaderService, { show: jest.fn(), hide: jest.fn() }), + { provide: ActivatedRoute, useValue: buildActivatedRoute(routeParams) }, + { provide: Router, useValue: mockRouter }, + MockProvider(LoaderService, loaderService), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getPagesSchema, value: [] }, - { selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: false, touched: false } } }, - { - selector: RegistriesSelectors.getSchemaResponse, - value: { - registrationSchemaId: 'schema-1', - revisionJustification: 'Reason', - reviewsState: 'revision_in_progress', - }, - }, - { selector: RegistriesSelectors.getSchemaResponseRevisionData, value: {} }, + { selector: RegistriesSelectors.getSchemaResponse, value: schemaResponse }, + { selector: RegistriesSelectors.getPagesSchema, value: pages }, + { selector: RegistriesSelectors.getStepsState, value: stepsState }, + { selector: RegistriesSelectors.getSchemaResponseRevisionData, value: revisionData }, ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(JustificationComponent); component = fixture.componentInstance; + + actionsMock = { + getSchemaBlocks: jest.fn().mockReturnValue(of({})), + clearState: jest.fn().mockReturnValue(of({})), + getSchemaResponse: jest.fn().mockReturnValue(of({})), + updateStepState: jest.fn().mockReturnValue(of({})), + }; + Object.defineProperty(component, 'actions', { value: actionsMock, writable: true }); + fixture.detectChanges(); - }); + } + + beforeEach(() => setup()); + + afterEach(() => fixture?.destroy()); it('should create', () => { expect(component).toBeTruthy(); }); - it('should compute steps with justification and review', () => { + it('should extract revisionId from route params', () => { + setup({ routeParams: { id: 'rev-42' } }); + expect(component.revisionId).toBe('rev-42'); + }); + + it('should default revisionId to empty string when no id param', () => { + setup({ routeParams: {} }); + expect(component.revisionId).toBe(''); + }); + + it('should build justification as first and review as last step with custom steps in between', () => { + const steps = component.steps(); + expect(steps.length).toBe(4); + expect(steps[0]).toEqual(expect.objectContaining({ index: 0, value: 'justification', routeLink: 'justification' })); + expect(steps[1]).toEqual(expect.objectContaining({ index: 1, label: 'Page One', value: 'page-1', routeLink: '1' })); + expect(steps[2]).toEqual(expect.objectContaining({ index: 2, label: 'Page Two', value: 'page-2', routeLink: '2' })); + expect(steps[3]).toEqual( + expect.objectContaining({ index: 3, value: 'review', routeLink: 'review', invalid: false }) + ); + }); + + it('should mark justification step as invalid when revisionJustification is empty', () => { + setup({ schemaResponse: { ...MOCK_SCHEMA_RESPONSE, revisionJustification: '' } }); + const step = component.steps()[0]; + expect(step.invalid).toBe(true); + expect(step.touched).toBe(false); + }); + + it('should disable steps when reviewsState is not RevisionInProgress', () => { + setup({ schemaResponse: createMockSchemaResponse('resp-1', RevisionReviewStates.Approved) }); + const steps = component.steps(); + expect(steps[0].disabled).toBe(true); + expect(steps[1].disabled).toBe(true); + }); + + it('should apply stepsState invalid/touched to custom steps', () => { + setup({ stepsState: { 1: { invalid: true, touched: true }, 2: { invalid: false, touched: false } } }); + const steps = component.steps(); + expect(steps[1]).toEqual(expect.objectContaining({ invalid: true, touched: true })); + expect(steps[2]).toEqual(expect.objectContaining({ invalid: false, touched: false })); + }); + + it('should handle null schemaResponse gracefully', () => { + setup({ schemaResponse: null }); + const step = component.steps()[0]; + expect(step.invalid).toBe(true); + expect(step.disabled).toBe(true); + }); + + it('should produce only justification and review when no pages', () => { + setup({ pages: [] }); const steps = component.steps(); expect(steps.length).toBe(2); expect(steps[0].value).toBe('justification'); - expect(steps[1].value).toBe('review'); + expect(steps[1]).toEqual(expect.objectContaining({ index: 1, value: 'review' })); + }); + + it('should initialize currentStepIndex from route step param', () => { + setup({ routeParams: { id: 'rev-1', step: '2' } }); + expect(component.currentStepIndex()).toBe(2); + }); + + it('should default currentStepIndex to 0 when no step param', () => { + expect(component.currentStepIndex()).toBe(0); + }); + + it('should return the step at currentStepIndex', () => { + component.currentStepIndex.set(0); + expect(component.currentStep().value).toBe('justification'); + }); + + it('should update currentStepIndex and navigate on stepChange', () => { + component.stepChange({ index: 1, label: 'Page One', value: 'page-1' } as any); + + expect(component.currentStepIndex()).toBe(1); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/revisions/rev-1/', '1']); + }); + + it('should navigate to review route for last step', () => { + const reviewIndex = component.steps().length - 1; + component.stepChange({ index: reviewIndex, label: 'Review', value: 'review' } as any); + + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/revisions/rev-1/', 'review']); + }); + + it('should update currentStepIndex on NavigationEnd', () => { + setup({ routeParams: { id: 'rev-1', step: '2' }, routerUrl: '/registries/revisions/rev-1/2' }); + + routerBuilder.emit(new NavigationEnd(1, '/test', '/test')); + + expect(component.currentStepIndex()).toBe(2); + }); + + it('should show loader on init', () => { + expect(loaderService.show).toHaveBeenCalled(); + }); + + it('should dispatch FetchSchemaResponse when not already loaded', () => { + setup({ schemaResponse: null }); + const store = TestBed.inject(Store); + expect(store.dispatch).toHaveBeenCalled(); }); - it('should navigate on stepChange', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); - component.stepChange({ index: 1, routeLink: '1', value: 'p1', label: 'Page 1' } as any); - expect(navSpy).toHaveBeenCalledWith(['/registries/revisions/rev-1/', 'review']); + it('should not dispatch FetchSchemaResponse when already loaded', () => { + const store = TestBed.inject(Store); + expect(store.dispatch).not.toHaveBeenCalled(); }); - it('should clear state on destroy', () => { - const actionsMock = { - clearState: jest.fn(), - getSchemaBlocks: jest.fn().mockReturnValue({ pipe: () => ({ subscribe: () => {} }) }), - } as any; - Object.defineProperty(component as any, 'actions', { value: actionsMock }); - fixture.destroy(); + it('should dispatch clearState on destroy', () => { + component.ngOnDestroy(); expect(actionsMock.clearState).toHaveBeenCalled(); }); + + it('should detect review page from URL', () => { + setup({ routerUrl: '/registries/revisions/rev-1/review' }); + expect(component['isReviewPage']).toBe(true); + }); + + it('should return false for isReviewPage when not on review', () => { + expect(component['isReviewPage']).toBe(false); + }); }); diff --git a/src/app/features/registries/pages/justification/justification.component.ts b/src/app/features/registries/pages/justification/justification.component.ts index e196e9038..610dfd668 100644 --- a/src/app/features/registries/pages/justification/justification.component.ts +++ b/src/app/features/registries/pages/justification/justification.component.ts @@ -2,7 +2,7 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { filter, tap } from 'rxjs'; +import { filter } from 'rxjs'; import { ChangeDetectionStrategy, @@ -12,7 +12,6 @@ import { effect, inject, OnDestroy, - Signal, signal, untracked, } from '@angular/core'; @@ -33,21 +32,14 @@ import { ClearState, FetchSchemaBlocks, FetchSchemaResponse, RegistriesSelectors templateUrl: './justification.component.html', styleUrl: './justification.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [TranslateService], }) export class JustificationComponent implements OnDestroy { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); - private readonly loaderService = inject(LoaderService); private readonly translateService = inject(TranslateService); - readonly pages = select(RegistriesSelectors.getPagesSchema); - readonly stepsState = select(RegistriesSelectors.getStepsState); - readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); - readonly schemaResponseRevisionData = select(RegistriesSelectors.getSchemaResponseRevisionData); - private readonly actions = createDispatchMap({ getSchemaBlocks: FetchSchemaBlocks, clearState: ClearState, @@ -55,61 +47,79 @@ export class JustificationComponent implements OnDestroy { updateStepState: UpdateStepState, }); + readonly pages = select(RegistriesSelectors.getPagesSchema); + readonly stepsState = select(RegistriesSelectors.getStepsState); + readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); + readonly schemaResponseRevisionData = select(RegistriesSelectors.getSchemaResponseRevisionData); + + readonly revisionId = this.route.snapshot.firstChild?.params['id'] || ''; + get isReviewPage(): boolean { return this.router.url.includes('/review'); } - reviewStep!: StepOption; - justificationStep!: StepOption; - revisionId = this.route.snapshot.firstChild?.params['id'] || ''; + readonly steps = computed(() => { + const response = this.schemaResponse(); + const isJustificationValid = !!response?.revisionJustification; + const isDisabled = response?.reviewsState !== RevisionReviewStates.RevisionInProgress; + const stepState = this.stepsState(); + const pages = this.pages(); - steps: Signal = computed(() => { - const isJustificationValid = !!this.schemaResponse()?.revisionJustification; - this.justificationStep = { + const justificationStep: StepOption = { index: 0, value: 'justification', label: this.translateService.instant('registries.justification.step'), invalid: !isJustificationValid, touched: isJustificationValid, routeLink: 'justification', - disabled: this.schemaResponse()?.reviewsState !== RevisionReviewStates.RevisionInProgress, + disabled: isDisabled, }; - this.reviewStep = { - index: 1, + const customSteps: StepOption[] = pages.map((page, index) => ({ + index: index + 1, + label: page.title, + value: page.id, + routeLink: `${index + 1}`, + invalid: stepState?.[index + 1]?.invalid || false, + touched: stepState?.[index + 1]?.touched || false, + disabled: isDisabled, + })); + + const reviewStep: StepOption = { + index: customSteps.length + 1, value: 'review', label: this.translateService.instant('registries.review.step'), invalid: false, routeLink: 'review', }; - const stepState = this.stepsState(); - const customSteps = this.pages().map((page, index) => { - return { - index: index + 1, - label: page.title, - value: page.id, - routeLink: `${index + 1}`, - invalid: stepState?.[index + 1]?.invalid || false, - touched: stepState?.[index + 1]?.touched || false, - disabled: this.schemaResponse()?.reviewsState !== RevisionReviewStates.RevisionInProgress, - }; - }); - return [ - { ...this.justificationStep }, - ...customSteps, - { ...this.reviewStep, index: customSteps.length + 1, invalid: false }, - ]; + + return [justificationStep, ...customSteps, reviewStep]; }); currentStepIndex = signal( this.route.snapshot.firstChild?.params['step'] ? +this.route.snapshot.firstChild?.params['step'] : 0 ); - currentStep = computed(() => { - return this.steps()[this.currentStepIndex()]; - }); + currentStep = computed(() => this.steps()[this.currentStepIndex()]); constructor() { + this.initRouterListener(); + this.initDataFetching(); + this.initReviewPageSync(); + this.initStepValidation(); + } + + ngOnDestroy(): void { + this.actions.clearState(); + } + + stepChange(step: StepOption): void { + this.currentStepIndex.set(step.index); + const pageLink = this.steps()[step.index].routeLink; + this.router.navigate([`/registries/revisions/${this.revisionId}/`, pageLink]); + } + + private initRouterListener(): void { this.router.events .pipe( takeUntilDestroyed(this.destroyRef), @@ -120,47 +130,56 @@ export class JustificationComponent implements OnDestroy { if (step) { this.currentStepIndex.set(+step); } else if (this.isReviewPage) { - const reviewStepIndex = this.pages().length + 1; - this.currentStepIndex.set(reviewStepIndex); + this.currentStepIndex.set(this.pages().length + 1); } else { this.currentStepIndex.set(0); } }); + } + private initDataFetching(): void { this.loaderService.show(); + if (!this.schemaResponse()) { this.actions.getSchemaResponse(this.revisionId); } effect(() => { const registrationSchemaId = this.schemaResponse()?.registrationSchemaId; + if (registrationSchemaId) { - this.actions - .getSchemaBlocks(registrationSchemaId) - .pipe(tap(() => this.loaderService.hide())) - .subscribe(); + this.actions.getSchemaBlocks(registrationSchemaId).subscribe(() => this.loaderService.hide()); } }); + } + private initReviewPageSync(): void { effect(() => { const reviewStepIndex = this.pages().length + 1; + if (this.isReviewPage) { this.currentStepIndex.set(reviewStepIndex); } }); + } + private initStepValidation(): void { effect(() => { + const currentIndex = this.currentStepIndex(); + const pages = this.pages(); + const revisionData = this.schemaResponseRevisionData(); const stepState = untracked(() => this.stepsState()); - if (this.currentStepIndex() > 0) { + if (currentIndex > 0) { this.actions.updateStepState('0', true, stepState?.[0]?.touched || false); } - if (this.pages().length && this.currentStepIndex() > 0 && this.schemaResponseRevisionData()) { - for (let i = 1; i < this.currentStepIndex(); i++) { - const pageStep = this.pages()[i - 1]; + + if (pages.length && currentIndex > 0 && revisionData) { + for (let i = 1; i < currentIndex; i++) { + const pageStep = pages[i - 1]; const isStepInvalid = pageStep?.questions?.some((question) => { - const questionData = this.schemaResponseRevisionData()[question.responseKey!]; + const questionData = revisionData[question.responseKey!]; return question.required && (Array.isArray(questionData) ? !questionData.length : !questionData); }) || false; this.actions.updateStepState(i.toString(), isStepInvalid, stepState?.[i]?.touched || false); @@ -168,14 +187,4 @@ export class JustificationComponent implements OnDestroy { } }); } - - stepChange(step: StepOption): void { - this.currentStepIndex.set(step.index); - const pageLink = this.steps()[step.index].routeLink; - this.router.navigate([`/registries/revisions/${this.revisionId}/`, pageLink]); - } - - ngOnDestroy(): void { - this.actions.clearState(); - } } diff --git a/src/app/features/registries/pages/registries-landing/registries-landing.component.ts b/src/app/features/registries/pages/registries-landing/registries-landing.component.ts index 1aa9c22c8..9ed82c014 100644 --- a/src/app/features/registries/pages/registries-landing/registries-landing.component.ts +++ b/src/app/features/registries/pages/registries-landing/registries-landing.component.ts @@ -44,7 +44,7 @@ import { GetRegistries, RegistriesSelectors } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistriesLandingComponent implements OnInit, OnDestroy { - private router = inject(Router); + private readonly router = inject(Router); private readonly environment = inject(ENVIRONMENT); private readonly platformId = inject(PLATFORM_ID); private readonly isBrowser = isPlatformBrowser(this.platformId); diff --git a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts index 6498fed94..f29b7f8c7 100644 --- a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts +++ b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts @@ -1,11 +1,14 @@ -import { MockComponents, MockProvider } from 'ng-mocks'; +import { MockComponents } from 'ng-mocks'; +import { of } from 'rxjs'; + +import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Params } from '@angular/router'; import { RegistryProviderHeroComponent } from '@osf/features/registries/components/registry-provider-hero/registry-provider-hero.component'; import { GlobalSearchComponent } from '@osf/shared/components/global-search/global-search.component'; -import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; import { RegistriesProviderSearchComponent } from './registries-provider-search.component'; @@ -14,54 +17,100 @@ import { OSFTestingModule } from '@testing/osf.testing.module'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +const MOCK_PROVIDER = { iri: 'http://iri/provider', name: 'Test Provider' }; + describe('RegistriesProviderSearchComponent', () => { let component: RegistriesProviderSearchComponent; let fixture: ComponentFixture; + let actionsMock: { + getProvider: jest.Mock; + setDefaultFilterValue: jest.Mock; + setResourceType: jest.Mock; + clearCurrentProvider: jest.Mock; + clearRegistryProvider: jest.Mock; + }; + + function setup(options: { params?: Params; platformId?: string } = {}) { + const { params = { providerId: 'osf' }, platformId = 'browser' } = options; - beforeEach(async () => { - const routeMock = ActivatedRouteMockBuilder.create().withParams({ name: 'osf' }).build(); + fixture?.destroy(); + TestBed.resetTestingModule(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ RegistriesProviderSearchComponent, OSFTestingModule, ...MockComponents(GlobalSearchComponent, RegistryProviderHeroComponent), ], providers: [ - { provide: ActivatedRoute, useValue: routeMock }, - MockProvider(CustomDialogService, { open: jest.fn() }), + { provide: ActivatedRoute, useValue: ActivatedRouteMockBuilder.create().withParams(params).build() }, + { provide: PLATFORM_ID, useValue: platformId }, provideMockStore({ signals: [ - { selector: RegistrationProviderSelectors.getBrandedProvider, value: { iri: 'http://iri/provider' } }, + { selector: RegistrationProviderSelectors.getBrandedProvider, value: MOCK_PROVIDER }, { selector: RegistrationProviderSelectors.isBrandedProviderLoading, value: false }, ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(RegistriesProviderSearchComponent); component = fixture.componentInstance; - }); - it('should create', () => { + actionsMock = { + getProvider: jest.fn().mockReturnValue(of({})), + setDefaultFilterValue: jest.fn().mockReturnValue(of({})), + setResourceType: jest.fn().mockReturnValue(of({})), + clearCurrentProvider: jest.fn().mockReturnValue(of({})), + clearRegistryProvider: jest.fn().mockReturnValue(of({})), + }; + Object.defineProperty(component, 'actions', { value: actionsMock, writable: true }); + fixture.detectChanges(); + } + + beforeEach(() => setup()); + + afterEach(() => fixture?.destroy()); + + it('should create', () => { expect(component).toBeTruthy(); }); - it('should clear providers on destroy', () => { - fixture.detectChanges(); + it('should initialize searchControl with empty string', () => { + expect(component.searchControl.value).toBe(''); + }); - const actionsMock = { - getProvider: jest.fn(), - setDefaultFilterValue: jest.fn(), - setResourceType: jest.fn(), - clearCurrentProvider: jest.fn(), - clearRegistryProvider: jest.fn(), - } as any; - Object.defineProperty(component as any, 'actions', { value: actionsMock }); + it('should initialize defaultSearchFiltersInitialized as false before ngOnInit completes', () => { + setup({ params: {} }); + expect(component.defaultSearchFiltersInitialized()).toBe(false); + }); + + it('should fetch provider and set filters on init when providerId exists', () => { + expect(actionsMock.getProvider).toHaveBeenCalledWith('osf'); + expect(actionsMock.setDefaultFilterValue).toHaveBeenCalledWith('publisher', MOCK_PROVIDER.iri); + expect(actionsMock.setResourceType).toHaveBeenCalledWith(ResourceType.Registration); + expect(component.defaultSearchFiltersInitialized()).toBe(true); + }); - fixture.destroy(); + it('should not fetch provider when providerId is missing', () => { + setup({ params: {} }); + expect(actionsMock.getProvider).not.toHaveBeenCalled(); + expect(actionsMock.setDefaultFilterValue).not.toHaveBeenCalled(); + expect(actionsMock.setResourceType).not.toHaveBeenCalled(); + expect(component.defaultSearchFiltersInitialized()).toBe(false); + }); + + it('should clear providers on destroy in browser', () => { + component.ngOnDestroy(); expect(actionsMock.clearCurrentProvider).toHaveBeenCalled(); expect(actionsMock.clearRegistryProvider).toHaveBeenCalled(); }); + + it('should not clear providers on destroy on server', () => { + setup({ platformId: 'server' }); + component.ngOnDestroy(); + expect(actionsMock.clearCurrentProvider).not.toHaveBeenCalled(); + expect(actionsMock.clearRegistryProvider).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.ts b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.ts index e59a55ef7..73b1a1c83 100644 --- a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.ts +++ b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.ts @@ -14,31 +14,18 @@ import { RegistriesSelectors, UpdateSchemaResponse } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class RevisionsCustomStepComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); readonly schemaResponseRevisionData = select(RegistriesSelectors.getSchemaResponseRevisionData); - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - actions = createDispatchMap({ - updateRevision: UpdateSchemaResponse, - }); - - filesLink = computed(() => { - return this.schemaResponse()?.filesLink || ' '; - }); - - provider = computed(() => { - return this.schemaResponse()?.registrationId || ''; - }); - - projectId = computed(() => { - return this.schemaResponse()?.registrationId || ''; - }); - - stepsData = computed(() => { - const schemaResponse = this.schemaResponse(); - return schemaResponse?.revisionResponses || {}; - }); + actions = createDispatchMap({ updateRevision: UpdateSchemaResponse }); + + filesLink = computed(() => this.schemaResponse()?.filesLink || ' '); + provider = computed(() => this.schemaResponse()?.registrationId || ''); + projectId = computed(() => this.schemaResponse()?.registrationId || ''); + stepsData = computed(() => this.schemaResponse()?.revisionResponses || {}); onUpdateAction(data: Record): void { const id: string = this.route.snapshot.params['id'] || ''; From eef7ace6dd90b4b986852ece9539756879cb2f31 Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 12 Feb 2026 16:49:38 +0200 Subject: [PATCH 2/4] test(registries-pages): updated unit tests and code for some pages --- .../constants/registrations-tabs.ts | 4 +- .../registries/enums/registration-tab.enum.ts | 4 +- ...y-registrations-redirect.component.spec.ts | 14 ------- .../my-registrations.component.html | 2 +- .../my-registrations.component.spec.ts | 42 +++++++++++++------ .../my-registrations.component.ts | 30 ++++++------- .../registries-landing.component.spec.ts | 3 -- .../registries-landing.component.ts | 11 +---- src/testing/mocks/registries.mock.ts | 42 ++++++++++++++----- 9 files changed, 82 insertions(+), 70 deletions(-) diff --git a/src/app/features/registries/constants/registrations-tabs.ts b/src/app/features/registries/constants/registrations-tabs.ts index accf00f00..067163eec 100644 --- a/src/app/features/registries/constants/registrations-tabs.ts +++ b/src/app/features/registries/constants/registrations-tabs.ts @@ -1,8 +1,8 @@ -import { TabOption } from '@osf/shared/models/tab-option.model'; +import { CustomOption } from '@osf/shared/models/select-option.model'; import { RegistrationTab } from '../enums'; -export const REGISTRATIONS_TABS: TabOption[] = [ +export const REGISTRATIONS_TABS: CustomOption[] = [ { label: 'common.labels.drafts', value: RegistrationTab.Drafts, diff --git a/src/app/features/registries/enums/registration-tab.enum.ts b/src/app/features/registries/enums/registration-tab.enum.ts index 67eeac498..c7270c341 100644 --- a/src/app/features/registries/enums/registration-tab.enum.ts +++ b/src/app/features/registries/enums/registration-tab.enum.ts @@ -1,4 +1,4 @@ export enum RegistrationTab { - Drafts, - Submitted, + Drafts = 'drafts', + Submitted = 'submitted', } diff --git a/src/app/features/registries/pages/my-registrations-redirect/my-registrations-redirect.component.spec.ts b/src/app/features/registries/pages/my-registrations-redirect/my-registrations-redirect.component.spec.ts index d4f40e53f..b82eba951 100644 --- a/src/app/features/registries/pages/my-registrations-redirect/my-registrations-redirect.component.spec.ts +++ b/src/app/features/registries/pages/my-registrations-redirect/my-registrations-redirect.component.spec.ts @@ -28,24 +28,10 @@ describe('MyRegistrationsRedirectComponent', () => { expect(component).toBeTruthy(); }); - it('should be an instance of MyRegistrationsRedirectComponent', () => { - expect(component).toBeInstanceOf(MyRegistrationsRedirectComponent); - }); - it('should navigate to /my-registrations on component creation', () => { expect(router.navigate).toHaveBeenCalledWith(['/my-registrations'], { queryParamsHandling: 'preserve', replaceUrl: true, }); }); - - it('should preserve query parameters during navigation', () => { - const navigationOptions = router.navigate.mock.calls[0][1]; - expect(navigationOptions?.queryParamsHandling).toBe('preserve'); - }); - - it('should replace the current URL in browser history', () => { - const navigationOptions = router.navigate.mock.calls[0][1]; - expect(navigationOptions?.replaceUrl).toBe(true); - }); }); diff --git a/src/app/features/registries/pages/my-registrations/my-registrations.component.html b/src/app/features/registries/pages/my-registrations/my-registrations.component.html index d9197ccaa..45a6b4e5f 100644 --- a/src/app/features/registries/pages/my-registrations/my-registrations.component.html +++ b/src/app/features/registries/pages/my-registrations/my-registrations.component.html @@ -10,7 +10,7 @@
- + @if (!isMobile()) { @for (tab of tabOptions; track tab.value) { diff --git a/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts b/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts index 5f1c0f4e4..7ec894828 100644 --- a/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts +++ b/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts @@ -98,7 +98,7 @@ describe('MyRegistrationsComponent', () => { getDraftRegistrations: jest.fn(), getSubmittedRegistrations: jest.fn(), deleteDraft: jest.fn(), - } as any; + }; Object.defineProperty(component, 'actions', { value: actionsMock }); const navigateSpy = jest.spyOn(mockRouter, 'navigate'); @@ -119,7 +119,7 @@ describe('MyRegistrationsComponent', () => { getDraftRegistrations: jest.fn(), getSubmittedRegistrations: jest.fn(), deleteDraft: jest.fn(), - } as any; + }; Object.defineProperty(component, 'actions', { value: actionsMock }); const navigateSpy = jest.spyOn(mockRouter, 'navigate'); @@ -135,16 +135,32 @@ describe('MyRegistrationsComponent', () => { }); }); - it('should not process tab change if tab is not a number', () => { + it('should not process tab change if tab value is invalid', () => { const actionsMock = { getDraftRegistrations: jest.fn(), getSubmittedRegistrations: jest.fn(), deleteDraft: jest.fn(), - } as any; + }; Object.defineProperty(component, 'actions', { value: actionsMock }); const initialTab = component.selectedTab(); - component.onTabChange('invalid' as any); + component.onTabChange('invalid'); + + expect(component.selectedTab()).toBe(initialTab); + expect(actionsMock.getDraftRegistrations).not.toHaveBeenCalled(); + expect(actionsMock.getSubmittedRegistrations).not.toHaveBeenCalled(); + }); + + it('should not process tab change if tab is not a string', () => { + const actionsMock = { + getDraftRegistrations: jest.fn(), + getSubmittedRegistrations: jest.fn(), + deleteDraft: jest.fn(), + }; + Object.defineProperty(component, 'actions', { value: actionsMock }); + const initialTab = component.selectedTab(); + + component.onTabChange(0); expect(component.selectedTab()).toBe(initialTab); expect(actionsMock.getDraftRegistrations).not.toHaveBeenCalled(); @@ -158,17 +174,17 @@ describe('MyRegistrationsComponent', () => { }); it('should handle drafts pagination', () => { - const actionsMock = { getDraftRegistrations: jest.fn() } as any; + const actionsMock = { getDraftRegistrations: jest.fn() }; Object.defineProperty(component, 'actions', { value: actionsMock }); - component.onDraftsPageChange({ page: 2, first: 20 } as any); + component.onDraftsPageChange({ page: 2, first: 20 }); expect(actionsMock.getDraftRegistrations).toHaveBeenCalledWith(3); expect(component.draftFirst).toBe(20); }); it('should handle submitted pagination', () => { - const actionsMock = { getSubmittedRegistrations: jest.fn() } as any; + const actionsMock = { getSubmittedRegistrations: jest.fn() }; Object.defineProperty(component, 'actions', { value: actionsMock }); - component.onSubmittedPageChange({ page: 1, first: 10 } as any); + component.onSubmittedPageChange({ page: 1, first: 10 }); expect(actionsMock.getSubmittedRegistrations).toHaveBeenCalledWith(2); expect(component.submittedFirst).toBe(10); }); @@ -178,7 +194,7 @@ describe('MyRegistrationsComponent', () => { getDraftRegistrations: jest.fn(), getSubmittedRegistrations: jest.fn(), deleteDraft: jest.fn(() => of({})), - } as any; + }; Object.defineProperty(component, 'actions', { value: actionsMock }); customConfirmationService.confirmDelete.mockImplementation(({ onConfirm }) => { onConfirm(); @@ -201,7 +217,7 @@ describe('MyRegistrationsComponent', () => { getDraftRegistrations: jest.fn(), getSubmittedRegistrations: jest.fn(), deleteDraft: jest.fn(), - } as any; + }; Object.defineProperty(component, 'actions', { value: actionsMock }); customConfirmationService.confirmDelete.mockImplementation(() => {}); @@ -219,7 +235,7 @@ describe('MyRegistrationsComponent', () => { getDraftRegistrations: jest.fn(), getSubmittedRegistrations: jest.fn(), deleteDraft: jest.fn(), - } as any; + }; Object.defineProperty(component, 'actions', { value: actionsMock }); component.onTabChange(RegistrationTab.Drafts); @@ -233,7 +249,7 @@ describe('MyRegistrationsComponent', () => { getDraftRegistrations: jest.fn(), getSubmittedRegistrations: jest.fn(), deleteDraft: jest.fn(), - } as any; + }; Object.defineProperty(component, 'actions', { value: actionsMock }); component.onTabChange(RegistrationTab.Submitted); diff --git a/src/app/features/registries/pages/my-registrations/my-registrations.component.ts b/src/app/features/registries/pages/my-registrations/my-registrations.component.ts index 106db16e9..95179b7b7 100644 --- a/src/app/features/registries/pages/my-registrations/my-registrations.component.ts +++ b/src/app/features/registries/pages/my-registrations/my-registrations.component.ts @@ -10,7 +10,6 @@ import { TabsModule } from 'primeng/tabs'; import { NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; @@ -30,17 +29,16 @@ import { DeleteDraft, FetchDraftRegistrations, FetchSubmittedRegistrations, Regi @Component({ selector: 'osf-my-registrations', imports: [ - SubHeaderComponent, - TranslatePipe, - TabsModule, - FormsModule, - SelectComponent, - RegistrationCardComponent, - CustomPaginatorComponent, - Skeleton, Button, + Skeleton, + TabsModule, RouterLink, NgTemplateOutlet, + CustomPaginatorComponent, + RegistrationCardComponent, + SelectComponent, + SubHeaderComponent, + TranslatePipe, ], templateUrl: './my-registrations.component.html', styleUrl: './my-registrations.component.scss', @@ -82,26 +80,28 @@ export class MyRegistrationsComponent { constructor() { const initialTab = this.route.snapshot.queryParams['tab']; - const selectedTab = initialTab == 'drafts' ? RegistrationTab.Drafts : RegistrationTab.Submitted; + const selectedTab = initialTab === RegistrationTab.Drafts ? RegistrationTab.Drafts : RegistrationTab.Submitted; this.onTabChange(selectedTab); } onTabChange(tab: Primitive): void { - if (typeof tab !== 'number') { + if (typeof tab !== 'string' || !Object.values(RegistrationTab).includes(tab as RegistrationTab)) { return; } - this.selectedTab.set(tab); - this.loadTabData(tab); + const validTab = tab as RegistrationTab; + + this.selectedTab.set(validTab); + this.loadTabData(validTab); this.router.navigate([], { relativeTo: this.route, - queryParams: { tab: tab === RegistrationTab.Drafts ? 'drafts' : 'submitted' }, + queryParams: { tab }, queryParamsHandling: 'merge', }); } - private loadTabData(tab: number): void { + private loadTabData(tab: RegistrationTab): void { if (tab === RegistrationTab.Drafts) { this.draftFirst = 0; this.actions.getDraftRegistrations(); diff --git a/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts b/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts index cf4780553..b23fca833 100644 --- a/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts +++ b/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts @@ -8,7 +8,6 @@ import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/ import { ResourceCardComponent } from '@osf/shared/components/resource-card/resource-card.component'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; -import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; import { RegistryServicesComponent } from '../../components/registry-services/registry-services.component'; import { RegistriesSelectors } from '../../store'; @@ -44,8 +43,6 @@ describe('RegistriesLandingComponent', () => { { provide: Router, useValue: mockRouter }, provideMockStore({ signals: [ - { selector: RegistrationProviderSelectors.getBrandedProvider, value: null }, - { selector: RegistrationProviderSelectors.isBrandedProviderLoading, value: false }, { selector: RegistriesSelectors.getRegistries, value: [] }, { selector: RegistriesSelectors.isRegistriesLoading, value: false }, ], diff --git a/src/app/features/registries/pages/registries-landing/registries-landing.component.ts b/src/app/features/registries/pages/registries-landing/registries-landing.component.ts index 9ed82c014..917caf2fc 100644 --- a/src/app/features/registries/pages/registries-landing/registries-landing.component.ts +++ b/src/app/features/registries/pages/registries-landing/registries-landing.component.ts @@ -18,11 +18,7 @@ import { SearchInputComponent } from '@osf/shared/components/search-input/search import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { normalizeQuotes } from '@osf/shared/helpers/normalize-quotes'; -import { - ClearRegistryProvider, - GetRegistryProvider, - RegistrationProviderSelectors, -} from '@osf/shared/stores/registration-provider'; +import { ClearRegistryProvider, GetRegistryProvider } from '@osf/shared/stores/registration-provider'; import { RegistryServicesComponent } from '../../components/registry-services/registry-services.component'; import { GetRegistries, RegistriesSelectors } from '../../store'; @@ -46,8 +42,7 @@ import { GetRegistries, RegistriesSelectors } from '../../store'; export class RegistriesLandingComponent implements OnInit, OnDestroy { private readonly router = inject(Router); private readonly environment = inject(ENVIRONMENT); - private readonly platformId = inject(PLATFORM_ID); - private readonly isBrowser = isPlatformBrowser(this.platformId); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); private actions = createDispatchMap({ getRegistries: GetRegistries, @@ -56,8 +51,6 @@ export class RegistriesLandingComponent implements OnInit, OnDestroy { clearRegistryProvider: ClearRegistryProvider, }); - provider = select(RegistrationProviderSelectors.getBrandedProvider); - isProviderLoading = select(RegistrationProviderSelectors.isBrandedProviderLoading); registries = select(RegistriesSelectors.getRegistries); isRegistriesLoading = select(RegistriesSelectors.isRegistriesLoading); diff --git a/src/testing/mocks/registries.mock.ts b/src/testing/mocks/registries.mock.ts index fb5debaa8..bd3a5a998 100644 --- a/src/testing/mocks/registries.mock.ts +++ b/src/testing/mocks/registries.mock.ts @@ -1,25 +1,45 @@ import { FieldType } from '@osf/shared/enums/field-type.enum'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { DraftRegistrationModel } from '@osf/shared/models/registration/draft-registration.model'; +import { PageSchema } from '@osf/shared/models/registration/page-schema.model'; +import { ProviderSchema } from '@osf/shared/models/registration/provider-schema.model'; -export const MOCK_REGISTRIES_PAGE = { +export const MOCK_REGISTRIES_PAGE: PageSchema = { id: 'page-1', title: 'Page 1', questions: [ - { responseKey: 'field1', fieldType: FieldType.Text, required: true }, - { responseKey: 'field2', fieldType: FieldType.Text, required: false }, + { id: 'q1', displayText: 'Field 1', responseKey: 'field1', fieldType: FieldType.Text, required: true }, + { id: 'q2', displayText: 'Field 2', responseKey: 'field2', fieldType: FieldType.Text, required: false }, ], -} as any; +}; -export const MOCK_STEPS_DATA = { field1: 'value1', field2: 'value2' } as any; +export const MOCK_REGISTRIES_PAGE_WITH_SECTIONS: PageSchema = { + id: 'page-2', + title: 'Page 2', + questions: [], + sections: [ + { + id: 'sec-1', + title: 'Section 1', + questions: [ + { id: 'q3', displayText: 'Field 3', responseKey: 'field3', fieldType: FieldType.Text, required: true }, + ], + }, + ], +}; + +export const MOCK_STEPS_DATA: Record = { field1: 'value1', field2: 'value2' }; -export const MOCK_PAGES_SCHEMA = [MOCK_REGISTRIES_PAGE]; +export const MOCK_PAGES_SCHEMA: PageSchema[] = [MOCK_REGISTRIES_PAGE]; -export const MOCK_DRAFT_REGISTRATION = { +export const MOCK_DRAFT_REGISTRATION: Partial = { id: 'draft-1', title: ' My Title ', description: ' Description ', - license: { id: 'mit' }, + license: { id: 'mit', options: null }, providerId: 'osf', - currentUserPermissions: ['admin'], -} as any; + currentUserPermissions: [UserPermissions.Admin], + registrationSchemaId: 'schema-1', +}; -export const MOCK_PROVIDER_SCHEMAS = [{ id: 'schema-1' }] as any; +export const MOCK_PROVIDER_SCHEMAS: ProviderSchema[] = [{ id: 'schema-1', name: 'Schema 1' }]; From 81f14ffb6130f22d8da5ee331fb779ed71b20de3 Mon Sep 17 00:00:00 2001 From: nsemets Date: Fri, 13 Feb 2026 12:22:22 +0200 Subject: [PATCH 3/4] test(setup): added providers mocks --- src/testing/mocks/dynamic-dialog-ref.mock.ts | 13 +++++ src/testing/osf.testing.provider.ts | 48 +++++++++++++++++++ src/testing/providers/route-provider.mock.ts | 7 +++ src/testing/providers/router-provider.mock.ts | 7 +++ 4 files changed, 75 insertions(+) create mode 100644 src/testing/osf.testing.provider.ts diff --git a/src/testing/mocks/dynamic-dialog-ref.mock.ts b/src/testing/mocks/dynamic-dialog-ref.mock.ts index 091508d9e..d503e736d 100644 --- a/src/testing/mocks/dynamic-dialog-ref.mock.ts +++ b/src/testing/mocks/dynamic-dialog-ref.mock.ts @@ -1,8 +1,21 @@ import { DynamicDialogRef } from 'primeng/dynamicdialog'; +import { Subject } from 'rxjs'; + export const DynamicDialogRefMock = { provide: DynamicDialogRef, useValue: { close: jest.fn(), }, }; + +export function provideDynamicDialogRefMock() { + return { + provide: DynamicDialogRef, + useFactory: () => ({ + close: jest.fn(), + destroy: jest.fn(), + onClose: new Subject(), + }), + }; +} diff --git a/src/testing/osf.testing.provider.ts b/src/testing/osf.testing.provider.ts new file mode 100644 index 000000000..f3710e33a --- /dev/null +++ b/src/testing/osf.testing.provider.ts @@ -0,0 +1,48 @@ +import { TranslateModule } from '@ngx-translate/core'; + +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { importProvidersFrom } from '@angular/core'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; + +import { provideDynamicDialogRefMock } from './mocks/dynamic-dialog-ref.mock'; +import { EnvironmentTokenMock } from './mocks/environment.token.mock'; +import { ToastServiceMock } from './mocks/toast.service.mock'; +import { TranslationServiceMock } from './mocks/translation.service.mock'; +import { provideActivatedRouteMock } from './providers/route-provider.mock'; +import { provideRouterMock } from './providers/router-provider.mock'; + +export function provideOSFCore() { + return [ + provideNoopAnimations(), + importProvidersFrom(TranslateModule.forRoot()), + TranslationServiceMock, + EnvironmentTokenMock, + ]; +} + +export function provideOSFHttp() { + return [provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()]; +} + +export function provideOSFRouting() { + return [provideRouterMock(), provideActivatedRouteMock()]; +} + +export function provideOSFDialog() { + return [provideDynamicDialogRefMock()]; +} + +export function provideOSFToast() { + return [ToastServiceMock]; +} + +export function provideOSFTesting() { + return [ + ...provideOSFCore(), + ...provideOSFHttp(), + ...provideOSFRouting(), + ...provideOSFDialog(), + ...provideOSFToast(), + ]; +} diff --git a/src/testing/providers/route-provider.mock.ts b/src/testing/providers/route-provider.mock.ts index 53e8b3de0..a8efce108 100644 --- a/src/testing/providers/route-provider.mock.ts +++ b/src/testing/providers/route-provider.mock.ts @@ -87,3 +87,10 @@ export const ActivatedRouteMock = { return ActivatedRouteMockBuilder.create().withData(data); }, }; + +export function provideActivatedRouteMock(mock?: ReturnType) { + return { + provide: ActivatedRoute, + useFactory: () => mock ?? ActivatedRouteMockBuilder.create().build(), + }; +} diff --git a/src/testing/providers/router-provider.mock.ts b/src/testing/providers/router-provider.mock.ts index b13d86b59..be8268a33 100644 --- a/src/testing/providers/router-provider.mock.ts +++ b/src/testing/providers/router-provider.mock.ts @@ -60,3 +60,10 @@ export const RouterMock = { return RouterMockBuilder.create(); }, }; + +export function provideRouterMock(mock?: RouterMockType) { + return { + provide: Router, + useFactory: () => mock ?? RouterMockBuilder.create().build(), + }; +} From 032040143e8a7229c552716e3f45ee6e0c5468a1 Mon Sep 17 00:00:00 2001 From: nsemets Date: Fri, 13 Feb 2026 14:43:04 +0200 Subject: [PATCH 4/4] test(registries-pages): updated tests and coverage --- ...registration-custom-step.component.spec.ts | 140 +++++------- .../justification.component.spec.ts | 123 +++++------ .../my-registrations.component.spec.ts | 202 ++++++------------ .../registries-landing.component.spec.ts | 67 +++--- ...gistries-provider-search.component.spec.ts | 117 +++++----- .../revisions-custom-step.component.spec.ts | 49 +++-- src/testing/providers/loader-service.mock.ts | 9 + src/testing/providers/route-provider.mock.ts | 11 + 8 files changed, 319 insertions(+), 399 deletions(-) diff --git a/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts b/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts index 3c07dc4bb..c716f46fe 100644 --- a/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts +++ b/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts @@ -1,135 +1,107 @@ -import { MockComponent, ngMocks } from 'ng-mocks'; +import { Store } from '@ngxs/store'; + +import { MockComponent } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { RegistriesSelectors, UpdateDraft } from '@osf/features/registries/store'; +import { DraftRegistrationModel } from '@osf/shared/models/registration/draft-registration.model'; import { CustomStepComponent } from '../../components/custom-step/custom-step.component'; import { DraftRegistrationCustomStepComponent } from './draft-registration-custom-step.component'; import { MOCK_REGISTRIES_PAGE } from '@testing/mocks/registries.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { ActivatedRouteMockBuilder, provideActivatedRouteMock } from '@testing/providers/route-provider.mock'; +import { provideRouterMock, RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -const MOCK_DRAFT = { id: 'draft-1', providerId: 'prov-1', branchedFrom: { id: 'node-1', filesLink: '/files' } }; -const MOCK_STEPS_DATA = { 'question-1': 'answer-1' }; +const MOCK_DRAFT: Partial = { + id: 'draft-1', + providerId: 'prov-1', + branchedFrom: { id: 'node-1', filesLink: '/files' }, +}; +const MOCK_STEPS_DATA: Record = { 'question-1': 'answer-1' }; describe('DraftRegistrationCustomStepComponent', () => { let component: DraftRegistrationCustomStepComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; - - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1', step: '1' }).build(); + let store: Store; + let mockRouter: RouterMockType; + + function setup( + draft: Partial | null = MOCK_DRAFT, + stepsData: Record = MOCK_STEPS_DATA + ) { + const mockRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1', step: '1' }).build(); mockRouter = RouterMockBuilder.create().withUrl('/registries/prov-1/draft/draft-1/custom').build(); - await TestBed.configureTestingModule({ - imports: [DraftRegistrationCustomStepComponent, OSFTestingModule, MockComponent(CustomStepComponent)], + TestBed.configureTestingModule({ + imports: [DraftRegistrationCustomStepComponent, MockComponent(CustomStepComponent)], providers: [ - { provide: ActivatedRoute, useValue: mockActivatedRoute }, - { provide: Router, useValue: mockRouter }, + provideOSFCore(), + provideActivatedRouteMock(mockRoute), + provideRouterMock(mockRouter), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getStepsData, value: MOCK_STEPS_DATA }, - { selector: RegistriesSelectors.getDraftRegistration, value: MOCK_DRAFT }, + { selector: RegistriesSelectors.getStepsData, value: stepsData }, + { selector: RegistriesSelectors.getDraftRegistration, value: draft }, { selector: RegistriesSelectors.getPagesSchema, value: [MOCK_REGISTRIES_PAGE] }, { selector: RegistriesSelectors.getStepsState, value: { 1: { invalid: false } } }, ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(DraftRegistrationCustomStepComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); it('should compute inputs from draft registration', () => { + setup(); expect(component.filesLink()).toBe('/files'); expect(component.provider()).toBe('prov-1'); expect(component.projectId()).toBe('node-1'); }); - it('should dispatch updateDraft on onUpdateAction', () => { - const actionsMock = { updateDraft: jest.fn() } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); + it('should return empty strings when draftRegistration is null', () => { + setup(null, {}); + expect(component.filesLink()).toBe(''); + expect(component.provider()).toBe(''); + expect(component.projectId()).toBe(''); + }); - component.onUpdateAction({ a: 1 } as any); - expect(actionsMock.updateDraft).toHaveBeenCalledWith('draft-1', { registration_responses: { a: 1 } }); + it('should dispatch updateDraft with wrapped registration_responses', () => { + setup(); + component.onUpdateAction({ field1: 'value1', field2: ['a', 'b'] } as any); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateDraft('draft-1', { registration_responses: { field1: 'value1', field2: ['a', 'b'] } }) + ); }); it('should navigate back to metadata on onBack', () => { - const navigateSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + setup(); component.onBack(); - expect(navigateSpy).toHaveBeenCalledWith(['../', 'metadata'], { relativeTo: TestBed.inject(ActivatedRoute) }); + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['../', 'metadata'], + expect.objectContaining({ relativeTo: expect.anything() }) + ); }); it('should navigate to review on onNext', () => { - const navigateSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + setup(); component.onNext(); - expect(navigateSpy).toHaveBeenCalledWith(['../', 'review'], { relativeTo: TestBed.inject(ActivatedRoute) }); - }); - - it('should pass stepsData to custom step component', () => { - const customStep = ngMocks.find(CustomStepComponent).componentInstance; - expect(customStep.stepsData).toEqual(MOCK_STEPS_DATA); - }); - - it('should pass filesLink, projectId, and provider to custom step component', () => { - const customStep = ngMocks.find(CustomStepComponent).componentInstance; - expect(customStep.filesLink).toBe('/files'); - expect(customStep.projectId).toBe('node-1'); - expect(customStep.provider).toBe('prov-1'); - }); - - it('should return empty strings when draftRegistration is null', async () => { - TestBed.resetTestingModule(); - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1', step: '1' }).build(); - mockRouter = RouterMockBuilder.create().withUrl('/registries/prov-1/draft/draft-1/custom').build(); - - await TestBed.configureTestingModule({ - imports: [DraftRegistrationCustomStepComponent, OSFTestingModule, MockComponent(CustomStepComponent)], - providers: [ - { provide: ActivatedRoute, useValue: mockActivatedRoute }, - { provide: Router, useValue: mockRouter }, - provideMockStore({ - signals: [ - { selector: RegistriesSelectors.getStepsData, value: {} }, - { selector: RegistriesSelectors.getDraftRegistration, value: null }, - { selector: RegistriesSelectors.getPagesSchema, value: [MOCK_REGISTRIES_PAGE] }, - { selector: RegistriesSelectors.getStepsState, value: {} }, - ], - }), - ], - }).compileComponents(); - - const nullFixture = TestBed.createComponent(DraftRegistrationCustomStepComponent); - const nullComponent = nullFixture.componentInstance; - nullFixture.detectChanges(); - - expect(nullComponent.filesLink()).toBe(''); - expect(nullComponent.provider()).toBe(''); - expect(nullComponent.projectId()).toBe(''); - }); - - it('should wrap attributes in registration_responses on update', () => { - const actionsMock = { updateDraft: jest.fn() } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - - const attributes = { field1: 'value1', field2: ['a', 'b'] }; - component.onUpdateAction(attributes as any); - - expect(actionsMock.updateDraft).toHaveBeenCalledWith('draft-1', { - registration_responses: { field1: 'value1', field2: ['a', 'b'] }, - }); + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['../', 'review'], + expect.objectContaining({ relativeTo: expect.anything() }) + ); }); }); diff --git a/src/app/features/registries/pages/justification/justification.component.spec.ts b/src/app/features/registries/pages/justification/justification.component.spec.ts index 3efa9f79c..00b39b835 100644 --- a/src/app/features/registries/pages/justification/justification.component.spec.ts +++ b/src/app/features/registries/pages/justification/justification.component.spec.ts @@ -1,26 +1,25 @@ import { Store } from '@ngxs/store'; -import { MockComponents, MockProvider } from 'ng-mocks'; - -import { of } from 'rxjs'; +import { MockComponents } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { NavigationEnd } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; import { PageSchema } from '@osf/shared/models/registration/page-schema.model'; import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; -import { LoaderService } from '@osf/shared/services/loader.service'; -import { RegistriesSelectors } from '../../store'; +import { ClearState, FetchSchemaBlocks, FetchSchemaResponse, RegistriesSelectors } from '../../store'; import { JustificationComponent } from './justification.component'; import { createMockSchemaResponse } from '@testing/mocks/schema-response.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { LoaderServiceMock, provideLoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { ActivatedRouteMockBuilder, provideActivatedRouteMock } from '@testing/providers/route-provider.mock'; +import { provideRouterMock, RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; const MOCK_SCHEMA_RESPONSE = createMockSchemaResponse('resp-1', RevisionReviewStates.RevisionInProgress); @@ -30,36 +29,24 @@ const MOCK_PAGES: PageSchema[] = [ { id: 'page-2', title: 'Page Two', questions: [{ id: 'q2', displayText: 'Q2', required: false, responseKey: 'q2' }] }, ]; -function buildActivatedRoute(params: Record = {}) { - return { - snapshot: { firstChild: { params } }, - firstChild: { snapshot: { params } }, - } as unknown as ActivatedRoute; +interface SetupOptions { + routeParams?: Record; + routerUrl?: string; + schemaResponse?: SchemaResponse | null; + pages?: PageSchema[]; + stepsState?: Record; + revisionData?: Record; } describe('JustificationComponent', () => { let component: JustificationComponent; let fixture: ComponentFixture; + let store: Store; let mockRouter: RouterMockType; let routerBuilder: RouterMockBuilder; - let loaderService: jest.Mocked; - let actionsMock: { - getSchemaBlocks: jest.Mock; - clearState: jest.Mock; - getSchemaResponse: jest.Mock; - updateStepState: jest.Mock; - }; - - function setup( - options: { - routeParams?: Record; - routerUrl?: string; - schemaResponse?: SchemaResponse | null; - pages?: PageSchema[]; - stepsState?: Record; - revisionData?: Record; - } = {} - ) { + let loaderService: LoaderServiceMock; + + function setup(options: SetupOptions = {}) { const { routeParams = { id: 'rev-1' }, routerUrl = '/registries/revisions/rev-1/justification', @@ -69,19 +56,21 @@ describe('JustificationComponent', () => { revisionData = MOCK_SCHEMA_RESPONSE.revisionResponses, } = options; - fixture?.destroy(); - TestBed.resetTestingModule(); - routerBuilder = RouterMockBuilder.create().withUrl(routerUrl); mockRouter = routerBuilder.build(); - loaderService = { show: jest.fn(), hide: jest.fn() } as unknown as jest.Mocked; + loaderService = new LoaderServiceMock(); + + const mockRoute = ActivatedRouteMockBuilder.create() + .withFirstChild((child) => child.withParams(routeParams)) + .build(); TestBed.configureTestingModule({ - imports: [JustificationComponent, OSFTestingModule, ...MockComponents(StepperComponent, SubHeaderComponent)], + imports: [JustificationComponent, ...MockComponents(StepperComponent, SubHeaderComponent)], providers: [ - { provide: ActivatedRoute, useValue: buildActivatedRoute(routeParams) }, - { provide: Router, useValue: mockRouter }, - MockProvider(LoaderService, loaderService), + provideOSFCore(), + provideActivatedRouteMock(mockRoute), + provideRouterMock(mockRouter), + provideLoaderServiceMock(loaderService), provideMockStore({ signals: [ { selector: RegistriesSelectors.getSchemaResponse, value: schemaResponse }, @@ -95,23 +84,12 @@ describe('JustificationComponent', () => { fixture = TestBed.createComponent(JustificationComponent); component = fixture.componentInstance; - - actionsMock = { - getSchemaBlocks: jest.fn().mockReturnValue(of({})), - clearState: jest.fn().mockReturnValue(of({})), - getSchemaResponse: jest.fn().mockReturnValue(of({})), - updateStepState: jest.fn().mockReturnValue(of({})), - }; - Object.defineProperty(component, 'actions', { value: actionsMock, writable: true }); - + store = TestBed.inject(Store); fixture.detectChanges(); } - beforeEach(() => setup()); - - afterEach(() => fixture?.destroy()); - it('should create', () => { + setup(); expect(component).toBeTruthy(); }); @@ -126,6 +104,7 @@ describe('JustificationComponent', () => { }); it('should build justification as first and review as last step with custom steps in between', () => { + setup(); const steps = component.steps(); expect(steps.length).toBe(4); expect(steps[0]).toEqual(expect.objectContaining({ index: 0, value: 'justification', routeLink: 'justification' })); @@ -178,54 +157,61 @@ describe('JustificationComponent', () => { }); it('should default currentStepIndex to 0 when no step param', () => { + setup(); expect(component.currentStepIndex()).toBe(0); }); it('should return the step at currentStepIndex', () => { + setup(); component.currentStepIndex.set(0); expect(component.currentStep().value).toBe('justification'); }); it('should update currentStepIndex and navigate on stepChange', () => { + setup(); component.stepChange({ index: 1, label: 'Page One', value: 'page-1' } as any); - expect(component.currentStepIndex()).toBe(1); expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/revisions/rev-1/', '1']); }); it('should navigate to review route for last step', () => { + setup(); const reviewIndex = component.steps().length - 1; component.stepChange({ index: reviewIndex, label: 'Review', value: 'review' } as any); - expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/revisions/rev-1/', 'review']); }); it('should update currentStepIndex on NavigationEnd', () => { setup({ routeParams: { id: 'rev-1', step: '2' }, routerUrl: '/registries/revisions/rev-1/2' }); - routerBuilder.emit(new NavigationEnd(1, '/test', '/test')); - expect(component.currentStepIndex()).toBe(2); }); it('should show loader on init', () => { + setup(); expect(loaderService.show).toHaveBeenCalled(); }); it('should dispatch FetchSchemaResponse when not already loaded', () => { setup({ schemaResponse: null }); - const store = TestBed.inject(Store); - expect(store.dispatch).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSchemaResponse('rev-1')); }); it('should not dispatch FetchSchemaResponse when already loaded', () => { - const store = TestBed.inject(Store); - expect(store.dispatch).not.toHaveBeenCalled(); + setup(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchSchemaResponse)); + }); + + it('should dispatch FetchSchemaBlocks when schemaResponse has registrationSchemaId', () => { + setup(); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSchemaBlocks(MOCK_SCHEMA_RESPONSE.registrationSchemaId)); }); it('should dispatch clearState on destroy', () => { + setup(); + (store.dispatch as jest.Mock).mockClear(); component.ngOnDestroy(); - expect(actionsMock.clearState).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new ClearState()); }); it('should detect review page from URL', () => { @@ -234,6 +220,21 @@ describe('JustificationComponent', () => { }); it('should return false for isReviewPage when not on review', () => { + setup(); expect(component['isReviewPage']).toBe(false); }); + + it('should set currentStepIndex to last step on NavigationEnd when on review page without step param', () => { + setup({ routeParams: { id: 'rev-1' }, routerUrl: '/registries/revisions/rev-1/review' }); + component.currentStepIndex.set(0); + routerBuilder.emit(new NavigationEnd(2, '/review', '/review')); + expect(component.currentStepIndex()).toBe(MOCK_PAGES.length + 1); + }); + + it('should reset currentStepIndex to 0 on NavigationEnd when not on review and no step param', () => { + setup({ routeParams: { id: 'rev-1' }, routerUrl: '/registries/revisions/rev-1/justification' }); + component.currentStepIndex.set(2); + routerBuilder.emit(new NavigationEnd(2, '/justification', '/justification')); + expect(component.currentStepIndex()).toBe(0); + }); }); diff --git a/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts b/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts index 7ec894828..b5bf6b208 100644 --- a/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts +++ b/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts @@ -1,9 +1,9 @@ -import { MockComponents, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; -import { of } from 'rxjs'; +import { MockComponents } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { UserSelectors } from '@core/store/user'; import { RegistrationTab } from '@osf/features/registries/enums'; @@ -15,35 +15,40 @@ import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ToastService } from '@osf/shared/services/toast.service'; +import { DeleteDraft, FetchDraftRegistrations, FetchSubmittedRegistrations } from '../../store'; + import { MyRegistrationsComponent } from './my-registrations.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { MockCustomConfirmationServiceProvider } from '@testing/mocks/custom-confirmation.service.mock'; +import { provideOSFCore, provideOSFToast } from '@testing/osf.testing.provider'; +import { ActivatedRouteMockBuilder, provideActivatedRouteMock } from '@testing/providers/route-provider.mock'; +import { provideRouterMock, RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('MyRegistrationsComponent', () => { let component: MyRegistrationsComponent; let fixture: ComponentFixture; - let mockRouter: ReturnType; - let mockActivatedRoute: Partial; + let store: Store; + let mockRoute: ReturnType; + let mockRouter: RouterMockType; let customConfirmationService: jest.Mocked; let toastService: jest.Mocked; - beforeEach(async () => { + function setup(queryParams: Record = {}) { mockRouter = RouterMockBuilder.create().withUrl('/registries/me').build(); - mockActivatedRoute = { snapshot: { queryParams: {} } } as any; + mockRoute = ActivatedRouteMockBuilder.create().withQueryParams(queryParams).build(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ MyRegistrationsComponent, - OSFTestingModule, ...MockComponents(SubHeaderComponent, SelectComponent, RegistrationCardComponent, CustomPaginatorComponent), ], providers: [ - { provide: Router, useValue: mockRouter }, - { provide: ActivatedRoute, useValue: mockActivatedRoute }, - MockProvider(CustomConfirmationService, { confirmDelete: jest.fn() }), - MockProvider(ToastService, { showSuccess: jest.fn(), showWarn: jest.fn(), showError: jest.fn() }), + provideOSFCore(), + provideRouterMock(mockRouter), + provideActivatedRouteMock(mockRoute), + MockCustomConfirmationServiceProvider, + provideOSFToast(), provideMockStore({ signals: [ { selector: RegistriesSelectors.getDraftRegistrations, value: [] }, @@ -56,146 +61,109 @@ describe('MyRegistrationsComponent', () => { ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(MyRegistrationsComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); customConfirmationService = TestBed.inject(CustomConfirmationService) as jest.Mocked; toastService = TestBed.inject(ToastService) as jest.Mocked; fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should default to submitted tab when no query param', () => { + it('should default to submitted tab and fetch submitted registrations', () => { + setup(); expect(component.selectedTab()).toBe(RegistrationTab.Submitted); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSubmittedRegistrations()); }); - it('should switch to drafts tab when query param is drafts', () => { - (mockActivatedRoute.snapshot as any).queryParams = { tab: 'drafts' }; - - fixture = TestBed.createComponent(MyRegistrationsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - + it('should switch to drafts tab from query param and fetch drafts', () => { + setup({ tab: 'drafts' }); expect(component.selectedTab()).toBe(RegistrationTab.Drafts); + expect(store.dispatch).toHaveBeenCalledWith(new FetchDraftRegistrations()); }); - it('should switch to submitted tab when query param is submitted', () => { - (mockActivatedRoute.snapshot as any).queryParams = { tab: 'submitted' }; - - fixture = TestBed.createComponent(MyRegistrationsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - - expect(component.selectedTab()).toBe(RegistrationTab.Submitted); - }); - - it('should handle tab change and update query params', () => { - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(), - }; - Object.defineProperty(component, 'actions', { value: actionsMock }); - const navigateSpy = jest.spyOn(mockRouter, 'navigate'); + it('should change tab to drafts, reset pagination, fetch data, and update query params', () => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + (mockRouter.navigate as jest.Mock).mockClear(); component.onTabChange(RegistrationTab.Drafts); expect(component.selectedTab()).toBe(RegistrationTab.Drafts); expect(component.draftFirst).toBe(0); - expect(actionsMock.getDraftRegistrations).toHaveBeenCalledWith(); - expect(navigateSpy).toHaveBeenCalledWith([], { - relativeTo: mockActivatedRoute, + expect(store.dispatch).toHaveBeenCalledWith(new FetchDraftRegistrations()); + expect(mockRouter.navigate).toHaveBeenCalledWith([], { + relativeTo: TestBed.inject(ActivatedRoute), queryParams: { tab: 'drafts' }, queryParamsHandling: 'merge', }); }); - it('should handle tab change to submitted and update query params', () => { - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(), - }; - Object.defineProperty(component, 'actions', { value: actionsMock }); - const navigateSpy = jest.spyOn(mockRouter, 'navigate'); + it('should change tab to submitted, reset pagination, fetch data, and update query params', () => { + setup(); + component.onTabChange(RegistrationTab.Drafts); + (store.dispatch as jest.Mock).mockClear(); + (mockRouter.navigate as jest.Mock).mockClear(); component.onTabChange(RegistrationTab.Submitted); expect(component.selectedTab()).toBe(RegistrationTab.Submitted); expect(component.submittedFirst).toBe(0); - expect(actionsMock.getSubmittedRegistrations).toHaveBeenCalledWith(); - expect(navigateSpy).toHaveBeenCalledWith([], { - relativeTo: mockActivatedRoute, + expect(store.dispatch).toHaveBeenCalledWith(new FetchSubmittedRegistrations()); + expect(mockRouter.navigate).toHaveBeenCalledWith([], { + relativeTo: TestBed.inject(ActivatedRoute), queryParams: { tab: 'submitted' }, queryParamsHandling: 'merge', }); }); - it('should not process tab change if tab value is invalid', () => { - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(), - }; - Object.defineProperty(component, 'actions', { value: actionsMock }); + it('should ignore invalid tab values', () => { + setup(); + (store.dispatch as jest.Mock).mockClear(); const initialTab = component.selectedTab(); component.onTabChange('invalid'); - - expect(component.selectedTab()).toBe(initialTab); - expect(actionsMock.getDraftRegistrations).not.toHaveBeenCalled(); - expect(actionsMock.getSubmittedRegistrations).not.toHaveBeenCalled(); - }); - - it('should not process tab change if tab is not a string', () => { - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(), - }; - Object.defineProperty(component, 'actions', { value: actionsMock }); - const initialTab = component.selectedTab(); - component.onTabChange(0); expect(component.selectedTab()).toBe(initialTab); - expect(actionsMock.getDraftRegistrations).not.toHaveBeenCalled(); - expect(actionsMock.getSubmittedRegistrations).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); }); it('should navigate to create registration page', () => { - const navSpy = jest.spyOn(mockRouter, 'navigate'); + setup(); component.goToCreateRegistration(); - expect(navSpy).toHaveBeenLastCalledWith(['/registries', 'osf', 'new']); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries', 'osf', 'new']); }); it('should handle drafts pagination', () => { - const actionsMock = { getDraftRegistrations: jest.fn() }; - Object.defineProperty(component, 'actions', { value: actionsMock }); + setup(); + (store.dispatch as jest.Mock).mockClear(); + component.onDraftsPageChange({ page: 2, first: 20 }); - expect(actionsMock.getDraftRegistrations).toHaveBeenCalledWith(3); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchDraftRegistrations(3)); expect(component.draftFirst).toBe(20); }); it('should handle submitted pagination', () => { - const actionsMock = { getSubmittedRegistrations: jest.fn() }; - Object.defineProperty(component, 'actions', { value: actionsMock }); + setup(); + (store.dispatch as jest.Mock).mockClear(); + component.onSubmittedPageChange({ page: 1, first: 10 }); - expect(actionsMock.getSubmittedRegistrations).toHaveBeenCalledWith(2); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchSubmittedRegistrations(2)); expect(component.submittedFirst).toBe(10); }); it('should delete draft after confirmation', () => { - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(() => of({})), - }; - Object.defineProperty(component, 'actions', { value: actionsMock }); + setup(); + (store.dispatch as jest.Mock).mockClear(); customConfirmationService.confirmDelete.mockImplementation(({ onConfirm }) => { onConfirm(); }); @@ -207,53 +175,21 @@ describe('MyRegistrationsComponent', () => { messageKey: 'registries.confirmDeleteDraft', onConfirm: expect.any(Function), }); - expect(actionsMock.deleteDraft).toHaveBeenCalledWith('draft-123'); - expect(actionsMock.getDraftRegistrations).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteDraft('draft-123')); + expect(store.dispatch).toHaveBeenCalledWith(new FetchDraftRegistrations()); expect(toastService.showSuccess).toHaveBeenCalledWith('registries.successDeleteDraft'); }); it('should not delete draft if confirmation is cancelled', () => { - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(), - }; - Object.defineProperty(component, 'actions', { value: actionsMock }); + setup(); + (store.dispatch as jest.Mock).mockClear(); + toastService.showSuccess.mockClear(); customConfirmationService.confirmDelete.mockImplementation(() => {}); component.onDeleteDraft('draft-123'); expect(customConfirmationService.confirmDelete).toHaveBeenCalled(); - expect(actionsMock.deleteDraft).not.toHaveBeenCalled(); - expect(actionsMock.getDraftRegistrations).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); expect(toastService.showSuccess).not.toHaveBeenCalled(); }); - - it('should reset draftFirst when switching to drafts tab', () => { - component.draftFirst = 20; - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(), - }; - Object.defineProperty(component, 'actions', { value: actionsMock }); - - component.onTabChange(RegistrationTab.Drafts); - - expect(component.draftFirst).toBe(0); - }); - - it('should reset submittedFirst when switching to submitted tab', () => { - component.submittedFirst = 20; - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(), - }; - Object.defineProperty(component, 'actions', { value: actionsMock }); - - component.onTabChange(RegistrationTab.Submitted); - - expect(component.submittedFirst).toBe(0); - }); }); diff --git a/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts b/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts index b23fca833..7520a2198 100644 --- a/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts +++ b/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts @@ -1,35 +1,39 @@ +import { Store } from '@ngxs/store'; + import { MockComponents } from 'ng-mocks'; +import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Router } from '@angular/router'; import { ScheduledBannerComponent } from '@core/components/osf-banners/scheduled-banner/scheduled-banner.component'; +import { ClearCurrentProvider } from '@core/store/provider'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { ResourceCardComponent } from '@osf/shared/components/resource-card/resource-card.component'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +import { ClearRegistryProvider, GetRegistryProvider } from '@osf/shared/stores/registration-provider'; import { RegistryServicesComponent } from '../../components/registry-services/registry-services.component'; -import { RegistriesSelectors } from '../../store'; +import { GetRegistries, RegistriesSelectors } from '../../store'; import { RegistriesLandingComponent } from './registries-landing.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { provideRouterMock, RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RegistriesLandingComponent', () => { let component: RegistriesLandingComponent; let fixture: ComponentFixture; - let mockRouter: ReturnType; + let store: Store; + let mockRouter: RouterMockType; - beforeEach(async () => { + beforeEach(() => { mockRouter = RouterMockBuilder.create().withUrl('/registries').build(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ RegistriesLandingComponent, - OSFTestingModule, ...MockComponents( SearchInputComponent, RegistryServicesComponent, @@ -40,7 +44,9 @@ describe('RegistriesLandingComponent', () => { ), ], providers: [ - { provide: Router, useValue: mockRouter }, + provideOSFCore(), + provideRouterMock(mockRouter), + { provide: PLATFORM_ID, useValue: 'browser' }, provideMockStore({ signals: [ { selector: RegistriesSelectors.getRegistries, value: [] }, @@ -48,10 +54,11 @@ describe('RegistriesLandingComponent', () => { ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(RegistriesLandingComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); fixture.detectChanges(); }); @@ -59,51 +66,31 @@ describe('RegistriesLandingComponent', () => { expect(component).toBeTruthy(); }); - it('should dispatch get registries and provider on init', () => { - const actionsMock = { - getRegistries: jest.fn(), - getProvider: jest.fn(), - clearCurrentProvider: jest.fn(), - clearRegistryProvider: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - - component.ngOnInit(); - - expect(actionsMock.getRegistries).toHaveBeenCalled(); - expect(actionsMock.getProvider).toHaveBeenCalledWith(component.defaultProvider); + it('should dispatch getRegistries and getProvider on init', () => { + expect(store.dispatch).toHaveBeenCalledWith(new GetRegistries()); + expect(store.dispatch).toHaveBeenCalledWith(new GetRegistryProvider(component.defaultProvider)); }); - it('should clear providers on destroy', () => { - const actionsMock = { - getRegistries: jest.fn(), - getProvider: jest.fn(), - clearCurrentProvider: jest.fn(), - clearRegistryProvider: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - + it('should dispatch clear actions on destroy', () => { + (store.dispatch as jest.Mock).mockClear(); fixture.destroy(); - expect(actionsMock.clearCurrentProvider).toHaveBeenCalled(); - expect(actionsMock.clearRegistryProvider).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new ClearCurrentProvider()); + expect(store.dispatch).toHaveBeenCalledWith(new ClearRegistryProvider()); }); it('should navigate to search with value', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); component.searchControl.setValue('abc'); component.redirectToSearchPageWithValue(); - expect(navSpy).toHaveBeenCalledWith(['/search'], { queryParams: { search: 'abc', tab: 3 } }); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/search'], { queryParams: { search: 'abc', tab: 3 } }); }); it('should navigate to search registrations tab', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); component.redirectToSearchPageRegistrations(); - expect(navSpy).toHaveBeenCalledWith(['/search'], { queryParams: { tab: 3 } }); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/search'], { queryParams: { tab: 3 } }); }); it('should navigate to create page', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); component.goToCreateRegistration(); - expect(navSpy).toHaveBeenCalledWith(['/registries/osf/new']); + expect(mockRouter.navigate).toHaveBeenCalledWith([`/registries/${component.defaultProvider}/new`]); }); }); diff --git a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts index f29b7f8c7..6f53ec22f 100644 --- a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts +++ b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts @@ -1,49 +1,57 @@ -import { MockComponents } from 'ng-mocks'; +import { Store } from '@ngxs/store'; -import { of } from 'rxjs'; +import { MockComponents } from 'ng-mocks'; import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Params } from '@angular/router'; -import { RegistryProviderHeroComponent } from '@osf/features/registries/components/registry-provider-hero/registry-provider-hero.component'; +import { ClearCurrentProvider } from '@core/store/provider'; import { GlobalSearchComponent } from '@osf/shared/components/global-search/global-search.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; +import { RegistryProviderDetails } from '@osf/shared/models/provider/registry-provider.model'; +import { SetDefaultFilterValue, SetResourceType } from '@osf/shared/stores/global-search'; +import { + ClearRegistryProvider, + GetRegistryProvider, + RegistrationProviderSelectors, +} from '@osf/shared/stores/registration-provider'; + +import { RegistryProviderHeroComponent } from '../../components/registry-provider-hero/registry-provider-hero.component'; import { RegistriesProviderSearchComponent } from './registries-provider-search.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { ActivatedRouteMockBuilder, provideActivatedRouteMock } from '@testing/providers/route-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -const MOCK_PROVIDER = { iri: 'http://iri/provider', name: 'Test Provider' }; +const MOCK_PROVIDER: RegistryProviderDetails = { + id: 'provider-1', + name: 'Test Provider', + descriptionHtml: '', + permissions: [], + brand: null, + iri: 'http://iri.example.com', + reviewsWorkflow: 'pre-moderation', +}; describe('RegistriesProviderSearchComponent', () => { let component: RegistriesProviderSearchComponent; let fixture: ComponentFixture; - let actionsMock: { - getProvider: jest.Mock; - setDefaultFilterValue: jest.Mock; - setResourceType: jest.Mock; - clearCurrentProvider: jest.Mock; - clearRegistryProvider: jest.Mock; - }; + let store: Store; - function setup(options: { params?: Params; platformId?: string } = {}) { - const { params = { providerId: 'osf' }, platformId = 'browser' } = options; + const PROVIDER_ID = 'provider-1'; - fixture?.destroy(); - TestBed.resetTestingModule(); + function setup(params: Record = { providerId: PROVIDER_ID }, platformId = 'browser') { + const mockRoute = ActivatedRouteMockBuilder.create().withParams(params).build(); TestBed.configureTestingModule({ imports: [ RegistriesProviderSearchComponent, - OSFTestingModule, - ...MockComponents(GlobalSearchComponent, RegistryProviderHeroComponent), + ...MockComponents(RegistryProviderHeroComponent, GlobalSearchComponent), ], providers: [ - { provide: ActivatedRoute, useValue: ActivatedRouteMockBuilder.create().withParams(params).build() }, + provideOSFCore(), + provideActivatedRouteMock(mockRoute), { provide: PLATFORM_ID, useValue: platformId }, provideMockStore({ signals: [ @@ -56,61 +64,52 @@ describe('RegistriesProviderSearchComponent', () => { fixture = TestBed.createComponent(RegistriesProviderSearchComponent); component = fixture.componentInstance; - - actionsMock = { - getProvider: jest.fn().mockReturnValue(of({})), - setDefaultFilterValue: jest.fn().mockReturnValue(of({})), - setResourceType: jest.fn().mockReturnValue(of({})), - clearCurrentProvider: jest.fn().mockReturnValue(of({})), - clearRegistryProvider: jest.fn().mockReturnValue(of({})), - }; - Object.defineProperty(component, 'actions', { value: actionsMock, writable: true }); - + store = TestBed.inject(Store); fixture.detectChanges(); } - beforeEach(() => setup()); - - afterEach(() => fixture?.destroy()); - it('should create', () => { + setup(); expect(component).toBeTruthy(); }); + it('should fetch provider and initialize search filters on init', () => { + setup(); + expect(store.dispatch).toHaveBeenCalledWith(new GetRegistryProvider(PROVIDER_ID)); + expect(store.dispatch).toHaveBeenCalledWith(new SetDefaultFilterValue('publisher', MOCK_PROVIDER.iri)); + expect(store.dispatch).toHaveBeenCalledWith(new SetResourceType(ResourceType.Registration)); + expect(component.defaultSearchFiltersInitialized()).toBe(true); + }); + it('should initialize searchControl with empty string', () => { + setup(); expect(component.searchControl.value).toBe(''); }); - it('should initialize defaultSearchFiltersInitialized as false before ngOnInit completes', () => { - setup({ params: {} }); - expect(component.defaultSearchFiltersInitialized()).toBe(false); + it('should expose provider and isProviderLoading from store', () => { + setup(); + expect(component.provider()).toEqual(MOCK_PROVIDER); + expect(component.isProviderLoading()).toBe(false); }); - it('should fetch provider and set filters on init when providerId exists', () => { - expect(actionsMock.getProvider).toHaveBeenCalledWith('osf'); - expect(actionsMock.setDefaultFilterValue).toHaveBeenCalledWith('publisher', MOCK_PROVIDER.iri); - expect(actionsMock.setResourceType).toHaveBeenCalledWith(ResourceType.Registration); - expect(component.defaultSearchFiltersInitialized()).toBe(true); + it('should dispatch clear actions on destroy in browser', () => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).toHaveBeenCalledWith(new ClearCurrentProvider()); + expect(store.dispatch).toHaveBeenCalledWith(new ClearRegistryProvider()); }); - it('should not fetch provider when providerId is missing', () => { - setup({ params: {} }); - expect(actionsMock.getProvider).not.toHaveBeenCalled(); - expect(actionsMock.setDefaultFilterValue).not.toHaveBeenCalled(); - expect(actionsMock.setResourceType).not.toHaveBeenCalled(); + it('should not fetch provider or initialize filters when providerId is missing', () => { + setup({}); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetRegistryProvider)); expect(component.defaultSearchFiltersInitialized()).toBe(false); }); - it('should clear providers on destroy in browser', () => { - component.ngOnDestroy(); - expect(actionsMock.clearCurrentProvider).toHaveBeenCalled(); - expect(actionsMock.clearRegistryProvider).toHaveBeenCalled(); - }); - - it('should not clear providers on destroy on server', () => { - setup({ platformId: 'server' }); + it('should not dispatch clear actions on destroy on server', () => { + setup({}, 'server'); + (store.dispatch as jest.Mock).mockClear(); component.ngOnDestroy(); - expect(actionsMock.clearCurrentProvider).not.toHaveBeenCalled(); - expect(actionsMock.clearRegistryProvider).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts index 6411524f3..86b056485 100644 --- a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts +++ b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts @@ -1,33 +1,35 @@ -import { MockComponents, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; + +import { MockComponents } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; import { CustomStepComponent } from '../../components/custom-step/custom-step.component'; -import { RegistriesSelectors } from '../../store'; +import { RegistriesSelectors, UpdateSchemaResponse } from '../../store'; import { RevisionsCustomStepComponent } from './revisions-custom-step.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { ActivatedRouteMockBuilder, provideActivatedRouteMock } from '@testing/providers/route-provider.mock'; +import { provideRouterMock, RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RevisionsCustomStepComponent', () => { let component: RevisionsCustomStepComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; + let store: Store; + let mockRouter: RouterMockType; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'rev-1', step: '1' }).build(); + beforeEach(() => { + const mockRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'rev-1', step: '1' }).build(); mockRouter = RouterMockBuilder.create().withUrl('/registries/revisions/rev-1/1').build(); - await TestBed.configureTestingModule({ - imports: [RevisionsCustomStepComponent, OSFTestingModule, MockComponents(CustomStepComponent)], + TestBed.configureTestingModule({ + imports: [RevisionsCustomStepComponent, MockComponents(CustomStepComponent)], providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), - MockProvider(Router, mockRouter), + provideOSFCore(), + provideActivatedRouteMock(mockRoute), + provideRouterMock(mockRouter), provideMockStore({ signals: [ { @@ -43,10 +45,11 @@ describe('RevisionsCustomStepComponent', () => { ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(RevisionsCustomStepComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); fixture.detectChanges(); }); @@ -62,21 +65,23 @@ describe('RevisionsCustomStepComponent', () => { }); it('should dispatch updateRevision on onUpdateAction', () => { - const actionsMock = { updateRevision: jest.fn() } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); component.onUpdateAction({ x: 2 }); - expect(actionsMock.updateRevision).toHaveBeenCalledWith('rev-1', 'because', { x: 2 }); + expect(store.dispatch).toHaveBeenCalledWith(new UpdateSchemaResponse('rev-1', 'because', { x: 2 })); }); it('should navigate back to justification on onBack', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); component.onBack(); - expect(navSpy).toHaveBeenCalledWith(['../', 'justification'], { relativeTo: TestBed.inject(ActivatedRoute) }); + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['../', 'justification'], + expect.objectContaining({ relativeTo: expect.anything() }) + ); }); it('should navigate to review on onNext', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); component.onNext(); - expect(navSpy).toHaveBeenCalledWith(['../', 'review'], { relativeTo: TestBed.inject(ActivatedRoute) }); + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['../', 'review'], + expect.objectContaining({ relativeTo: expect.anything() }) + ); }); }); diff --git a/src/testing/providers/loader-service.mock.ts b/src/testing/providers/loader-service.mock.ts index 3a76f2822..eb7d002dd 100644 --- a/src/testing/providers/loader-service.mock.ts +++ b/src/testing/providers/loader-service.mock.ts @@ -1,5 +1,7 @@ import { signal } from '@angular/core'; +import { LoaderService } from '@osf/shared/services/loader.service'; + export class LoaderServiceMock { private _isLoading = signal(false); readonly isLoading = this._isLoading.asReadonly(); @@ -7,3 +9,10 @@ export class LoaderServiceMock { show = jest.fn(() => this._isLoading.set(true)); hide = jest.fn(() => this._isLoading.set(false)); } + +export function provideLoaderServiceMock(mock?: LoaderServiceMock) { + return { + provide: LoaderService, + useFactory: () => mock ?? new LoaderServiceMock(), + }; +} diff --git a/src/testing/providers/route-provider.mock.ts b/src/testing/providers/route-provider.mock.ts index a8efce108..739aea0b5 100644 --- a/src/testing/providers/route-provider.mock.ts +++ b/src/testing/providers/route-provider.mock.ts @@ -6,6 +6,7 @@ export class ActivatedRouteMockBuilder { private paramsObj: Record = {}; private queryParamsObj: Record = {}; private dataObj: Record = {}; + private firstChildBuilder: ActivatedRouteMockBuilder | null = null; private params$ = new BehaviorSubject>({}); private queryParams$ = new BehaviorSubject>({}); @@ -39,6 +40,12 @@ export class ActivatedRouteMockBuilder { return this; } + withFirstChild(configureFn: (builder: ActivatedRouteMockBuilder) => void): ActivatedRouteMockBuilder { + this.firstChildBuilder = new ActivatedRouteMockBuilder(); + configureFn(this.firstChildBuilder); + return this; + } + build(): Partial { const paramMap = { get: jest.fn((key: string) => this.paramsObj[key]), @@ -47,6 +54,8 @@ export class ActivatedRouteMockBuilder { keys: Object.keys(this.paramsObj), }; + const firstChild = this.firstChildBuilder ? this.firstChildBuilder.build() : null; + const route: Partial = { parent: { params: this.params$.asObservable(), @@ -59,7 +68,9 @@ export class ActivatedRouteMockBuilder { queryParams: this.queryParamsObj, data: this.dataObj, paramMap: paramMap, + firstChild: firstChild?.snapshot ?? null, } as any, + firstChild: firstChild as any, params: this.params$.asObservable(), queryParams: this.queryParams$.asObservable(), data: this.data$.asObservable(),