diff --git a/src/app/core/profile/researcher-profile.service.spec.ts b/src/app/core/profile/researcher-profile.service.spec.ts index 0dada6e5e3..9a6b0477d5 100644 --- a/src/app/core/profile/researcher-profile.service.spec.ts +++ b/src/app/core/profile/researcher-profile.service.spec.ts @@ -12,6 +12,7 @@ import { RequestService } from '../data/request.service'; import { PageInfo } from '../shared/page-info.model'; import { buildPaginatedList } from '../data/paginated-list.model'; import { + createFailedRemoteDataObject$, createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ @@ -22,12 +23,14 @@ import { ResearcherProfileService } from './researcher-profile.service'; import { RouterMock } from '../../shared/mocks/router.mock'; import { ResearcherProfile } from './model/researcher-profile.model'; import { Item } from '../shared/item.model'; -import { ReplaceOperation } from 'fast-json-patch'; +import { RemoveOperation, ReplaceOperation } from 'fast-json-patch'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { PostRequest } from '../data/request.models'; import { followLink } from '../../shared/utils/follow-link-config.model'; import { ConfigurationProperty } from '../shared/configuration-property.model'; import { ConfigurationDataService } from '../data/configuration-data.service'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { environment } from '../../../environments/environment'; describe('ResearcherProfileService', () => { let scheduler: TestScheduler; @@ -89,6 +92,106 @@ describe('ResearcherProfileService', () => { }, } }); + + const mockItemUnlinkedToOrcid: Item = Object.assign(new Item(), { + id: 'mockItemUnlinkedToOrcid', + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }] + } + }); + + const mockItemLinkedToOrcid: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + 'dspace.object.owner': [{ + 'value': 'test person', + 'language': null, + 'authority': 'researcher-profile-id', + 'confidence': 600, + 'place': 0 + }], + 'dspace.orcid.authenticated': [{ + 'value': '2022-06-10T15:15:12.952872', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }], + 'dspace.orcid.scope': [{ + 'value': '/authenticate', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }, { + 'value': '/read-limited', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 1 + }, { + 'value': '/activities/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 2 + }, { + 'value': '/person/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 3 + }], + 'person.identifier.orcid': [{ + 'value': 'orcid-id', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }] + } + }); + + const disconnectionAllowAdmin = { + uuid: 'orcid.disconnection.allowed-users', + name: 'orcid.disconnection.allowed-users', + values: ['only_admin'] + } as ConfigurationProperty; + + const disconnectionAllowAdminOwner = { + uuid: 'orcid.disconnection.allowed-users', + name: 'orcid.disconnection.allowed-users', + values: ['admin_and_owner'] + } as ConfigurationProperty; + + const authorizeUrl = { + uuid: 'orcid.authorize-url', + name: 'orcid.authorize-url', + values: ['orcid.authorize-url'] + } as ConfigurationProperty; + const appClientId = { + uuid: 'orcid.application-client-id', + name: 'orcid.application-client-id', + values: ['orcid.application-client-id'] + } as ConfigurationProperty; + const orcidScope = { + uuid: 'orcid.scope', + name: 'orcid.scope', + values: ['/authenticate', '/read-limited'] + } as ConfigurationProperty; + const endpointURL = `https://rest.api/rest/api/profiles`; const endpointURLWithEmbed = 'https://rest.api/rest/api/profiles?embed=item'; const sourceUri = `https://rest.api/rest/api/external-source/profile`; @@ -140,12 +243,7 @@ describe('ResearcherProfileService', () => { findByHref: jasmine.createSpy('findByHref') }); configurationDataService = jasmine.createSpyObj('configurationDataService', { - findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { - name: 'test', - values: [ - 'org.dspace.ctask.general.ProfileFormats = test' - ] - })) + findByPropertyName: jasmine.createSpy('findByPropertyName') }); service = new ResearcherProfileService( @@ -283,7 +381,7 @@ describe('ResearcherProfileService', () => { }); describe('createFromExternalSource', () => { - let patchSpy; + beforeEach(() => { spyOn((service as any).dataService, 'patch').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); spyOn((service as any), 'findById').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); @@ -305,4 +403,140 @@ describe('ResearcherProfileService', () => { }); }); + describe('isLinkedToOrcid', () => { + it('should return true when item has metadata', () => { + const result = service.isLinkedToOrcid(mockItemLinkedToOrcid); + expect(result).toBeTrue(); + }); + + it('should return true when item has no metadata', () => { + const result = service.isLinkedToOrcid(mockItemUnlinkedToOrcid); + expect(result).toBeFalse(); + }); + }); + + describe('onlyAdminCanDisconnectProfileFromOrcid', () => { + it('should return true when property is only_admin', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createSuccessfulRemoteDataObject$(disconnectionAllowAdmin)); + const result = service.onlyAdminCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: true + }); + expect(result).toBeObservable(expected); + }); + + it('should return false on faild', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createFailedRemoteDataObject$()); + const result = service.onlyAdminCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('ownerCanDisconnectProfileFromOrcid', () => { + it('should return true when property is admin_and_owner', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createSuccessfulRemoteDataObject$(disconnectionAllowAdminOwner)); + const result = service.ownerCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: true + }); + expect(result).toBeObservable(expected); + }); + + it('should return false on faild', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createFailedRemoteDataObject$()); + const result = service.ownerCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('unlinkOrcid', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + spyOn((service as any).dataService, 'patch').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + spyOn((service as any), 'findById').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfile)); + }); + + it('should call patch method properly', () => { + const operations: RemoveOperation[] = [{ + path: '/orcid', + op: 'remove' + }]; + + scheduler.schedule(() => service.unlinkOrcid(mockItemLinkedToOrcid).subscribe()); + scheduler.flush(); + + expect((service as any).dataService.patch).toHaveBeenCalledWith(researcherProfile, operations); + }); + }); + + describe('getOrcidAuthorizeUrl', () => { + beforeEach(() => { + (service as any).configurationService.findByPropertyName.and.returnValues( + createSuccessfulRemoteDataObject$(authorizeUrl), + createSuccessfulRemoteDataObject$(appClientId), + createSuccessfulRemoteDataObject$(orcidScope) + ); + }); + + it('should build the url properly', () => { + const result = service.getOrcidAuthorizeUrl(mockItemUnlinkedToOrcid); + const redirectUri = environment.rest.baseUrl + '/api/eperson/orcid/' + mockItemUnlinkedToOrcid.id + '/?url=undefined'; + const url = 'orcid.authorize-url?client_id=orcid.application-client-id&redirect_uri=' + redirectUri + '&response_type=code&scope=/authenticate /read-limited'; + + const expected = cold('(a|)', { + a: url + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('updateByOrcidOperations', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + spyOn((service as any).dataService, 'patch').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + }); + + it('should call patch method properly', () => { + scheduler.schedule(() => service.updateByOrcidOperations(researcherProfile, []).subscribe()); + scheduler.flush(); + + expect((service as any).dataService.patch).toHaveBeenCalledWith(researcherProfile, []); + }); + }); + + describe('getOrcidAuthorizationScopesByItem', () => { + it('should return list of scopes saved in the item', () => { + const orcidScopes = [ + '/authenticate', + '/read-limited', + '/activities/update', + '/person/update' + ]; + const result = service.getOrcidAuthorizationScopesByItem(mockItemLinkedToOrcid); + expect(result).toEqual(orcidScopes); + }); + }); + + describe('getOrcidAuthorizationScopes', () => { + it('should return list of scopes by configuration', () => { + (service as any).configurationService.findByPropertyName.and.returnValue( + createSuccessfulRemoteDataObject$(orcidScope) + ); + const orcidScopes = [ + '/authenticate', + '/read-limited' + ]; + const expected = cold('(a|)', { + a: orcidScopes + }); + const result = service.getOrcidAuthorizationScopes(); + expect(result).toBeObservable(expected); + }); + }); }); diff --git a/src/app/core/profile/researcher-profile.service.ts b/src/app/core/profile/researcher-profile.service.ts index 702c168539..f944d53b46 100644 --- a/src/app/core/profile/researcher-profile.service.ts +++ b/src/app/core/profile/researcher-profile.service.ts @@ -32,7 +32,7 @@ import { ResearcherProfile } from './model/researcher-profile.model'; import { RESEARCHER_PROFILE } from './model/researcher-profile.resource-type'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { PostRequest } from '../data/request.models'; -import { hasValue, isEmpty } from '../../shared/empty.util'; +import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { CoreState } from '../core-state.model'; import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { Item } from '../shared/item.model'; @@ -171,7 +171,7 @@ export class ResearcherProfileService { * @param item the item to check * @returns the check result */ - isLinkedToOrcid(item: Item): boolean { + public isLinkedToOrcid(item: Item): boolean { return item.hasMetadata('dspace.orcid.authenticated'); } @@ -180,9 +180,11 @@ export class ResearcherProfileService { * * @returns the check result */ - onlyAdminCanDisconnectProfileFromOrcid(): Observable { + public onlyAdminCanDisconnectProfileFromOrcid(): Observable { return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe( - map((property) => property.values.map( (value) => value.toLowerCase()).includes('only_admin')) + map((propertyRD: RemoteData) => { + return propertyRD.hasSucceeded && propertyRD.payload.values.map((value) => value.toLowerCase()).includes('only_admin'); + }) ); } @@ -191,25 +193,10 @@ export class ResearcherProfileService { * * @returns the check result */ - ownerCanDisconnectProfileFromOrcid(): Observable { + public ownerCanDisconnectProfileFromOrcid(): Observable { return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe( - map((property) => { - const values = property.values.map( (value) => value.toLowerCase()); - return values.includes('only_owner') || values.includes('admin_and_owner'); - }) - ); - } - - /** - * Returns true if the admin users can disconnect a researcher profile from ORCID. - * - * @returns the check result - */ - adminCanDisconnectProfileFromOrcid(): Observable { - return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe( - map((property) => { - const values = property.values.map( (value) => value.toLowerCase()); - return values.includes('only_admin') || values.includes('admin_and_owner'); + map((propertyRD: RemoteData) => { + return propertyRD.hasSucceeded && propertyRD.payload.values.map( (value) => value.toLowerCase()).includes('admin_and_owner'); }) ); } @@ -217,8 +204,7 @@ export class ResearcherProfileService { /** * If the given item represents a profile unlink it from ORCID. */ - unlinkOrcid(item: Item): Observable> { - + public unlinkOrcid(item: Item): Observable> { const operations: RemoveOperation[] = [{ path:'/orcid', op:'remove' @@ -231,7 +217,12 @@ export class ResearcherProfileService { ); } - getOrcidAuthorizeUrl(profile: Item): Observable { + /** + * Build and return the url to authenticate with orcid + * + * @param profile + */ + public getOrcidAuthorizeUrl(profile: Item): Observable { return combineLatest([ this.configurationService.findByPropertyName('orcid.authorize-url').pipe(getFirstSucceededRemoteDataPayload()), this.configurationService.findByPropertyName('orcid.application-client-id').pipe(getFirstSucceededRemoteDataPayload()), @@ -278,9 +269,28 @@ export class ResearcherProfileService { return this.dataService.patch(researcherProfile, operations); } - private getOrcidDisconnectionAllowedUsersConfiguration(): Observable { + /** + * Return all orcid authorization scopes saved in the given item + * + * @param item + */ + public getOrcidAuthorizationScopesByItem(item: Item): string[] { + return isNotEmpty(item) ? item.allMetadataValues('dspace.orcid.scope') : []; + } + + /** + * Return all orcid authorization scopes available by configuration + */ + public getOrcidAuthorizationScopes(): Observable { + return this.configurationService.findByPropertyName('orcid.scope').pipe( + getFirstCompletedRemoteData(), + map((propertyRD: RemoteData) => propertyRD.hasSucceeded ? propertyRD.payload.values : []) + ); + } + + private getOrcidDisconnectionAllowedUsersConfiguration(): Observable> { return this.configurationService.findByPropertyName('orcid.disconnection.allowed-users').pipe( - getFirstSucceededRemoteDataPayload() + getFirstCompletedRemoteData() ); } diff --git a/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.html b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.html index 12b13c433c..b15b23d1e0 100644 --- a/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.html +++ b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.html @@ -3,75 +3,89 @@
- +
+ - -
-
-
{{ 'person.page.orcid.granted-authorizations'| translate }}
-
-
-
    -
  • {{getAuthorizationDescription(auth) | translate}}
  • -
-
-
-
-
-
{{ 'person.page.orcid.missing-authorizations'| translate }}
-
-
-
- {{'person.page.orcid.no-missing-authorizations-message' | translate}} -
-
- {{'person.page.orcid.missing-authorizations-message' | translate}} + +
+
+
+
+
{{ 'person.page.orcid.granted-authorizations'| translate }}
+
+
    -
  • {{getAuthorizationDescription(auth) | translate }}
  • +
  • + {{getAuthorizationDescription(auth) | translate}} +
+
+
+
{{ 'person.page.orcid.missing-authorizations'| translate }}
+
+
+ + {{'person.page.orcid.no-missing-authorizations-message' | translate}} + + + {{'person.page.orcid.missing-authorizations-message' | translate}} +
    +
  • + {{getAuthorizationDescription(auth) | translate }} +
  • +
+
+
+
+
+
-
+ {{ 'person.page.orcid.remove-orcid-message' | translate}} -
-
-
- -
- +
+
- + +
orcid-logo
-
{{ getOrcidNotLinkedMessage() | async }}
+
+ {{ getOrcidNotLinkedMessage() | async }} +
-
+
- -
+
+
+ diff --git a/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.spec.ts b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.spec.ts new file mode 100644 index 0000000000..a2ec1cf9b1 --- /dev/null +++ b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.spec.ts @@ -0,0 +1,336 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { By } from '@angular/platform-browser'; + +import { getTestScheduler } from 'jasmine-marbles'; +import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { of } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { ResearcherProfileService } from '../../../core/profile/researcher-profile.service'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { Item } from '../../../core/shared/item.model'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; +import { OrcidAuthComponent } from './orcid-auth.component'; +import { NativeWindowService } from '../../../core/services/window.service'; +import { NativeWindowMockFactory } from '../../../shared/mocks/mock-native-window-ref'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { ResearcherProfile } from '../../../core/profile/model/researcher-profile.model'; + +describe('OrcidAuthComponent test suite', () => { + let comp: OrcidAuthComponent; + let fixture: ComponentFixture; + let scheduler: TestScheduler; + let researcherProfileService: jasmine.SpyObj; + let nativeWindowRef; + let notificationsService; + + const orcidScopes = [ + '/authenticate', + '/read-limited', + '/activities/update', + '/person/update' + ]; + + const partialOrcidScopes = [ + '/authenticate', + '/read-limited', + ]; + + const mockItemUnlinkedToOrcid: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }] + } + }); + + const mockItemLinkedToOrcid: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + 'dspace.object.owner': [{ + 'value': 'test person', + 'language': null, + 'authority': 'deced3e7-68e2-495d-bf98-7c44fc33b8ff', + 'confidence': 600, + 'place': 0 + }], + 'dspace.orcid.authenticated': [{ + 'value': '2022-06-10T15:15:12.952872', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }], + 'dspace.orcid.scope': [{ + 'value': '/authenticate', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }, { + 'value': '/read-limited', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 1 + }, { + 'value': '/activities/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 2 + }, { + 'value': '/person/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 3 + }], + 'person.identifier.orcid': [{ + 'value': 'orcid-id', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }] + } + }); + + beforeEach(waitForAsync(() => { + researcherProfileService = jasmine.createSpyObj('researcherProfileService', { + getOrcidAuthorizationScopes: jasmine.createSpy('getOrcidAuthorizationScopes'), + getOrcidAuthorizationScopesByItem: jasmine.createSpy('getOrcidAuthorizationScopesByItem'), + getOrcidAuthorizeUrl: jasmine.createSpy('getOrcidAuthorizeUrl'), + isLinkedToOrcid: jasmine.createSpy('isLinkedToOrcid'), + onlyAdminCanDisconnectProfileFromOrcid: jasmine.createSpy('onlyAdminCanDisconnectProfileFromOrcid'), + ownerCanDisconnectProfileFromOrcid: jasmine.createSpy('ownerCanDisconnectProfileFromOrcid'), + unlinkOrcid: jasmine.createSpy('unlinkOrcid') + }); + + void TestBed.configureTestingModule({ + imports: [ + NgbAccordionModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + RouterTestingModule.withRoutes([]) + ], + declarations: [OrcidAuthComponent], + providers: [ + { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, + { provide: NotificationsService, useClass: NotificationsServiceStub }, + { provide: ResearcherProfileService, useValue: researcherProfileService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(OrcidAuthComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(waitForAsync(() => { + scheduler = getTestScheduler(); + fixture = TestBed.createComponent(OrcidAuthComponent); + comp = fixture.componentInstance; + researcherProfileService.getOrcidAuthorizationScopes.and.returnValue(of(orcidScopes)); + })); + + describe('when orcid profile is not linked', () => { + beforeEach(waitForAsync(() => { + comp.item = mockItemUnlinkedToOrcid; + researcherProfileService.getOrcidAuthorizationScopesByItem.and.returnValue([]); + researcherProfileService.isLinkedToOrcid.and.returnValue(false); + researcherProfileService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(false)); + researcherProfileService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + researcherProfileService.getOrcidAuthorizeUrl.and.returnValue(of('oarcidUrl')); + fixture.detectChanges(); + })); + + it('should create', fakeAsync(() => { + const orcidLinked = fixture.debugElement.query(By.css('[data-test="orcidLinked"]')); + const orcidNotLinked = fixture.debugElement.query(By.css('[data-test="orcidNotLinked"]')); + expect(orcidLinked).toBeFalsy(); + expect(orcidNotLinked).toBeTruthy(); + })); + + it('should change location on link', () => { + nativeWindowRef = (comp as any)._window; + scheduler.schedule(() => comp.linkOrcid()); + scheduler.flush(); + + expect(nativeWindowRef.nativeWindow.location.href).toBe('oarcidUrl'); + }); + + }); + + describe('when orcid profile is linked', () => { + beforeEach(waitForAsync(() => { + comp.item = mockItemLinkedToOrcid; + researcherProfileService.isLinkedToOrcid.and.returnValue(true); + })); + + describe('', () => { + + beforeEach(waitForAsync(() => { + comp.item = mockItemLinkedToOrcid; + notificationsService = (comp as any).notificationsService; + researcherProfileService.getOrcidAuthorizationScopesByItem.and.returnValue([...orcidScopes]); + researcherProfileService.isLinkedToOrcid.and.returnValue(true); + researcherProfileService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(false)); + researcherProfileService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + })); + + describe('and unlink is successfully', () => { + beforeEach(waitForAsync(() => { + comp.item = mockItemLinkedToOrcid; + researcherProfileService.unlinkOrcid.and.returnValue(createSuccessfulRemoteDataObject$(new ResearcherProfile())); + spyOn(comp.unlink, 'emit'); + fixture.detectChanges(); + })); + + it('should show success notification', () => { + scheduler.schedule(() => comp.unlinkOrcid()); + scheduler.flush(); + + expect(notificationsService.success).toHaveBeenCalled(); + expect(comp.unlink.emit).toHaveBeenCalled(); + }); + }); + + describe('and unlink is failed', () => { + beforeEach(waitForAsync(() => { + comp.item = mockItemLinkedToOrcid; + researcherProfileService.unlinkOrcid.and.returnValue(createFailedRemoteDataObject$()); + fixture.detectChanges(); + })); + + it('should show success notification', () => { + scheduler.schedule(() => comp.unlinkOrcid()); + scheduler.flush(); + + expect(notificationsService.error).toHaveBeenCalled(); + }); + }); + }); + + describe('and has orcid authorization scopes', () => { + + beforeEach(waitForAsync(() => { + comp.item = mockItemLinkedToOrcid; + researcherProfileService.getOrcidAuthorizationScopesByItem.and.returnValue([...orcidScopes]); + researcherProfileService.isLinkedToOrcid.and.returnValue(true); + researcherProfileService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(false)); + researcherProfileService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + fixture.detectChanges(); + })); + + it('should create', fakeAsync(() => { + const orcidLinked = fixture.debugElement.query(By.css('[data-test="orcidLinked"]')); + const orcidNotLinked = fixture.debugElement.query(By.css('[data-test="orcidNotLinked"]')); + expect(orcidLinked).toBeTruthy(); + expect(orcidNotLinked).toBeFalsy(); + })); + + it('should display orcid authorizations', fakeAsync(() => { + const orcidAuthorizations = fixture.debugElement.query(By.css('[data-test="hasOrcidAuthorizations"]')); + const noMissingOrcidAuthorizations = fixture.debugElement.query(By.css('[data-test="noMissingOrcidAuthorizations"]')); + const orcidAuthorizationsList = fixture.debugElement.queryAll(By.css('[data-test="orcidAuthorization"]')); + + expect(orcidAuthorizations).toBeTruthy(); + expect(noMissingOrcidAuthorizations).toBeTruthy(); + expect(orcidAuthorizationsList.length).toBe(4); + })); + }); + + describe('and has missing orcid authorization scopes', () => { + + beforeEach(waitForAsync(() => { + comp.item = mockItemLinkedToOrcid; + researcherProfileService.getOrcidAuthorizationScopesByItem.and.returnValue([...partialOrcidScopes]); + researcherProfileService.isLinkedToOrcid.and.returnValue(true); + researcherProfileService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(false)); + researcherProfileService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + fixture.detectChanges(); + })); + + it('should create', fakeAsync(() => { + const orcidLinked = fixture.debugElement.query(By.css('[data-test="orcidLinked"]')); + const orcidNotLinked = fixture.debugElement.query(By.css('[data-test="orcidNotLinked"]')); + expect(orcidLinked).toBeTruthy(); + expect(orcidNotLinked).toBeFalsy(); + })); + + it('should display orcid authorizations', fakeAsync(() => { + const orcidAuthorizations = fixture.debugElement.query(By.css('[data-test="hasOrcidAuthorizations"]')); + const missingOrcidAuthorizations = fixture.debugElement.query(By.css('[data-test="missingOrcidAuthorizations"]')); + const orcidAuthorizationsList = fixture.debugElement.queryAll(By.css('[data-test="orcidAuthorization"]')); + const missingOrcidAuthorizationsList = fixture.debugElement.queryAll(By.css('[data-test="missingOrcidAuthorization"]')); + + expect(orcidAuthorizations).toBeTruthy(); + expect(missingOrcidAuthorizations).toBeTruthy(); + expect(orcidAuthorizationsList.length).toBe(2); + expect(missingOrcidAuthorizationsList.length).toBe(2); + })); + }); + + describe('and only admin can unlink scopes', () => { + + beforeEach(waitForAsync(() => { + comp.item = mockItemLinkedToOrcid; + researcherProfileService.getOrcidAuthorizationScopesByItem.and.returnValue([...orcidScopes]); + researcherProfileService.isLinkedToOrcid.and.returnValue(true); + researcherProfileService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + researcherProfileService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(false)); + fixture.detectChanges(); + })); + + it('should display warning panel', fakeAsync(() => { + const unlinkOnlyAdmin = fixture.debugElement.query(By.css('[data-test="unlinkOnlyAdmin"]')); + const unlinkOwner = fixture.debugElement.query(By.css('[data-test="unlinkOwner"]')); + expect(unlinkOnlyAdmin).toBeTruthy(); + expect(unlinkOwner).toBeFalsy(); + })); + + }); + + describe('and owner can unlink scopes', () => { + + beforeEach(waitForAsync(() => { + comp.item = mockItemLinkedToOrcid; + researcherProfileService.getOrcidAuthorizationScopesByItem.and.returnValue([...orcidScopes]); + researcherProfileService.isLinkedToOrcid.and.returnValue(true); + researcherProfileService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + researcherProfileService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + fixture.detectChanges(); + })); + + it('should display warning panel', fakeAsync(() => { + const unlinkOnlyAdmin = fixture.debugElement.query(By.css('[data-test="unlinkOnlyAdmin"]')); + const unlinkOwner = fixture.debugElement.query(By.css('[data-test="unlinkOwner"]')); + expect(unlinkOnlyAdmin).toBeFalsy(); + expect(unlinkOwner).toBeTruthy(); + })); + + }); + + }); + + +}); diff --git a/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.ts b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.ts index aa234648f0..ed8b1fd3d9 100644 --- a/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.ts +++ b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.ts @@ -1,59 +1,126 @@ -import { Component, Inject, Input, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { Component, EventEmitter, Inject, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; + import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { ConfigurationDataService } from '../../../core/data/configuration-data.service'; -import { ItemDataService } from '../../../core/data/item-data.service'; -import { RemoteData } from '../../../core/data/remote-data'; + import { ResearcherProfileService } from '../../../core/profile/researcher-profile.service'; import { NativeWindowRef, NativeWindowService } from '../../../core/services/window.service'; import { Item } from '../../../core/shared/item.model'; -import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { RemoteData } from '../../../core/data/remote-data'; +import { ResearcherProfile } from '../../../core/profile/model/researcher-profile.model'; @Component({ selector: 'ds-orcid-auth', templateUrl: './orcid-auth.component.html', styleUrls: ['./orcid-auth.component.scss'] }) -export class OrcidAuthComponent implements OnInit { +export class OrcidAuthComponent implements OnInit, OnChanges { /** * The item for which showing the orcid settings */ @Input() item: Item; - missingAuthorizations$ = new BehaviorSubject([]); + /** + * The list of exposed orcid authorization scopes for the orcid profile + */ + profileAuthorizationScopes: BehaviorSubject = new BehaviorSubject([]); - unlinkProcessing = false; + /** + * The list of all orcid authorization scopes missing in the orcid profile + */ + missingAuthorizationScopes: BehaviorSubject = new BehaviorSubject([]); + + /** + * The list of all orcid authorization scopes available + */ + orcidAuthorizationScopes: BehaviorSubject = new BehaviorSubject([]); + + /** + * A boolean representing if unlink operation is processing + */ + unlinkProcessing: BehaviorSubject = new BehaviorSubject(false); + + /** + * A boolean representing if orcid profile is linked + */ + private isOrcidLinked$: BehaviorSubject = new BehaviorSubject(false); + + /** + * A boolean representing if only admin can disconnect orcid profile + */ + private onlyAdminCanDisconnectProfileFromOrcid$: BehaviorSubject = new BehaviorSubject(false); + + /** + * A boolean representing if owner can disconnect orcid profile + */ + private ownerCanDisconnectProfileFromOrcid$: BehaviorSubject = new BehaviorSubject(false); + + /** + * An event emitted when orcid profile is unliked successfully + */ + @Output() unlink: EventEmitter = new EventEmitter(); constructor( - private configurationService: ConfigurationDataService, private researcherProfileService: ResearcherProfileService, - protected translateService: TranslateService, + private translateService: TranslateService, private notificationsService: NotificationsService, - private itemService: ItemDataService, - private route: ActivatedRoute, @Inject(NativeWindowService) private _window: NativeWindowRef, - ) { + ) { } ngOnInit() { - const scopes = this.getOrcidAuthorizations(); - return this.configurationService.findByPropertyName('orcid.scope') - .pipe(getFirstSucceededRemoteDataPayload(), - map((configurationProperty) => configurationProperty.values), - map((allScopes) => allScopes.filter((scope) => !scopes.includes(scope)))) - .subscribe((missingScopes) => this.missingAuthorizations$.next(missingScopes)); + this.researcherProfileService.getOrcidAuthorizationScopes().subscribe((scopes: string[]) => { + this.orcidAuthorizationScopes.next(scopes); + this.initOrcidAuthSettings(); + }); } - getOrcidAuthorizations(): string[] { - return this.item.allMetadataValues('dspace.orcid.scope'); + ngOnChanges(changes: SimpleChanges): void { + if (!changes.item.isFirstChange() && changes.item.currentValue !== changes.item.previousValue) { + this.initOrcidAuthSettings(); + } } - isLinkedToOrcid(): boolean { - return this.researcherProfileService.isLinkedToOrcid(this.item); + /** + * Check if the list of exposed orcid authorization scopes for the orcid profile has values + */ + hasOrcidAuthorizations(): Observable { + return this.profileAuthorizationScopes.asObservable().pipe( + map((scopes: string[]) => scopes.length > 0) + ); + } + + /** + * Return the list of exposed orcid authorization scopes for the orcid profile + */ + getOrcidAuthorizations(): Observable { + return this.profileAuthorizationScopes.asObservable(); + } + + /** + * Check if the list of exposed orcid authorization scopes for the orcid profile has values + */ + hasMissingOrcidAuthorizations(): Observable { + return this.missingAuthorizationScopes.asObservable().pipe( + map((scopes: string[]) => scopes.length > 0) + ); + } + + /** + * Return the list of exposed orcid authorization scopes for the orcid profile + */ + getMissingOrcidAuthorizations(): Observable { + return this.profileAuthorizationScopes.asObservable(); + } + + /** + * Return a boolean representing if orcid profile is linked + */ + isLinkedToOrcid(): Observable { + return this.isOrcidLinked$.asObservable(); } getOrcidNotLinkedMessage(): Observable { @@ -65,34 +132,85 @@ export class OrcidAuthComponent implements OnInit { } } + /** + * Get label for a given orcid authorization scope + * + * @param scope + */ getAuthorizationDescription(scope: string) { return 'person.page.orcid.scope.' + scope.substring(1).replace('/', '-'); } + /** + * Return a boolean representing if only admin can disconnect orcid profile + */ onlyAdminCanDisconnectProfileFromOrcid(): Observable { - return this.researcherProfileService.onlyAdminCanDisconnectProfileFromOrcid(); + return this.onlyAdminCanDisconnectProfileFromOrcid$.asObservable(); } + /** + * Return a boolean representing if owner can disconnect orcid profile + */ ownerCanDisconnectProfileFromOrcid(): Observable { - return this.researcherProfileService.ownerCanDisconnectProfileFromOrcid(); + return this.ownerCanDisconnectProfileFromOrcid$.asObservable(); } + /** + * Link existing person profile with orcid + */ linkOrcid(): void { this.researcherProfileService.getOrcidAuthorizeUrl(this.item).subscribe((authorizeUrl) => { this._window.nativeWindow.location.href = authorizeUrl; }); } + /** + * Unlink existing person profile from orcid + */ unlinkOrcid(): void { - this.unlinkProcessing = true; - this.researcherProfileService.unlinkOrcid(this.item).subscribe((remoteData) => { - this.unlinkProcessing = false; + this.unlinkProcessing.next(true); + this.researcherProfileService.unlinkOrcid(this.item).subscribe((remoteData: RemoteData) => { + this.unlinkProcessing.next(false); if (remoteData.isSuccess) { this.notificationsService.success(this.translateService.get('person.page.orcid.unlink.success')); + this.unlink.emit(); } else { this.notificationsService.error(this.translateService.get('person.page.orcid.unlink.error')); } }); } + /** + * initialize all Orcid authentication settings + * @private + */ + private initOrcidAuthSettings(): void { + + this.setOrcidAuthorizationsFromItem(); + + this.setMissingOrcidAuthorizations(); + + this.researcherProfileService.onlyAdminCanDisconnectProfileFromOrcid().subscribe((result) => { + this.onlyAdminCanDisconnectProfileFromOrcid$.next(result); + }); + + this.researcherProfileService.ownerCanDisconnectProfileFromOrcid().subscribe((result) => { + this.ownerCanDisconnectProfileFromOrcid$.next(result); + }); + + this.isOrcidLinked$.next(this.researcherProfileService.isLinkedToOrcid(this.item)); + } + + private setMissingOrcidAuthorizations(): void { + const profileScopes = this.researcherProfileService.getOrcidAuthorizationScopesByItem(this.item); + const orcidScopes = this.orcidAuthorizationScopes.value; + const missingScopes = orcidScopes.filter((scope) => !profileScopes.includes(scope)); + + this.missingAuthorizationScopes.next(missingScopes); + } + + private setOrcidAuthorizationsFromItem(): void { + this.profileAuthorizationScopes.next(this.researcherProfileService.getOrcidAuthorizationScopesByItem(this.item)); + } + } diff --git a/src/app/item-page/orcid-page/orcid-page.component.html b/src/app/item-page/orcid-page/orcid-page.component.html index 91c49596b6..1921db3e92 100644 --- a/src/app/item-page/orcid-page/orcid-page.component.html +++ b/src/app/item-page/orcid-page/orcid-page.component.html @@ -1,4 +1,4 @@ -
+
- - + + diff --git a/src/app/item-page/orcid-page/orcid-page.component.spec.ts b/src/app/item-page/orcid-page/orcid-page.component.spec.ts index 1d61f18c3e..9210d56865 100644 --- a/src/app/item-page/orcid-page/orcid-page.component.spec.ts +++ b/src/app/item-page/orcid-page/orcid-page.component.spec.ts @@ -1,11 +1,13 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { By } from '@angular/platform-browser'; import { of as observableOf } from 'rxjs'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TestScheduler } from 'rxjs/testing'; +import { getTestScheduler } from 'jasmine-marbles'; import { AuthService } from '../../core/auth/auth.service'; import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; @@ -15,14 +17,16 @@ import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } f import { Item } from '../../core/shared/item.model'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { ItemDataService } from '../../core/data/item-data.service'; -fdescribe('OrcidPageComponent test suite', () => { +describe('OrcidPageComponent test suite', () => { let comp: OrcidPageComponent; let fixture: ComponentFixture; - + let scheduler: TestScheduler; let authService: jasmine.SpyObj; let routeStub: jasmine.SpyObj; let routeData: any; + let itemDataService: jasmine.SpyObj; let researcherProfileService: jasmine.SpyObj; const mockItem: Item = Object.assign(new Item(), { @@ -70,6 +74,10 @@ fdescribe('OrcidPageComponent test suite', () => { isLinkedToOrcid: jasmine.createSpy('isLinkedToOrcid') }); + itemDataService = jasmine.createSpyObj('ItemDataService', { + findById: jasmine.createSpy('findById') + }); + void TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot({ @@ -85,6 +93,7 @@ fdescribe('OrcidPageComponent test suite', () => { { provide: ActivatedRoute, useValue: routeStub }, { provide: ResearcherProfileService, useValue: researcherProfileService }, { provide: AuthService, useValue: authService }, + { provide: ItemDataService, useValue: itemDataService }, ], schemas: [NO_ERRORS_SCHEMA] @@ -92,6 +101,7 @@ fdescribe('OrcidPageComponent test suite', () => { })); beforeEach(waitForAsync(() => { + scheduler = getTestScheduler(); fixture = TestBed.createComponent(OrcidPageComponent); comp = fixture.componentInstance; authService.isAuthenticated.and.returnValue(observableOf(true)); @@ -107,7 +117,15 @@ fdescribe('OrcidPageComponent test suite', () => { it('should call isLinkedToOrcid', () => { comp.isLinkedToOrcid(); - expect(researcherProfileService.isLinkedToOrcid).toHaveBeenCalledWith(comp.item); + expect(researcherProfileService.isLinkedToOrcid).toHaveBeenCalledWith(comp.item.value); }); + it('should update item', fakeAsync(() => { + itemDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockItemLinkedToOrcid)); + scheduler.schedule(() => comp.updateItem()); + scheduler.flush(); + + expect(comp.item.value).toEqual(mockItemLinkedToOrcid); + })); + }); diff --git a/src/app/item-page/orcid-page/orcid-page.component.ts b/src/app/item-page/orcid-page/orcid-page.component.ts index 122c199f61..be4e0e7945 100644 --- a/src/app/item-page/orcid-page/orcid-page.component.ts +++ b/src/app/item-page/orcid-page/orcid-page.component.ts @@ -1,15 +1,17 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; import { map } from 'rxjs/operators'; import { ResearcherProfileService } from '../../core/profile/researcher-profile.service'; -import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; import { getItemPageRoute } from '../item-page-routing-paths'; import { AuthService } from '../../core/auth/auth.service'; import { redirectOn4xx } from '../../core/shared/authorized.operators'; +import { ItemDataService } from '../../core/data/item-data.service'; /** * A component that represents the orcid settings page @@ -24,10 +26,11 @@ export class OrcidPageComponent implements OnInit { /** * The item for which showing the orcid settings */ - item: Item; + item: BehaviorSubject = new BehaviorSubject(null); constructor( private authService: AuthService, + private itemService: ItemDataService, private researcherProfileService: ResearcherProfileService, private route: ActivatedRoute, private router: Router @@ -43,7 +46,7 @@ export class OrcidPageComponent implements OnInit { redirectOn4xx(this.router, this.authService), getFirstSucceededRemoteDataPayload() ).subscribe((item) => { - this.item = item; + this.item.next(item); }); } @@ -53,14 +56,27 @@ export class OrcidPageComponent implements OnInit { * @returns the check result */ isLinkedToOrcid(): boolean { - return this.researcherProfileService.isLinkedToOrcid(this.item); + return this.researcherProfileService.isLinkedToOrcid(this.item.value); } /** * Get the route to an item's page */ getItemPage(): string { - return getItemPageRoute(this.item); + return getItemPageRoute(this.item.value); + } + + /** + * Retrieve the updated profile item + */ + updateItem(): void { + this.itemService.findById(this.item.value.id, false).pipe( + getFirstCompletedRemoteData() + ).subscribe((itemRD: RemoteData) => { + if (itemRD.hasSucceeded) { + this.item.next(itemRD.payload); + } + }); } }