diff --git a/client/src/app/instance/instance-routing.module.ts b/client/src/app/instance/instance-routing.module.ts index 53ae1296d4a2648f295956697e42626368d31be5..e6795b8b3773c388b49be1bf6f66322564b78b99 100644 --- a/client/src/app/instance/instance-routing.module.ts +++ b/client/src/app/instance/instance-routing.module.ts @@ -24,6 +24,10 @@ const routes: Routes = [ } ]; +/** + * @class + * @classdesc Instance routing module. + */ @NgModule({ imports: [RouterModule.forChild(routes)], exports: [RouterModule] diff --git a/client/src/app/instance/instance.component.spec.ts b/client/src/app/instance/instance.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..06d51a4436643232c7850fe75179743c1ac2c1b8 --- /dev/null +++ b/client/src/app/instance/instance.component.spec.ts @@ -0,0 +1,155 @@ +import { Component, Input } from '@angular/core'; +import { TestBed, waitForAsync, ComponentFixture } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { of } from 'rxjs'; + +import { InstanceComponent } from './instance.component'; +import { AppConfigService } from 'src/app/app-config.service'; +import * as authActions from 'src/app/auth/auth.actions'; +import { Instance } from '../metamodel/models'; +import { UserProfile } from '../auth/user-profile.model'; +import * as datasetFamilyActions from '../metamodel/actions/dataset-family.actions'; +import * as datasetActions from '../metamodel/actions/dataset.actions'; +import * as surveyActions from '../metamodel/actions/survey.actions'; + +describe('InstanceComponent', () => { + @Component({ selector: 'app-navbar', template: '' }) + class NavbarStubComponent { + @Input() links: {label: string, icon: string, routerLink: string}[]; + @Input() isAuthenticated: boolean; + @Input() userProfile: UserProfile = null; + @Input() baseHref: string; + @Input() authenticationEnabled: boolean; + @Input() apiUrl: string; + @Input() instance: Instance; + } + + let component: InstanceComponent; + let fixture: ComponentFixture<InstanceComponent>; + let store: MockStore; + let appConfigServiceStub = new AppConfigService(); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [RouterTestingModule], + declarations: [ + InstanceComponent, + NavbarStubComponent + ], + providers: [ + provideMockStore({ }), + { provide: AppConfigService, useValue: appConfigServiceStub } + ] + }).compileComponents(); + fixture = TestBed.createComponent(InstanceComponent); + component = fixture.componentInstance; + store = TestBed.inject(MockStore); + document.body.innerHTML = + '<title id="title">Default title</title>' + + '<link id="favicon" href="">'; + })); + + it('should create the component', () => { + expect(component).toBeDefined(); + }); + + it('should execute ngOnInit lifecycle', (done) => { + const instance: Instance = { + name: 'myInstance', + label: 'My Instance', + data_path: 'data/path', + config: { + design: { + design_color: 'green', + design_background_color: 'darker green', + design_logo: 'path/to/logo', + design_favicon: 'path/to/favicon' + }, + home: { + home_component: 'HomeComponent', + home_config: { + home_component_text: 'Description', + home_component_logo: 'path/to/logo' + } + }, + search: { + search_by_criteria_allowed: true, + search_by_criteria_label: 'Search', + search_multiple_allowed: true, + search_multiple_label: 'Search multiple', + search_multiple_all_datasets_selected: false + }, + documentation: { + documentation_allowed: true, + documentation_label: 'Documentation' + } + }, + nb_dataset_families: 1, + nb_datasets: 2 + }; + component.instance = of(instance); + const spy = jest.spyOn(store, 'dispatch'); + const expectedLinks = [ + { label: 'Home', icon: 'fas fa-home', routerLink: 'home' }, + { label: 'Search', icon: 'fas fa-search', routerLink: 'search' }, + { label: 'Search multiple', icon: 'fas fa-search-plus', routerLink: 'search-multiple' }, + { label: 'Documentation', icon: 'fas fa-question', routerLink: 'documentation' } + ]; + component.ngOnInit(); + Promise.resolve(null).then(function() { + expect(spy).toHaveBeenCalledTimes(3); + expect(spy).toHaveBeenCalledWith(datasetFamilyActions.loadDatasetFamilyList()); + expect(spy).toHaveBeenCalledWith(datasetActions.loadDatasetList()); + expect(spy).toHaveBeenCalledWith(surveyActions.loadSurveyList()); + expect(component.links).toEqual(expectedLinks); + expect(component.favIcon.href).toEqual('http://localhost/undefined/download-instance-file/myInstance/path/to/favicon'); + expect(component.title.textContent).toEqual('My Instance'); + done(); + }); + }); + + it('#getBaseHref() should return base href config key value', () => { + appConfigServiceStub.baseHref = '/my-project'; + expect(component.getBaseHref()).toBe('/my-project'); + }); + + it('#authenticationEnabled() should return authentication enabled config key value', () => { + appConfigServiceStub.authenticationEnabled = true; + expect(component.getAuthenticationEnabled()).toBeTruthy(); + }); + + it('#getApiUrl() should return API URL', () => { + appConfigServiceStub.apiUrl = 'http:test.com'; + expect(component.getApiUrl()).toEqual('http:test.com'); + }); + + it('#login() should dispatch login action', () => { + const spy = jest.spyOn(store, 'dispatch'); + component.login(); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(authActions.login()); + }); + + it('#logout() should dispatch logout action', () => { + const spy = jest.spyOn(store, 'dispatch'); + component.logout(); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(authActions.logout()); + }); + + it('#openEditProfile() should dispatch open edit profile action', () => { + const spy = jest.spyOn(store, 'dispatch'); + component.openEditProfile(); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(authActions.openEditProfile()); + }); + + it('should unsubscribe to instance when component is destroyed', () => { + component.instanceSubscription = of().subscribe(); + const spy = jest.spyOn(component.instanceSubscription, 'unsubscribe'); + component.ngOnDestroy(); + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/app/instance/instance.component.ts b/client/src/app/instance/instance.component.ts index fa981f4b2c09c1d62b2d27f7ee1c1917a6ca8db7..bb78d58ce124b33a795c7e5f88dc0fee2a80bc7a 100644 --- a/client/src/app/instance/instance.component.ts +++ b/client/src/app/instance/instance.component.ts @@ -8,8 +8,9 @@ */ import { Component, OnDestroy, OnInit } from '@angular/core'; -import { Observable, Subscription } from 'rxjs'; + import { Store } from '@ngrx/store'; +import { Observable, Subscription } from 'rxjs'; import { UserProfile } from 'src/app/auth/user-profile.model'; import { Instance } from 'src/app/metamodel/models'; @@ -21,25 +22,25 @@ import * as surveyActions from 'src/app/metamodel/actions/survey.actions'; import * as instanceSelector from 'src/app/metamodel/selectors/instance.selector'; import { AppConfigService } from 'src/app/app-config.service'; -@Component({ - selector: 'app-instance', - templateUrl: 'instance.component.html' -}) /** * @class * @classdesc Instance container + * + * @implements OnInit + * @implements OnDestroy */ +@Component({ + selector: 'app-instance', + templateUrl: 'instance.component.html' +}) export class InstanceComponent implements OnInit, OnDestroy { public favIcon: HTMLLinkElement = document.querySelector('#favicon'); public title: HTMLLinkElement = document.querySelector('#title'); - public links = [ - { label: 'Home', icon: 'fas fa-home', routerLink: 'home' } - ]; + public links = [{ label: 'Home', icon: 'fas fa-home', routerLink: 'home' }]; public instance: Observable<Instance>; public isAuthenticated: Observable<boolean>; public userProfile: Observable<UserProfile>; public userRoles: Observable<string[]>; - public instanceSubscription: Subscription; constructor(private store: Store<{ }>, private config: AppConfigService) { @@ -50,6 +51,8 @@ export class InstanceComponent implements OnInit, OnDestroy { } ngOnInit() { + // Create a micro task that is processed after the current synchronous code + // This micro task prevent the expression has changed after view init error Promise.resolve(null).then(() => this.store.dispatch(datasetFamilyActions.loadDatasetFamilyList())); Promise.resolve(null).then(() => this.store.dispatch(datasetActions.loadDatasetList())); Promise.resolve(null).then(() => this.store.dispatch(surveyActions.loadSurveyList())); @@ -70,31 +73,58 @@ export class InstanceComponent implements OnInit, OnDestroy { }) } - getBaseHref() { + /** + * Returns application base href. + * + * @return string + */ + getBaseHref(): string { return this.config.baseHref; } - getAuthenticationEnabled() { + /** + * Checks if authentication is enabled. + * + * @return boolean + */ + getAuthenticationEnabled(): boolean { return this.config.authenticationEnabled; } - getApiUrl() { + /** + * Returns API URL. + * + * @return string + */ + getApiUrl(): string { return this.config.apiUrl; } + /** + * Dispatches action to log in. + */ login(): void { this.store.dispatch(authActions.login()); } + /** + * Dispatches action to log out. + */ logout(): void { this.store.dispatch(authActions.logout()); } + /** + * Dispatches action to open profile editor. + */ openEditProfile(): void { this.store.dispatch(authActions.openEditProfile()); } + /** + * Unsubscribes to instance when component is destroyed. + */ ngOnDestroy() { - this.instanceSubscription.unsubscribe(); + if (this.instanceSubscription) this.instanceSubscription.unsubscribe(); } } diff --git a/client/src/app/instance/instance.module.ts b/client/src/app/instance/instance.module.ts index ffedd2ae3a7e9336104f44a5f56aa14b9d54c56b..33a6514300c63f72977df896246e0cf0bc1135a9 100644 --- a/client/src/app/instance/instance.module.ts +++ b/client/src/app/instance/instance.module.ts @@ -18,6 +18,10 @@ import { instanceReducer } from './instance.reducer'; import { instanceEffects } from './store/effects'; import { instanceServices } from './store/services'; +/** + * @class + * @classdesc Instance module. + */ @NgModule({ imports: [ SharedModule, diff --git a/client/src/app/instance/instance.reducer.ts b/client/src/app/instance/instance.reducer.ts index d14c08ae52efefef8b043088ae0b74cddbb1a44d..2c3b3c970e64e3c185933ae2cd8f57515fe97bd8 100644 --- a/client/src/app/instance/instance.reducer.ts +++ b/client/src/app/instance/instance.reducer.ts @@ -16,6 +16,11 @@ import * as samp from './store/reducers/samp.reducer'; import * as coneSearch from './store/reducers/cone-search.reducer'; import * as detail from './store/reducers/detail.reducer'; +/** + * Interface for instance state. + * + * @interface State + */ export interface State { search: search.State, searchMultiple: searchMultiple.State, diff --git a/client/src/app/instance/store/effects/search-multiple.effects.spec.ts b/client/src/app/instance/store/effects/search-multiple.effects.spec.ts index ed415dad1fe4868f45a89b6451b1e40a623f3d86..c3aaca6262d8b64615159ad9ac970bca5190bbec 100644 --- a/client/src/app/instance/store/effects/search-multiple.effects.spec.ts +++ b/client/src/app/instance/store/effects/search-multiple.effects.spec.ts @@ -9,20 +9,13 @@ import { ToastrService } from 'ngx-toastr'; import { SearchMultipleEffects } from './search-multiple.effects'; import { SearchService } from '../services/search.service'; -import * as searchActions from '../actions/search.actions'; import * as fromSearch from '../reducers/search.reducer'; import * as fromSearchMultiple from '../reducers/search-multiple.reducer'; import * as fromInstance from '../../../metamodel/reducers/instance.reducer'; import * as datasetSelector from '../../../metamodel/selectors/dataset.selector'; -import * as searchSelector from '../selectors/search.selector'; -import * as attributeActions from '../../../metamodel/actions/attribute.actions'; -import * as criteriaFamilyActions from '../../../metamodel/actions/criteria-family.actions'; -import * as outputFamilyActions from '../../../metamodel/actions/output-family.actions'; -import * as outputCategoryActions from '../../../metamodel/actions/output-category.actions'; -import * as attributeSelector from '../../../metamodel/selectors/attribute.selector'; import * as coneSearchSelector from '../selectors/cone-search.selector'; import * as coneSearchActions from '../actions/cone-search.actions'; -import { ConeSearch, Criterion, PaginationOrder, SearchMultipleDatasetLength } from '../models'; +import { ConeSearch, SearchMultipleDatasetLength } from '../models'; import * as searchMultipleSelector from '../selectors/search-multiple.selector'; import * as instanceSelector from '../../../metamodel/selectors/instance.selector'; import * as searchMultipleActions from '../actions/search-multiple.actions'; @@ -322,67 +315,6 @@ describe('SearchMultipleEffects', () => { expect(effects.initSearch$).toBeObservable(expected); }); - - // it('should dispatch a bunch of actions when a dataset is selected or a page is reloaded with steps checked', () => { - // mockDatasetSelectorSelectDatasetNameByRoute = store.overrideSelector( - // datasetSelector.selectDatasetNameByRoute, 'myDatasetName' - // ); - // mockSearchSelectorSelectPristine = store.overrideSelector( - // searchSelector.selectPristine,true - // ); - // mockSearchSelectorSelectCurrentDataset = store.overrideSelector( - // searchSelector.selectCurrentDataset,'myDatasetName' - // ); - // mockSearchSelectorSelectStepsByRoute = store.overrideSelector( - // searchSelector.selectStepsByRoute, '111' - // ); - // - // const action = searchActions.initSearch(); - // actions = hot('-a', { a: action }); - // const expected = cold('-(bcdefghi)', { - // b: searchActions.changeCurrentDataset({ currentDataset: 'myDatasetName' }), - // c: attributeActions.loadAttributeList(), - // d: criteriaFamilyActions.loadCriteriaFamilyList(), - // e: outputFamilyActions.loadOutputFamilyList(), - // f: outputCategoryActions.loadOutputCategoryList(), - // g: searchActions.checkCriteria(), - // h: searchActions.checkOutput(), - // i: searchActions.checkResult() - // }); - // - // expect(effects.initSearch$).toBeObservable(expected); - // }); - // - // it('should dispatch a resetSearch action when user get back to search module', () => { - // mockDatasetSelectorSelectDatasetNameByRoute = store.overrideSelector( - // datasetSelector.selectDatasetNameByRoute, '' - // ); - // mockSearchSelectorSelectPristine = store.overrideSelector( - // searchSelector.selectPristine,false - // ); - // - // const action = searchActions.initSearch(); - // const outcome = searchActions.resetSearch(); - // - // actions = hot('-a', { a: action }); - // const expected = cold('-b', { b: outcome }); - // - // expect(effects.initSearch$).toBeObservable(expected); - // }); - // - // it('should not dispatch action when step changed on same search', () => { - // mockDatasetSelectorSelectDatasetNameByRoute = store.overrideSelector( - // datasetSelector.selectDatasetNameByRoute, '' - // ); - // - // const action = searchActions.initSearch(); - // const outcome = { type: '[No Action] Init Search' }; - // - // actions = hot('-a', { a: action }); - // const expected = cold('-b', { b: outcome }); - // - // expect(effects.initSearch$).toBeObservable(expected); - // }); }); describe('restartSearch$ effect', () => { @@ -487,86 +419,4 @@ describe('SearchMultipleEffects', () => { expect(spy).toHaveBeenCalledWith('Loading Failed', 'The search multiple data length loading failed'); }); }); - - // describe('retrieveData$ effect', () => { - // it('should dispatch the retrieveDataSuccess action on success', () => { - // const action = searchActions.retrieveData( { - // pagination: { dname: 'myDatasetName', page: 1, nbItems: 10, sortedCol: 1, order: PaginationOrder.a } - // }); - // const outcome = searchActions.retrieveDataSuccess({ data: ['data'] }); - // - // actions = hot('-a', { a: action }); - // const response = cold('-b|', { b: ['data'] }); - // const expected = cold('--c', { c: outcome }); - // searchService.retrieveData = jest.fn(() => response); - // - // expect(effects.retrieveData$).toBeObservable(expected); - // }); - // - // it('should dispatch the retrieveDataFail action on failure', () => { - // const action = searchActions.retrieveData({ - // pagination: { dname: 'myDatasetName', page: 1, nbItems: 10, sortedCol: 1, order: PaginationOrder.a } - // }); - // const error = new Error(); - // const outcome = searchActions.retrieveDataFail(); - // - // actions = hot('-a', { a: action }); - // const response = cold('-#|', {}, error); - // const expected = cold('--b', { b: outcome }); - // searchService.retrieveData = jest.fn(() => response); - // - // expect(effects.retrieveData$).toBeObservable(expected); - // }); - // - // it('should pass correct query to the service', () => { - // mockDatasetSelectorSelectDatasetNameByRoute = store.overrideSelector( - // datasetSelector.selectDatasetNameByRoute, 'myDataset' - // ); - // mockSearchSelectorSelectCriteriaList = store.overrideSelector( - // searchSelector.selectCriteriaList, [{'id':1,'type':'field','operator':'eq','value':'one'} as Criterion] - // ); - // mockConeSearchSelectorSelectConeSearch = store.overrideSelector( - // coneSearchSelector.selectConeSearch, { ra: 1, dec: 2, radius: 3 } - // ); - // mockSearchSelectorSelectOutputList = store.overrideSelector( - // searchSelector.selectOutputList, [1, 2] - // ); - // - // jest.spyOn(searchService, 'retrieveData'); - // - // const action = searchActions.retrieveData({ - // pagination: { dname: 'myDatasetName', page: 1, nbItems: 10, sortedCol: 1, order: PaginationOrder.a } - // }); - // const outcome = searchActions.retrieveDataSuccess({ data: ['data'] }); - // - // actions = hot('-a', { a: action }); - // const response = cold('-b|', { b: ['data'] }); - // const expected = cold('--c', { c: outcome }); - // searchService.retrieveData = jest.fn(() => response); - // - // expect(effects.retrieveData$).toBeObservable(expected); - // expect(searchService.retrieveData).toHaveBeenCalledTimes(1); - // expect(searchService.retrieveData).toHaveBeenCalledWith('myDataset?a=1;2&c=1::eq::one&cs=1:2:3&p=10:1&o=1:a'); - // }); - // }); - - // describe('retrieveDataFail$ effect', () => { - // it('should not dispatch', () => { - // expect(metadata.retrieveDataFail$).toEqual( - // expect.objectContaining({ dispatch: false }) - // ); - // }); - // - // it('should display a error notification', () => { - // const spy = jest.spyOn(toastr, 'error'); - // const action = searchActions.retrieveDataFail(); - // - // actions = hot('a', { a: action }); - // const expected = cold('a', { a: action }); - // - // expect(effects.retrieveDataFail$).toBeObservable(expected); - // expect(spy).toHaveBeenCalledTimes(1); - // expect(spy).toHaveBeenCalledWith('Loading Failed', 'The search data loading failed'); - // }); - // }); });