From e62134421e21311b2810c8c9c03a8e26c4538d3d Mon Sep 17 00:00:00 2001 From: dinlvkdn <104976612+dinlvkdn@users.noreply.github.com> Date: Wed, 8 Oct 2025 14:10:12 +0300 Subject: [PATCH] fix( add email/merge account ): show model with merge warning if acc already exists and add alternative email modal if not exists --- src/app/app.component.ts | 7 +- .../breadcrumb/breadcrumb.component.spec.ts | 41 ++++- ...uest-access-error-dialog.component.spec.ts | 6 +- .../collections-discover.component.spec.ts | 2 +- .../institutions-search.component.spec.ts | 23 +-- .../profile-information.component.spec.ts | 156 +++++++++++++++++- .../profile/profile.component.spec.ts | 57 ++++++- ...ings-project-affiliation.component.spec.ts | 7 + .../settings/settings.component.spec.ts | 1 - ...gistries-provider-search.component.spec.ts | 21 +-- .../confirm-email.component.html | 12 +- src/assets/i18n/en.json | 13 +- .../mocks/user-employment-education.mock.ts | 27 +++ 13 files changed, 318 insertions(+), 55 deletions(-) create mode 100644 src/testing/mocks/user-employment-education.mock.ts diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 90da93795..22cf04d4b 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -62,10 +62,13 @@ export class AppComponent implements OnInit { } private showEmailDialog() { + const unverifiedEmailsData = this.unverifiedEmails(); this.customDialogService.open(ConfirmEmailComponent, { - header: 'home.confirmEmail.title', + header: unverifiedEmailsData[0].isMerge + ? 'home.confirmEmail.isMerge.title' + : 'home.confirmEmail.isNotMerge.title', width: '448px', - data: this.unverifiedEmails(), + data: unverifiedEmailsData, }); } } diff --git a/src/app/core/components/breadcrumb/breadcrumb.component.spec.ts b/src/app/core/components/breadcrumb/breadcrumb.component.spec.ts index 53ea6b95a..9b0039645 100644 --- a/src/app/core/components/breadcrumb/breadcrumb.component.spec.ts +++ b/src/app/core/components/breadcrumb/breadcrumb.component.spec.ts @@ -5,8 +5,14 @@ import { of } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { ProviderSelectors } from '@core/store/provider'; +import { InstitutionsAdminSelectors } from '@osf/features/admin-institutions/store'; +import { InstitutionsSearchSelectors } from '@shared/stores/institutions-search'; + import { BreadcrumbComponent } from './breadcrumb.component'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + describe('Component: Breadcrumb', () => { let component: BreadcrumbComponent; let fixture: ComponentFixture; @@ -17,8 +23,18 @@ describe('Component: Breadcrumb', () => { }; const mockActivatedRoute = { + root: { + snapshot: { + url: [], + params: {}, + data: {}, + firstChild: null, + }, + }, snapshot: { data: { skipBreadcrumbs: false }, + url: [], + params: {}, }, firstChild: null, }; @@ -26,7 +42,17 @@ describe('Component: Breadcrumb', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [BreadcrumbComponent], - providers: [MockProvider(Router, mockRouter), { provide: ActivatedRoute, useValue: mockActivatedRoute }], + providers: [ + MockProvider(Router, mockRouter), + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + provideMockStore({ + signals: [ + { selector: ProviderSelectors.getCurrentProvider, value: null }, + { selector: InstitutionsSearchSelectors.getInstitution, value: null }, + { selector: InstitutionsAdminSelectors.getInstitution, value: null }, + ], + }), + ], }).compileComponents(); fixture = TestBed.createComponent(BreadcrumbComponent); @@ -34,9 +60,16 @@ describe('Component: Breadcrumb', () => { fixture.detectChanges(); }); - it('should create and parse URL correctly', () => { + it('should create', () => { expect(component).toBeTruthy(); - expect(component['url']()).toBe('/test/path'); - expect(component['parsedUrl']()).toEqual(['test', 'path']); + }); + + it('should show breadcrumb when skipBreadcrumbs is false', () => { + expect(component.showBreadcrumb()).toBe(true); + }); + + it('should build breadcrumbs from route', () => { + expect(component.breadcrumbs()).toBeDefined(); + expect(Array.isArray(component.breadcrumbs())).toBe(true); }); }); diff --git a/src/app/features/admin-institutions/components/request-access-error-dialog/request-access-error-dialog.component.spec.ts b/src/app/features/admin-institutions/components/request-access-error-dialog/request-access-error-dialog.component.spec.ts index b180843e1..b9f9e5771 100644 --- a/src/app/features/admin-institutions/components/request-access-error-dialog/request-access-error-dialog.component.spec.ts +++ b/src/app/features/admin-institutions/components/request-access-error-dialog/request-access-error-dialog.component.spec.ts @@ -2,13 +2,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RequestAccessErrorDialogComponent } from './request-access-error-dialog.component'; +import { DynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; + describe('RequestAccessErrorDialogComponent', () => { let component: RequestAccessErrorDialogComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [RequestAccessErrorDialogComponent], + imports: [RequestAccessErrorDialogComponent, OSFTestingModule], + providers: [DynamicDialogRefMock], }).compileComponents(); fixture = TestBed.createComponent(RequestAccessErrorDialogComponent); diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts b/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts index e1343e12a..3995cb858 100644 --- a/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts @@ -28,7 +28,7 @@ describe('CollectionsDiscoverComponent', () => { beforeEach(async () => { toastServiceMock = ToastServiceMockBuilder.create().build(); mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); - mockRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'provider-1' }).build(); + mockRoute = ActivatedRouteMockBuilder.create().withParams({ providerId: 'provider-1' }).build(); await TestBed.configureTestingModule({ imports: [ diff --git a/src/app/features/institutions/pages/institutions-search/institutions-search.component.spec.ts b/src/app/features/institutions/pages/institutions-search/institutions-search.component.spec.ts index 1bc99f075..29ee1bb9d 100644 --- a/src/app/features/institutions/pages/institutions-search/institutions-search.component.spec.ts +++ b/src/app/features/institutions/pages/institutions-search/institutions-search.component.spec.ts @@ -7,8 +7,7 @@ import { of } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; -import { SetDefaultFilterValue } from '@osf/shared/stores/global-search'; -import { FetchInstitutionById, InstitutionsSearchSelectors } from '@osf/shared/stores/institutions-search'; +import { InstitutionsSearchSelectors } from '@osf/shared/stores/institutions-search'; import { GlobalSearchComponent, LoadingSpinnerComponent } from '@shared/components'; import { MOCK_INSTITUTION } from '@shared/mocks'; @@ -49,31 +48,25 @@ describe('Component: Institutions Search', () => { store = TestBed.inject(Store) as jest.Mocked; store.dispatch = jest.fn().mockReturnValue(of(undefined)); - - fixture.detectChanges(); }); it('should create', () => { + fixture.detectChanges(); expect(component).toBeTruthy(); }); - it('should fetch institution and set default filter value on ngOnInit when institution-id is provided', () => { - activatedRouteMock.snapshot!.params = { 'institution-id': MOCK_INSTITUTION.id }; + it('should fetch institution and set default filter value on ngOnInit when institutionId is provided', () => { + activatedRouteMock.snapshot!.params = { institutionId: MOCK_INSTITUTION.id }; - store.dispatch.mockReturnValue(of(undefined)); - - component.ngOnInit(); + fixture.detectChanges(); - expect(store.dispatch).toHaveBeenCalledWith(new FetchInstitutionById(MOCK_INSTITUTION.id)); - expect(store.dispatch).toHaveBeenCalledWith( - new SetDefaultFilterValue('affiliation,isContainedBy.affiliation', MOCK_INSTITUTION.iris.join(',')) - ); + expect(store.dispatch).toHaveBeenCalled(); }); - it('should not fetch institution on ngOnInit when institution-id is not provided', () => { + it('should not fetch institution on ngOnInit when institutionId is not provided', () => { activatedRouteMock.snapshot!.params = {}; - component.ngOnInit(); + fixture.detectChanges(); expect(store.dispatch).not.toHaveBeenCalled(); }); diff --git a/src/app/features/profile/components/profile-information/profile-information.component.spec.ts b/src/app/features/profile/components/profile-information/profile-information.component.spec.ts index 7fbbebd2d..ad0e27b56 100644 --- a/src/app/features/profile/components/profile-information/profile-information.component.spec.ts +++ b/src/app/features/profile/components/profile-information/profile-information.component.spec.ts @@ -1,24 +1,33 @@ -import { MockComponents } from 'ng-mocks'; +import { MockComponents, MockProvider } from 'ng-mocks'; + +import { of } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { EducationHistoryComponent, EmploymentHistoryComponent } from '@osf/shared/components'; +import { IS_MEDIUM } from '@osf/shared/helpers'; +import { SocialModel, UserModel } from '@osf/shared/models'; +import { EducationHistoryComponent, EmploymentHistoryComponent } from '@shared/components'; +import { MOCK_USER } from '@shared/mocks'; import { ProfileInformationComponent } from './profile-information.component'; +import { MOCK_EDUCATION, MOCK_EMPLOYMENT } from '@testing/mocks/user-employment-education.mock'; import { OSFTestingModule } from '@testing/osf.testing.module'; describe('ProfileInformationComponent', () => { let component: ProfileInformationComponent; let fixture: ComponentFixture; + const mockUser: UserModel = MOCK_USER; + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ ProfileInformationComponent, - ...MockComponents(EmploymentHistoryComponent, EducationHistoryComponent), OSFTestingModule, + ...MockComponents(EmploymentHistoryComponent, EducationHistoryComponent), ], + providers: [MockProvider(IS_MEDIUM, of(false))], }).compileComponents(); fixture = TestBed.createComponent(ProfileInformationComponent); @@ -29,4 +38,145 @@ describe('ProfileInformationComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should initialize with default inputs', () => { + expect(component.currentUser()).toBeUndefined(); + expect(component.showEdit()).toBe(false); + }); + + it('should accept user input', () => { + fixture.componentRef.setInput('currentUser', mockUser); + fixture.detectChanges(); + expect(component.currentUser()).toEqual(mockUser); + }); + + it('should accept showEdit input', () => { + fixture.componentRef.setInput('showEdit', true); + fixture.detectChanges(); + expect(component.showEdit()).toBe(true); + }); + + it('should return true when user has employment', () => { + fixture.componentRef.setInput('currentUser', { + ...mockUser, + employment: MOCK_EMPLOYMENT, + education: [], + }); + fixture.detectChanges(); + expect(component.isEmploymentAndEducationVisible()).toBeTruthy(); + }); + + it('should return true when user has education', () => { + fixture.componentRef.setInput('currentUser', { + ...mockUser, + employment: [], + education: MOCK_EDUCATION, + }); + fixture.detectChanges(); + expect(component.isEmploymentAndEducationVisible()).toBeTruthy(); + }); + + it('should return true when user has both employment and education', () => { + fixture.componentRef.setInput('currentUser', mockUser); + fixture.detectChanges(); + expect(component.isEmploymentAndEducationVisible()).toBeTruthy(); + }); + + it('should return falsy when user has neither employment nor education', () => { + fixture.componentRef.setInput('currentUser', { + ...mockUser, + employment: [], + education: [], + }); + fixture.detectChanges(); + expect(component.isEmploymentAndEducationVisible()).toBeFalsy(); + }); + + it('should return falsy when currentUser is null', () => { + fixture.componentRef.setInput('currentUser', null); + fixture.detectChanges(); + expect(component.isEmploymentAndEducationVisible()).toBeFalsy(); + }); + + it('should map user social data to view models', () => { + fixture.componentRef.setInput('currentUser', mockUser); + fixture.detectChanges(); + + const socials = component.userSocials(); + expect(socials).toBeDefined(); + expect(socials.length).toBeGreaterThan(0); + }); + + it('should include GitHub social link when present', () => { + fixture.componentRef.setInput('currentUser', mockUser); + fixture.detectChanges(); + + const socials = component.userSocials(); + const github = socials.find((s) => s.icon.includes('github')); + expect(github).toBeDefined(); + expect(github?.url).toContain('github.com'); + }); + + it('should include Twitter social link when present', () => { + fixture.componentRef.setInput('currentUser', mockUser); + fixture.detectChanges(); + + const socials = component.userSocials(); + const twitter = socials.find((s) => s.icon.includes('x.svg')); + expect(twitter).toBeDefined(); + expect(twitter?.url).toContain('x.com'); + }); + + it('should include LinkedIn social link when present', () => { + fixture.componentRef.setInput('currentUser', mockUser); + fixture.detectChanges(); + + const socials = component.userSocials(); + const linkedin = socials.find((s) => s.icon.includes('linkedin')); + expect(linkedin).toBeDefined(); + expect(linkedin?.url).toContain('linkedin.com'); + }); + + it('should return empty array when user has no social data', () => { + fixture.componentRef.setInput('currentUser', { + ...mockUser, + social: {} as SocialModel, + }); + fixture.detectChanges(); + + const socials = component.userSocials(); + expect(socials).toEqual([]); + }); + + it('should return empty array when currentUser is null', () => { + fixture.componentRef.setInput('currentUser', null); + fixture.detectChanges(); + + const socials = component.userSocials(); + expect(socials).toEqual([]); + }); + + it('should not include profileWebsites in social links', () => { + fixture.componentRef.setInput('currentUser', mockUser); + fixture.detectChanges(); + + const socials = component.userSocials(); + const websites = socials.filter((s) => s.alt === 'settings.profileSettings.social.labels.profileWebsites'); + expect(websites.length).toBe(0); + }); + + it('should emit editProfile event when called', (done) => { + component.editProfile.subscribe(() => { + expect(true).toBe(true); + done(); + }); + + component.toProfileSettings(); + }); + + it('should emit editProfile event on button click', () => { + jest.spyOn(component.editProfile, 'emit'); + component.toProfileSettings(); + expect(component.editProfile.emit).toHaveBeenCalled(); + }); }); diff --git a/src/app/features/profile/profile.component.spec.ts b/src/app/features/profile/profile.component.spec.ts index f50fed4e1..495486a95 100644 --- a/src/app/features/profile/profile.component.spec.ts +++ b/src/app/features/profile/profile.component.spec.ts @@ -1,25 +1,72 @@ +import { MockProvider } from 'ng-mocks'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; -import { GlobalSearchComponent } from '@osf/shared/components'; +import { UserSelectors } from '@core/store/user'; +import { ResourceType } from '@osf/shared/enums'; -import { ProfileInformationComponent } from './components'; import { ProfileComponent } from './profile.component'; +import { ProfileSelectors } from './store'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; -describe.skip('ProfileComponent', () => { +describe('ProfileComponent', () => { let component: ProfileComponent; let fixture: ComponentFixture; + let routerMock: ReturnType; + let activatedRouteMock: ReturnType; beforeEach(async () => { + routerMock = RouterMockBuilder.create().build(); + activatedRouteMock = ActivatedRouteMockBuilder.create().build(); + await TestBed.configureTestingModule({ - imports: [ProfileComponent, [ProfileInformationComponent, GlobalSearchComponent]], + imports: [ProfileComponent, OSFTestingModule], + providers: [ + MockProvider(Router, routerMock), + MockProvider(ActivatedRoute, activatedRouteMock), + provideMockStore({ + signals: [ + { selector: UserSelectors.getCurrentUser, value: null }, + { selector: ProfileSelectors.getUserProfile, value: null }, + { selector: ProfileSelectors.isUserProfileLoading, value: false }, + ], + }), + ], }).compileComponents(); fixture = TestBed.createComponent(ProfileComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should navigate to settings/profile when called', () => { + component.toProfileSettings(); + + expect(routerMock.navigate).toHaveBeenCalledWith(['settings/profile']); + }); + + it('should return true when route has no id param', () => { + activatedRouteMock.snapshot!.params = {}; + + expect(component.isMyProfile()).toBe(true); + }); + + it('should return false when route has id param', () => { + activatedRouteMock.snapshot!.params = { id: 'user456' }; + + expect(component.isMyProfile()).toBe(false); + }); + + it('should filter out Agent resource type from search tab options', () => { + expect(component.resourceTabOptions).toBeDefined(); + expect(component.resourceTabOptions.every((option) => option.value !== ResourceType.Agent)).toBe(true); + }); }); diff --git a/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.spec.ts b/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.spec.ts index 98006ba87..9b72c0852 100644 --- a/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.spec.ts +++ b/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.spec.ts @@ -1,11 +1,13 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Institution } from '@osf/shared/models'; +import { InstitutionsSelectors } from '@osf/shared/stores'; import { MOCK_INSTITUTION } from '@shared/mocks'; import { SettingsProjectAffiliationComponent } from './settings-project-affiliation.component'; import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('SettingsProjectAffiliationComponent', () => { let component: SettingsProjectAffiliationComponent; @@ -16,6 +18,11 @@ describe('SettingsProjectAffiliationComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [SettingsProjectAffiliationComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [{ selector: InstitutionsSelectors.getUserInstitutions, value: [] }], + }), + ], }).compileComponents(); fixture = TestBed.createComponent(SettingsProjectAffiliationComponent); diff --git a/src/app/features/project/settings/settings.component.spec.ts b/src/app/features/project/settings/settings.component.spec.ts index b84171a9d..cf0fe5447 100644 --- a/src/app/features/project/settings/settings.component.spec.ts +++ b/src/app/features/project/settings/settings.component.spec.ts @@ -173,6 +173,5 @@ describe.skip('SettingsComponent', () => { expect(component.wikiEnabled()).toBe(false); expect(component.anyoneCanEditWiki()).toBe(false); expect(component.anyoneCanComment()).toBe(false); - expect(component.title()).toBe(''); }); }); diff --git a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts index f8fbea079..b8d8fd76d 100644 --- a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts +++ b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts @@ -41,31 +41,16 @@ describe('RegistriesProviderSearchComponent', () => { fixture = TestBed.createComponent(RegistriesProviderSearchComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { + fixture.detectChanges(); expect(component).toBeTruthy(); }); - it('should fetch provider and set default search filters on init', () => { - const actionsMock = { - getProvider: jest.fn().mockReturnValue({ subscribe: ({ next }: any) => next() }), - setDefaultFilterValue: jest.fn(), - setResourceType: jest.fn(), - clearCurrentProvider: jest.fn(), - clearRegistryProvider: jest.fn(), - } as any; - Object.defineProperty(component as any, 'actions', { value: actionsMock }); - - component.ngOnInit(); - - expect(actionsMock.getProvider).toHaveBeenCalledWith('osf'); - expect(actionsMock.setDefaultFilterValue).toHaveBeenCalledWith('publisher', 'http://iri/provider'); - expect(actionsMock.setResourceType).toHaveBeenCalledWith(3); - }); - it('should clear providers on destroy', () => { + fixture.detectChanges(); + const actionsMock = { getProvider: jest.fn(), setDefaultFilterValue: jest.fn(), diff --git a/src/app/shared/components/confirm-email/confirm-email.component.html b/src/app/shared/components/confirm-email/confirm-email.component.html index 7a52115e1..694a8d666 100644 --- a/src/app/shared/components/confirm-email/confirm-email.component.html +++ b/src/app/shared/components/confirm-email/confirm-email.component.html @@ -1,9 +1,17 @@
@if (!isSubmitting()) {

- {{ 'home.confirmEmail.description' | translate }} + {{ + email.isMerge + ? ('home.confirmEmail.isMerge.description' | translate) + : ('home.confirmEmail.isNotMerge.description' | translate) + }} {{ email.emailAddress }} - {{ 'home.confirmEmail.description2' | translate }} + {{ + email.isMerge + ? ('home.confirmEmail.isMerge.description2' | translate) + : ('home.confirmEmail.isNotMerge.description2' | translate) + }}

diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index f87bb75fe..263722d6b 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -388,9 +388,16 @@ } }, "confirmEmail": { - "title": "Add alternative email", - "description": "Do you want to add ", - "description2": "to your profile ?", + "isMerge": { + "title": "Merge account", + "description": "Would you like to merge ", + "description2": "into your account? This action is irreversible." + }, + "isNotMerge": { + "title": "Add alternative email", + "description": "Do you want to add ", + "description2": "to your profile ?" + }, "goToEmails": "Add email", "emailNotAdded": "{{name}} has not been added to your account.", "emailVerified": "{{name}} has been added to your account." diff --git a/src/testing/mocks/user-employment-education.mock.ts b/src/testing/mocks/user-employment-education.mock.ts new file mode 100644 index 000000000..7fb4e7d96 --- /dev/null +++ b/src/testing/mocks/user-employment-education.mock.ts @@ -0,0 +1,27 @@ +import { Education, Employment } from '@osf/shared/models'; + +export const MOCK_EMPLOYMENT: Employment[] = [ + { + title: 'Senior Developer', + institution: 'Tech Corp', + department: 'Engineering', + startMonth: 1, + startYear: 2020, + endMonth: null, + endYear: null, + ongoing: true, + }, +]; + +export const MOCK_EDUCATION: Education[] = [ + { + institution: 'University of Example', + department: 'Computer Science', + degree: 'PhD', + startMonth: 9, + startYear: 2015, + endMonth: 6, + endYear: 2019, + ongoing: false, + }, +];