diff --git a/jest.config.js b/jest.config.js index ad155721d..4d2d69fcc 100644 --- a/jest.config.js +++ b/jest.config.js @@ -54,10 +54,10 @@ module.exports = { extensionsToTreatAsEsm: ['.ts'], coverageThreshold: { global: { - branches: 39.5, - functions: 41.1, - lines: 68.0, - statements: 68.4, + branches: 43.3, + functions: 42.7, + lines: 69.3, + statements: 69.8, }, }, watchPathIgnorePatterns: [ diff --git a/src/app/features/preprints/components/advisory-board/advisory-board.component.spec.ts b/src/app/features/preprints/components/advisory-board/advisory-board.component.spec.ts index 588caf723..8119d7b99 100644 --- a/src/app/features/preprints/components/advisory-board/advisory-board.component.spec.ts +++ b/src/app/features/preprints/components/advisory-board/advisory-board.component.spec.ts @@ -9,84 +9,54 @@ describe('AdvisoryBoardComponent', () => { const mockHtmlContent = '

Advisory Board

This is advisory board content.

'; - beforeEach(async () => { - await TestBed.configureTestingModule({ + beforeEach(() => { + TestBed.configureTestingModule({ imports: [AdvisoryBoardComponent], - }).compileComponents(); + }); fixture = TestBed.createComponent(AdvisoryBoardComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); + function getSection(): HTMLElement | null { + return fixture.nativeElement.querySelector('section'); + } it('should have default input values', () => { expect(component.htmlContent()).toBeNull(); - expect(component.brand()).toBeUndefined(); expect(component.isLandingPage()).toBe(false); }); - it('should not render section when htmlContent is null', () => { + it.each([null, undefined])('should not render section when htmlContent is %s', (htmlContent) => { + fixture.componentRef.setInput('htmlContent', htmlContent); fixture.detectChanges(); - const compiled = fixture.nativeElement; - const section = compiled.querySelector('section'); - - expect(section).toBeNull(); - }); - - it('should not render section when htmlContent is undefined', () => { - fixture.componentRef.setInput('htmlContent', undefined); - fixture.detectChanges(); - - const compiled = fixture.nativeElement; - const section = compiled.querySelector('section'); - - expect(section).toBeNull(); + expect(getSection()).toBeNull(); }); it('should render section when htmlContent is provided', () => { fixture.componentRef.setInput('htmlContent', mockHtmlContent); fixture.detectChanges(); - const compiled = fixture.nativeElement; - const section = compiled.querySelector('section'); - - expect(section).toBeTruthy(); - expect(section.innerHTML).toBe(mockHtmlContent); - }); - - it('should apply correct CSS classes when isLandingPage is false', () => { - fixture.componentRef.setInput('htmlContent', mockHtmlContent); - fixture.componentRef.setInput('isLandingPage', false); - fixture.detectChanges(); - - const compiled = fixture.nativeElement; - const section = compiled.querySelector('section'); + const section = getSection(); expect(section).toBeTruthy(); - expect(section.classList.contains('osf-preprint-service')).toBe(false); - expect(section.classList.contains('preprints-advisory-board-section')).toBe(true); - expect(section.classList.contains('pt-3')).toBe(true); - expect(section.classList.contains('pb-5')).toBe(true); - expect(section.classList.contains('px-3')).toBe(true); - expect(section.classList.contains('flex')).toBe(true); - expect(section.classList.contains('flex-column')).toBe(true); + expect(section?.innerHTML).toContain('Advisory Board'); + expect(section?.innerHTML).toContain('This is advisory board content.'); }); - it('should apply correct CSS classes when isLandingPage is true', () => { + it.each([ + { isLandingPage: false, hasLandingClass: false }, + { isLandingPage: true, hasLandingClass: true }, + ])('should handle landing class when isLandingPage is $isLandingPage', ({ isLandingPage, hasLandingClass }) => { fixture.componentRef.setInput('htmlContent', mockHtmlContent); - fixture.componentRef.setInput('isLandingPage', true); + fixture.componentRef.setInput('isLandingPage', isLandingPage); fixture.detectChanges(); - const compiled = fixture.nativeElement; - const section = compiled.querySelector('section'); + const section = getSection(); expect(section).toBeTruthy(); - expect(section.classList.contains('osf-preprint-service')).toBe(true); - expect(section.classList.contains('preprints-advisory-board-section')).toBe(true); + expect(section?.classList.contains('osf-preprint-service')).toBe(hasLandingClass); }); }); diff --git a/src/app/features/preprints/components/advisory-board/advisory-board.component.ts b/src/app/features/preprints/components/advisory-board/advisory-board.component.ts index 2840658ce..48435e681 100644 --- a/src/app/features/preprints/components/advisory-board/advisory-board.component.ts +++ b/src/app/features/preprints/components/advisory-board/advisory-board.component.ts @@ -2,7 +2,6 @@ import { NgClass } from '@angular/common'; import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { StringOrNullOrUndefined } from '@osf/shared/helpers/types.helper'; -import { BrandModel } from '@osf/shared/models/brand/brand.model'; import { SafeHtmlPipe } from '@osf/shared/pipes/safe-html.pipe'; @Component({ @@ -14,6 +13,5 @@ import { SafeHtmlPipe } from '@osf/shared/pipes/safe-html.pipe'; }) export class AdvisoryBoardComponent { htmlContent = input(null); - brand = input(); isLandingPage = input(false); } diff --git a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.html b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.html index 6ab5364ec..61646f32b 100644 --- a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.html +++ b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.html @@ -6,14 +6,14 @@

{{ 'preprints.browseBySubjects.title' | translate }}

} } @else { - @for (subject of subjects(); track subject) { + @for (subject of subjects(); track subject.id) { } } diff --git a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.spec.ts b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.spec.ts index 8c71f8c0c..b09b902fa 100644 --- a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.spec.ts +++ b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.spec.ts @@ -1,4 +1,7 @@ +import { MockProvider } from 'ng-mocks'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; import { ResourceType } from '@shared/enums/resource-type.enum'; import { SubjectModel } from '@shared/models/subject/subject.model'; @@ -6,7 +9,8 @@ import { SubjectModel } from '@shared/models/subject/subject.model'; import { BrowseBySubjectsComponent } from './browse-by-subjects.component'; import { SUBJECTS_MOCK } from '@testing/mocks/subject.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; describe('BrowseBySubjectsComponent', () => { let component: BrowseBySubjectsComponent; @@ -14,130 +18,77 @@ describe('BrowseBySubjectsComponent', () => { const mockSubjects: SubjectModel[] = SUBJECTS_MOCK; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BrowseBySubjectsComponent, OSFTestingModule], - }).compileComponents(); + function setup(overrides?: { + subjects?: SubjectModel[]; + areSubjectsLoading?: boolean; + isProviderLoading?: boolean; + isLandingPage?: boolean; + }) { + TestBed.configureTestingModule({ + imports: [BrowseBySubjectsComponent], + providers: [provideOSFCore(), MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().build())], + }); fixture = TestBed.createComponent(BrowseBySubjectsComponent); component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.componentRef.setInput('subjects', []); - fixture.componentRef.setInput('areSubjectsLoading', false); - fixture.componentRef.setInput('isProviderLoading', false); - + fixture.componentRef.setInput('subjects', overrides?.subjects ?? []); + fixture.componentRef.setInput('areSubjectsLoading', overrides?.areSubjectsLoading ?? false); + fixture.componentRef.setInput('isProviderLoading', overrides?.isProviderLoading ?? false); + fixture.componentRef.setInput('isLandingPage', overrides?.isLandingPage ?? false); fixture.detectChanges(); - expect(component).toBeTruthy(); - }); + } - it('should have default input values', () => { - fixture.componentRef.setInput('subjects', []); - fixture.componentRef.setInput('areSubjectsLoading', false); - fixture.componentRef.setInput('isProviderLoading', false); - fixture.detectChanges(); + it('should keep default isLandingPage input as false', () => { + setup(); - expect(component.subjects()).toEqual([]); - expect(component.areSubjectsLoading()).toBe(false); - expect(component.isProviderLoading()).toBe(false); expect(component.isLandingPage()).toBe(false); }); - it('should display title', () => { - fixture.componentRef.setInput('subjects', []); - fixture.componentRef.setInput('areSubjectsLoading', false); - fixture.componentRef.setInput('isProviderLoading', false); - fixture.detectChanges(); - - const compiled = fixture.nativeElement; - const title = compiled.querySelector('h2'); + it('should render skeleton rows while loading', () => { + setup({ areSubjectsLoading: true, subjects: mockSubjects }); - expect(title).toBeTruthy(); - expect(title.textContent).toBe('preprints.browseBySubjects.title'); + expect(fixture.nativeElement.querySelectorAll('p-skeleton').length).toBe(6); + expect(fixture.nativeElement.querySelectorAll('p-button').length).toBe(0); }); - it('should display correct subject names in buttons', () => { - fixture.componentRef.setInput('subjects', mockSubjects); - fixture.componentRef.setInput('areSubjectsLoading', false); - fixture.componentRef.setInput('isProviderLoading', false); - fixture.detectChanges(); - - const compiled = fixture.nativeElement; - const buttons = compiled.querySelectorAll('p-button'); + it('should render one button per subject when not loading', () => { + setup({ subjects: mockSubjects }); - expect(buttons[0].getAttribute('ng-reflect-label')).toBe('Mathematics'); - expect(buttons[1].getAttribute('ng-reflect-label')).toBe('Physics'); + expect(fixture.nativeElement.querySelectorAll('p-button').length).toBe(mockSubjects.length); }); - it('should compute linksToSearchPageForSubject correctly', () => { - fixture.componentRef.setInput('subjects', mockSubjects); - fixture.componentRef.setInput('areSubjectsLoading', false); - fixture.componentRef.setInput('isProviderLoading', false); - fixture.detectChanges(); - - const links = component.linksToSearchPageForSubject(); + it('should build query params for subject with iri', () => { + setup({ subjects: mockSubjects }); - expect(links).toHaveLength(2); - expect(links[0]).toEqual({ + expect(component.getQueryParamsForSubject(mockSubjects[0])).toEqual({ tab: ResourceType.Preprint, filter_subject: '[{"label":"Mathematics","value":"https://example.com/subjects/mathematics"}]', }); - expect(links[1]).toEqual({ - tab: ResourceType.Preprint, - filter_subject: '[{"label":"Physics","value":"https://example.com/subjects/physics"}]', - }); }); - it('should set correct routerLink for non-landing page', () => { - fixture.componentRef.setInput('subjects', mockSubjects); - fixture.componentRef.setInput('areSubjectsLoading', false); - fixture.componentRef.setInput('isProviderLoading', false); - fixture.componentRef.setInput('isLandingPage', false); - fixture.detectChanges(); - - const compiled = fixture.nativeElement; - const buttons = compiled.querySelectorAll('p-button'); - - expect(buttons[0].getAttribute('ng-reflect-router-link')).toBe('discover'); - }); - - it('should set correct routerLink for landing page', () => { - fixture.componentRef.setInput('subjects', mockSubjects); - fixture.componentRef.setInput('areSubjectsLoading', false); - fixture.componentRef.setInput('isProviderLoading', false); - fixture.componentRef.setInput('isLandingPage', true); - fixture.detectChanges(); - - const compiled = fixture.nativeElement; - const buttons = compiled.querySelectorAll('p-button'); - - expect(buttons[0].getAttribute('ng-reflect-router-link')).toBe('/search'); - }); - - it('should handle subjects without iri', () => { - const subjectsWithoutIri: SubjectModel[] = [ - { - id: 'subject-1', - name: 'Physics', - iri: undefined, - children: [], - parent: null, - expanded: false, - }, - ]; - - fixture.componentRef.setInput('subjects', subjectsWithoutIri); - fixture.componentRef.setInput('areSubjectsLoading', false); - fixture.componentRef.setInput('isProviderLoading', false); - fixture.detectChanges(); - - const links = component.linksToSearchPageForSubject(); - - expect(links).toHaveLength(1); - expect(links[0]).toEqual({ + it('should build query params for subject without iri', () => { + setup(); + const subjectWithoutIri = { + id: 'subject-1', + name: 'Physics', + iri: undefined, + children: [], + parent: null, + expanded: false, + } as SubjectModel; + + expect(component.getQueryParamsForSubject(subjectWithoutIri)).toEqual({ tab: ResourceType.Preprint, filter_subject: '[{"label":"Physics"}]', }); }); + + it.each([ + { isLandingPage: false, expected: 'discover' }, + { isLandingPage: true, expected: '/search' }, + ])('should resolve route for isLandingPage=$isLandingPage', ({ isLandingPage, expected }) => { + setup({ isLandingPage }); + + expect(component.subjectRoute()).toBe(expected); + }); }); diff --git a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.ts b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.ts index c701c73c4..6833c042b 100644 --- a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.ts +++ b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.ts @@ -6,20 +6,28 @@ import { Skeleton } from 'primeng/skeleton'; import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import { RouterLink } from '@angular/router'; -import { ResourceType } from '@shared/enums/resource-type.enum'; -import { SubjectModel } from '@shared/models/subject/subject.model'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { SubjectModel } from '@osf/shared/models/subject/subject.model'; @Component({ selector: 'osf-browse-by-subjects', - imports: [RouterLink, Skeleton, TranslatePipe, Button], + imports: [Button, Skeleton, RouterLink, TranslatePipe], templateUrl: './browse-by-subjects.component.html', styleUrl: './browse-by-subjects.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class BrowseBySubjectsComponent { - subjects = input.required(); - linksToSearchPageForSubject = computed(() => { - return this.subjects().map((subject) => ({ + readonly subjects = input.required(); + readonly areSubjectsLoading = input.required(); + readonly isProviderLoading = input.required(); + readonly isLandingPage = input(false); + + readonly skeletonArray = new Array(6); + + readonly subjectRoute = computed(() => (this.isLandingPage() ? '/search' : 'discover')); + + getQueryParamsForSubject(subject: SubjectModel) { + return { tab: ResourceType.Preprint, filter_subject: JSON.stringify([ { @@ -27,10 +35,6 @@ export class BrowseBySubjectsComponent { value: subject.iri, }, ]), - })); - }); - areSubjectsLoading = input.required(); - isProviderLoading = input.required(); - isLandingPage = input(false); - skeletonArray = Array.from({ length: 6 }, (_, i) => i + 1); + }; + } } diff --git a/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.spec.ts b/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.spec.ts index b2c0e251c..14c6b329f 100644 --- a/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.spec.ts +++ b/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.spec.ts @@ -3,52 +3,29 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { PreprintProviderFooterComponent } from './preprint-provider-footer.component'; describe('PreprintProviderFooterComponent', () => { - let component: PreprintProviderFooterComponent; let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ + beforeEach(() => { + TestBed.configureTestingModule({ imports: [PreprintProviderFooterComponent], - }).compileComponents(); + }); fixture = TestBed.createComponent(PreprintProviderFooterComponent); - component = fixture.componentInstance; }); - it('should create', () => { - fixture.componentRef.setInput('footerHtml', ''); + it('should render section when footerHtml has content', () => { + fixture.componentRef.setInput('footerHtml', '

Footer

'); fixture.detectChanges(); - expect(component).toBeTruthy(); + const section = fixture.nativeElement.querySelector('section'); + expect(section).not.toBeNull(); + expect(section.innerHTML).toContain('Footer'); }); - it('should not render section when footerHtml is null', () => { - fixture.componentRef.setInput('footerHtml', null); + it.each([null, undefined, ''])('should not render section when footerHtml is %p', (value) => { + fixture.componentRef.setInput('footerHtml', value); fixture.detectChanges(); - const compiled = fixture.nativeElement; - const section = compiled.querySelector('section'); - - expect(section).toBeNull(); - }); - - it('should not render section when footerHtml is undefined', () => { - fixture.componentRef.setInput('footerHtml', undefined); - fixture.detectChanges(); - - const compiled = fixture.nativeElement; - const section = compiled.querySelector('section'); - - expect(section).toBeNull(); - }); - - it('should not render section when footerHtml is empty string', () => { - fixture.componentRef.setInput('footerHtml', ''); - fixture.detectChanges(); - - const compiled = fixture.nativeElement; - const section = compiled.querySelector('section'); - - expect(section).toBeNull(); + expect(fixture.nativeElement.querySelector('section')).toBeNull(); }); }); diff --git a/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.ts b/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.ts index 6b7e2fa65..da4d77af4 100644 --- a/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.ts +++ b/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.ts @@ -11,5 +11,5 @@ import { SafeHtmlPipe } from '@osf/shared/pipes/safe-html.pipe'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class PreprintProviderFooterComponent { - footerHtml = input.required(); + readonly footerHtml = input(null); } diff --git a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.html b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.html index 877aefcc0..abde6fa7e 100644 --- a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.html +++ b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.html @@ -1,19 +1,21 @@ +@let provider = preprintProvider(); +
@if (isPreprintProviderLoading()) { - } @else { -

{{ preprintProvider()!.name }}

+ } @else if (provider) { +

{{ provider.name }}

}
@if (isPreprintProviderLoading()) { - } @else { + } @else if (provider) { } @@ -21,12 +23,12 @@

{{ preprintProvider()!.name }}

@if (isPreprintProviderLoading()) { - } @else if (preprintProvider()!.allowSubmissions) { + } @else if (provider?.allowSubmissions) { }
@@ -39,8 +41,8 @@

{{ preprintProvider()!.name }}

- } @else { -
+ } @else if (provider) { +
{{ 'preprints.poweredBy' | translate }} } @@ -51,9 +53,7 @@

{{ preprintProvider()!.name }}

{{ preprintProvider()!.name }} @if (isPreprintProviderLoading()) { - } @else if (preprintProvider()?.examplePreprintId) { -

- {{ 'preprints.showExample' | translate }} - -

+ } @else if (provider?.examplePreprintId) { + + {{ 'preprints.showExample' | translate }} + }
diff --git a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.scss b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.scss index 86cfbe725..e69de29bb 100644 --- a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.scss +++ b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.scss @@ -1,12 +0,0 @@ -@use "styles/mixins" as mix; - -.search-input-container { - position: relative; - - img { - position: absolute; - right: mix.rem(4px); - top: mix.rem(4px); - z-index: 1; - } -} diff --git a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.spec.ts b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.spec.ts index dbb62042e..4b3444fa5 100644 --- a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.spec.ts +++ b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.spec.ts @@ -1,177 +1,118 @@ -import { MockComponent, MockProvider } from 'ng-mocks'; - -import { DialogService } from 'primeng/dynamicdialog'; +import { MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { PreprintProviderDetails } from '@osf/features/preprints/models'; -import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; +import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; + +import { PreprintsHelpDialogComponent } from '../preprints-help-dialog/preprints-help-dialog.component'; import { PreprintProviderHeroComponent } from './preprint-provider-hero.component'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; -import { TranslationServiceMock } from '@testing/mocks/translation.service.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { DialogServiceMockBuilder } from '@testing/providers/dialog-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomDialogServiceMockBuilder, + CustomDialogServiceMockType, +} from '@testing/providers/custom-dialog-provider.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; describe('PreprintProviderHeroComponent', () => { let component: PreprintProviderHeroComponent; let fixture: ComponentFixture; - let mockDialogService: ReturnType; + let customDialogMock: CustomDialogServiceMockType; const mockPreprintProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; - beforeEach(async () => { - mockDialogService = DialogServiceMockBuilder.create().build(); + function setup(overrides?: { + searchControl?: FormControl; + preprintProvider?: PreprintProviderDetails | undefined; + isPreprintProviderLoading?: boolean; + }) { + customDialogMock = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); - await TestBed.configureTestingModule({ - imports: [PreprintProviderHeroComponent, OSFTestingModule, MockComponent(SearchInputComponent)], + TestBed.configureTestingModule({ + imports: [PreprintProviderHeroComponent], providers: [ - MockProvider(DialogService, mockDialogService), + provideOSFCore(), + MockProvider(CustomDialogService, customDialogMock), MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().build()), - TranslationServiceMock, ], - }) - .overrideComponent(PreprintProviderHeroComponent, { - set: { - providers: [{ provide: DialogService, useValue: mockDialogService }], - }, - }) - .compileComponents(); + }); fixture = TestBed.createComponent(PreprintProviderHeroComponent); component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); + fixture.componentRef.setInput( + 'searchControl', + overrides && 'searchControl' in overrides ? overrides.searchControl : new FormControl('', { nonNullable: true }) + ); + fixture.componentRef.setInput( + 'preprintProvider', + overrides && 'preprintProvider' in overrides ? overrides.preprintProvider : mockPreprintProvider + ); + fixture.componentRef.setInput( + 'isPreprintProviderLoading', + overrides && 'isPreprintProviderLoading' in overrides ? overrides.isPreprintProviderLoading : false + ); fixture.detectChanges(); + } - expect(component).toBeTruthy(); - }); + function query(selector: string): Element | null { + return fixture.nativeElement.querySelector(selector); + } - it('should display loading skeletons when isPreprintProviderLoading is true', () => { - fixture.componentRef.setInput('isPreprintProviderLoading', true); - fixture.detectChanges(); + it('should show skeletons while loading', () => { + setup({ isPreprintProviderLoading: true, preprintProvider: undefined }); - const compiled = fixture.nativeElement; - const skeletons = compiled.querySelectorAll('p-skeleton'); - const providerName = compiled.querySelector('.preprint-provider-name'); - const providerLogo = compiled.querySelector('img'); - const addButton = compiled.querySelector('p-button'); - const searchInput = compiled.querySelector('osf-search-input'); - - expect(skeletons.length).toBeGreaterThan(0); - expect(providerName).toBeNull(); - expect(providerLogo).toBeNull(); - expect(addButton).toBeNull(); - expect(searchInput).toBeNull(); + expect(fixture.nativeElement.querySelectorAll('p-skeleton').length).toBeGreaterThan(0); + expect(query('.preprint-provider-name')).toBeNull(); + expect(query('img')).toBeNull(); + expect(query('p-button')).toBeNull(); + expect(query('osf-search-input')).toBeNull(); }); - it('should display provider information when not loading', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); - fixture.detectChanges(); + it('should render provider info when not loading', () => { + setup(); - const compiled = fixture.nativeElement; - const providerName = compiled.querySelector('.preprint-provider-name'); - const providerLogo = compiled.querySelector('img'); - const description = compiled.querySelector('.provider-description div'); - - expect(providerName).toBeTruthy(); - expect(providerName?.textContent).toBe('OSF Preprints'); - expect(providerLogo).toBeTruthy(); - expect(providerLogo?.getAttribute('src')).toBe('https://osf.io/assets/hero-logo.png'); - expect(description).toBeTruthy(); - expect(description?.innerHTML).toContain('

Open preprints for all disciplines

'); + expect(query('.preprint-provider-name')?.textContent).toBe('OSF Preprints'); + expect((query('img') as HTMLImageElement).getAttribute('src')).toBe('https://osf.io/assets/hero-logo.png'); + expect(query('.provider-description div')?.innerHTML).toContain('

Open preprints for all disciplines

'); }); - it('should display add preprint button when allowSubmissions is true', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); - fixture.detectChanges(); - - const compiled = fixture.nativeElement; - const addButton = compiled.querySelector('p-button'); + it('should hide provider-dependent content when provider is undefined and not loading', () => { + setup({ preprintProvider: undefined, isPreprintProviderLoading: false }); - expect(addButton).toBeTruthy(); - expect(addButton?.getAttribute('ng-reflect-label')).toBe('Preprints.addpreprint'); - expect(addButton?.getAttribute('ng-reflect-router-link')).toBe('/preprints,osf-preprints,submi'); + expect(query('.preprint-provider-name')).toBeNull(); + expect(query('img')).toBeNull(); }); - it('should display search input when not loading', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); - fixture.detectChanges(); + it('should emit normalized search value', () => { + setup(); + jest.spyOn(component.triggerSearch, 'emit'); - const compiled = fixture.nativeElement; - const searchInput = compiled.querySelector('osf-search-input'); + component.onTriggerSearch('test “quoted” value'); - expect(searchInput).toBeTruthy(); - expect(searchInput?.getAttribute('ng-reflect-show-help-icon')).toBe('true'); - expect(searchInput?.getAttribute('ng-reflect-placeholder')).toBe('Preprints.searchplaceholder'); + expect(component.triggerSearch.emit).toHaveBeenCalledWith('test "quoted" value'); }); - it('should emit triggerSearch when onTriggerSearch is called', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); - fixture.detectChanges(); - + it('should emit empty string when search value is missing', () => { + setup(); jest.spyOn(component.triggerSearch, 'emit'); - const searchValue = 'test search query'; - component.onTriggerSearch(searchValue); + component.onTriggerSearch(undefined as unknown as string); - expect(component.triggerSearch.emit).toHaveBeenCalledWith(searchValue); + expect(component.triggerSearch.emit).toHaveBeenCalledWith(''); }); - it('should open help dialog when openHelpDialog is called', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); - fixture.detectChanges(); - - expect(mockDialogService.open).toBeDefined(); - expect(typeof mockDialogService.open).toBe('function'); + it('should open help dialog with expected header', () => { + setup(); component.openHelpDialog(); - expect(mockDialogService.open).toHaveBeenCalledWith(expect.any(Function), { - focusOnShow: false, + expect(customDialogMock.open).toHaveBeenCalledWith(PreprintsHelpDialogComponent, { header: 'preprints.helpDialog.header', - closeOnEscape: true, - modal: true, - closable: true, - breakpoints: { '768px': '95vw' }, }); }); - - it('should update when input properties change', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', undefined); - fixture.componentRef.setInput('isPreprintProviderLoading', true); - fixture.detectChanges(); - - let compiled = fixture.nativeElement; - expect(compiled.querySelectorAll('p-skeleton').length).toBeGreaterThan(0); - expect(compiled.querySelector('.preprint-provider-name')).toBeNull(); - - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); - fixture.detectChanges(); - - compiled = fixture.nativeElement; - expect(compiled.querySelectorAll('p-skeleton').length).toBe(0); - expect(compiled.querySelector('.preprint-provider-name')).toBeTruthy(); - expect(compiled.querySelector('.preprint-provider-name')?.textContent).toBe('OSF Preprints'); - }); }); diff --git a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.ts b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.ts index 826d17eca..2fa2bc2aa 100644 --- a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.ts +++ b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.ts @@ -18,24 +18,24 @@ import { PreprintsHelpDialogComponent } from '../preprints-help-dialog/preprints @Component({ selector: 'osf-preprint-provider-hero', - imports: [Button, RouterLink, SearchInputComponent, Skeleton, TranslatePipe, TitleCasePipe, SafeHtmlPipe], + imports: [Button, Skeleton, RouterLink, SearchInputComponent, SafeHtmlPipe, TitleCasePipe, TranslatePipe], templateUrl: './preprint-provider-hero.component.html', styleUrl: './preprint-provider-hero.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class PreprintProviderHeroComponent { - customDialogService = inject(CustomDialogService); + private readonly customDialogService = inject(CustomDialogService); - searchControl = input(new FormControl()); - preprintProvider = input.required(); - isPreprintProviderLoading = input.required(); - triggerSearch = output(); + readonly searchControl = input>(new FormControl('', { nonNullable: true })); + readonly isPreprintProviderLoading = input.required(); + readonly preprintProvider = input(); + readonly triggerSearch = output(); - onTriggerSearch(value: string) { - this.triggerSearch.emit(normalizeQuotes(value)!); + onTriggerSearch(value: string): void { + this.triggerSearch.emit(normalizeQuotes(value) ?? ''); } - openHelpDialog() { + openHelpDialog(): void { this.customDialogService.open(PreprintsHelpDialogComponent, { header: 'preprints.helpDialog.header' }); } } diff --git a/src/app/features/preprints/components/preprint-services/preprint-services.component.spec.ts b/src/app/features/preprints/components/preprint-services/preprint-services.component.spec.ts index fd76e1ec9..552053787 100644 --- a/src/app/features/preprints/components/preprint-services/preprint-services.component.spec.ts +++ b/src/app/features/preprints/components/preprint-services/preprint-services.component.spec.ts @@ -5,7 +5,7 @@ import { PreprintProviderShortInfo } from '@osf/features/preprints/models'; import { PreprintServicesComponent } from './preprint-services.component'; import { PREPRINT_PROVIDER_SHORT_INFO_MOCK } from '@testing/mocks/preprint-provider-short-info.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; describe('PreprintServicesComponent', () => { let component: PreprintServicesComponent; @@ -13,32 +13,30 @@ describe('PreprintServicesComponent', () => { const mockProviders: PreprintProviderShortInfo[] = [PREPRINT_PROVIDER_SHORT_INFO_MOCK]; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PreprintServicesComponent, OSFTestingModule], - }).compileComponents(); + function setup(providers: PreprintProviderShortInfo[]) { + TestBed.configureTestingModule({ + imports: [PreprintServicesComponent], + providers: [provideOSFCore()], + }); fixture = TestBed.createComponent(PreprintServicesComponent); component = fixture.componentInstance; - }); + fixture.componentRef.setInput('preprintProvidersToAdvertise', providers); + } it('should create', () => { - fixture.componentRef.setInput('preprintProvidersToAdvertise', []); - fixture.detectChanges(); - + setup(mockProviders); expect(component).toBeTruthy(); }); - it('should accept preprint providers input', () => { - fixture.componentRef.setInput('preprintProvidersToAdvertise', mockProviders); - fixture.detectChanges(); + it('should keep provided providers input', () => { + setup(mockProviders); expect(component.preprintProvidersToAdvertise()).toEqual(mockProviders); }); - it('should handle empty providers array', () => { - fixture.componentRef.setInput('preprintProvidersToAdvertise', []); - fixture.detectChanges(); + it('should keep empty providers input', () => { + setup([]); expect(component.preprintProvidersToAdvertise()).toEqual([]); }); diff --git a/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.html b/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.html index ef1b931d3..9669136c1 100644 --- a/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.html +++ b/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.html @@ -1,8 +1,8 @@

{{ 'preprints.helpDialog.message' | translate }} - {{ 'preprints.helpDialog.linkText' | translate }}. + {{ 'preprints.helpDialog.linkText' | translate }}.

diff --git a/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.spec.ts b/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.spec.ts index dfe20d050..ccd92cbc7 100644 --- a/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.spec.ts +++ b/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.spec.ts @@ -2,16 +2,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { PreprintsHelpDialogComponent } from './preprints-help-dialog.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; describe('PreprintsHelpDialogComponent', () => { let component: PreprintsHelpDialogComponent; let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PreprintsHelpDialogComponent, OSFTestingModule], - }).compileComponents(); + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [PreprintsHelpDialogComponent], + providers: [provideOSFCore()], + }); fixture = TestBed.createComponent(PreprintsHelpDialogComponent); component = fixture.componentInstance; diff --git a/src/app/features/preprints/guards/index.ts b/src/app/features/preprints/guards/index.ts deleted file mode 100644 index 1b75c6aed..000000000 --- a/src/app/features/preprints/guards/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './preprints-moderator.guard'; diff --git a/src/app/features/preprints/guards/preprints-moderator.guard.spec.ts b/src/app/features/preprints/guards/preprints-moderator.guard.spec.ts new file mode 100644 index 000000000..45d1e29a8 --- /dev/null +++ b/src/app/features/preprints/guards/preprints-moderator.guard.spec.ts @@ -0,0 +1,53 @@ +import { MockProvider } from 'ng-mocks'; + +import { runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; + +import { UserSelectors } from '@core/store/user'; + +import { preprintsModeratorGuard } from './preprints-moderator.guard'; + +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('preprintsModeratorGuard', () => { + let routerMock: RouterMockType; + const routeSnapshot = {} as ActivatedRouteSnapshot; + const stateSnapshot = {} as RouterStateSnapshot; + + function setup(canViewReviews: boolean) { + const urlTree = {} as UrlTree; + + routerMock = RouterMockBuilder.create().withCreateUrlTree(jest.fn().mockReturnValue(urlTree)).build(); + + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [{ selector: UserSelectors.getCanViewReviews, value: canViewReviews }], + }), + MockProvider(Router, routerMock), + ], + }); + + return { urlTree }; + } + + it('should allow activation when user can view reviews', () => { + setup(true); + + const result = runInInjectionContext(TestBed, () => preprintsModeratorGuard(routeSnapshot, stateSnapshot)); + + expect(result).toBe(true); + expect(routerMock.createUrlTree).not.toHaveBeenCalled(); + }); + + it('should return forbidden UrlTree when user cannot view reviews', () => { + const { urlTree } = setup(false); + + const result = runInInjectionContext(TestBed, () => preprintsModeratorGuard(routeSnapshot, stateSnapshot)); + + expect(routerMock.createUrlTree).toHaveBeenCalledWith(['/forbidden']); + expect(result).toBe(urlTree); + }); +}); diff --git a/src/app/features/preprints/guards/preprints-moderator.guard.ts b/src/app/features/preprints/guards/preprints-moderator.guard.ts index 4a3b625eb..a6fba2a91 100644 --- a/src/app/features/preprints/guards/preprints-moderator.guard.ts +++ b/src/app/features/preprints/guards/preprints-moderator.guard.ts @@ -12,8 +12,8 @@ export const preprintsModeratorGuard: CanActivateFn = () => { const canUserViewReviews = store.selectSnapshot(UserSelectors.getCanViewReviews); if (!canUserViewReviews) { - router.navigateByUrl('/forbidden'); + return router.createUrlTree(['/forbidden']); } - return canUserViewReviews; + return true; }; diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts index 590c4972b..d9cc1d1a1 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts @@ -350,6 +350,14 @@ describe('PreprintDetailsComponent', () => { expect(component.editButtonVisible()).toBe(false); }); + it('should hide edit button when user does not have write access', () => { + setup({ + selectorOverrides: [{ selector: PreprintSelectors.hasWriteAccess, value: false }], + }); + + expect(component.editButtonVisible()).toBe(false); + }); + it('should show edit button for initial preprint', () => { setup({ selectorOverrides: [ diff --git a/src/app/features/preprints/services/preprint-files.service.ts b/src/app/features/preprints/services/preprint-files.service.ts index aa2a0cad1..0f0de7945 100644 --- a/src/app/features/preprints/services/preprint-files.service.ts +++ b/src/app/features/preprints/services/preprint-files.service.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { map, Observable, switchMap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; @@ -18,7 +17,6 @@ import { FileFolderResponseJsonApi, FileFoldersResponseJsonApi, } from '@osf/shared/models/files/file-folder-json-api.model'; -import { FilesService } from '@osf/shared/services/files.service'; import { JsonApiService } from '@osf/shared/services/json-api.service'; import { FilesMapper } from '@shared/mappers/files/files.mapper'; @@ -26,8 +24,7 @@ import { FilesMapper } from '@shared/mappers/files/files.mapper'; providedIn: 'root', }) export class PreprintFilesService { - private filesService = inject(FilesService); - private jsonApiService = inject(JsonApiService); + private readonly jsonApiService = inject(JsonApiService); private readonly environment = inject(ENVIRONMENT); get apiUrl() { @@ -58,7 +55,7 @@ export class PreprintFilesService { } getPreprintFilesLinks(id: string): Observable { - return this.jsonApiService.get(`${this.apiUrl}/preprints/${id}/files/`).pipe( + return this.jsonApiService.get(`${this.apiUrl}/preprints/${id}/files/`).pipe( map((response) => { const rel = response.data[0].relationships; const links = response.data[0].links; @@ -72,12 +69,14 @@ export class PreprintFilesService { } getProjectRootFolder(projectId: string): Observable { - return this.jsonApiService.get(`${this.apiUrl}/nodes/${projectId}/files/`).pipe( - switchMap((response: FileFoldersResponseJsonApi) => { - return this.jsonApiService - .get(response.data[0].relationships.root_folder.links.related.href) - .pipe(map((folder) => FilesMapper.getFileFolder(folder.data))); - }) - ); + return this.jsonApiService + .get(`${this.apiUrl}/nodes/${projectId}/files/`) + .pipe( + switchMap((response) => + this.jsonApiService + .get(response.data[0].relationships.root_folder.links.related.href) + .pipe(map((folder) => FilesMapper.getFileFolder(folder.data))) + ) + ); } }