Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/app/app.routes.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export const serverRoutes: ServerRoute[] = [
path: 'privacy-policy',
renderMode: RenderMode.Prerender,
},
{
path: 'choose-repository',
renderMode: RenderMode.Prerender,
},
{
path: 'forbidden',
renderMode: RenderMode.Prerender,
Expand Down
7 changes: 7 additions & 0 deletions src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ export const routes: Routes = [
),
data: { skipBreadcrumbs: true },
},
{
path: 'choose-repository',
loadComponent: () =>
import('./features/home/pages/choose-repository/choose-repository.component').then(
(mod) => mod.ChooseRepositoryComponent
),
},
{
path: 'search',
loadComponent: () => import('./features/search/search.component').then((mod) => mod.SearchComponent),
Expand Down
4 changes: 4 additions & 0 deletions src/app/core/constants/storage-keys.const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const STORAGE_KEYS = {
currentUser: 'currentUser',
activeFlags: 'activeFlags',
} as const;
51 changes: 47 additions & 4 deletions src/app/core/services/storage.service.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,70 @@
import { isPlatformBrowser } from '@angular/common';
import { inject, Injectable, PLATFORM_ID } from '@angular/core';

import { STORAGE_KEYS } from '@core/constants/storage-keys.const';
import { UserModel } from '@osf/shared/models/user/user.model';

@Injectable({ providedIn: 'root' })
export class StorageService {
private platformId = inject(PLATFORM_ID);
private readonly platformId = inject(PLATFORM_ID);

getCachedUser(): UserModel | null {
return this.getJson<UserModel>(STORAGE_KEYS.currentUser);
}

setCachedUser(user: UserModel): void {
this.setJson(STORAGE_KEYS.currentUser, user);
}

getItem(key: string): string | null {
getCachedActiveFlags(): string[] {
return this.getJson<string[]>(STORAGE_KEYS.activeFlags) ?? [];
}

setCachedActiveFlags(flags: string[]): void {
this.setJson(STORAGE_KEYS.activeFlags, flags);
}

clearSession(): void {
this.removeItem(STORAGE_KEYS.currentUser);
this.removeItem(STORAGE_KEYS.activeFlags);
}

private getItem(key: string): string | null {
if (isPlatformBrowser(this.platformId)) {
return window.localStorage.getItem(key);
}

return null;
}

setItem(key: string, value: string): void {
private setItem(key: string, value: string): void {
if (isPlatformBrowser(this.platformId)) {
window.localStorage.setItem(key, value);
}
}

removeItem(key: string): void {
private removeItem(key: string): void {
if (isPlatformBrowser(this.platformId)) {
window.localStorage.removeItem(key);
}
}

private getJson<T>(key: string): T | null {
const raw = this.getItem(key);

if (!raw) {
return null;
}

try {
return JSON.parse(raw) as T;
} catch {
this.removeItem(key);
return null;
}
}

private setJson<T>(key: string, value: T): void {
this.setItem(key, JSON.stringify(value));
}
}
163 changes: 163 additions & 0 deletions src/app/core/services/user.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { MockProvider } from 'ng-mocks';

import { firstValueFrom, of, Subject } from 'rxjs';

import { TestBed } from '@angular/core/testing';

import { FEATURE_FLAGS } from '@osf/shared/constants/feature-flags.const';
import { ProfileSettingsKey } from '@osf/shared/enums/profile-settings-key.enum';
import { UserMapper } from '@osf/shared/mappers/user';
import { UserData } from '@osf/shared/models/user/user.model';
import {
UserAcceptedTermsOfServiceJsonApi,
UserDataResponseJsonApi,
} from '@osf/shared/models/user/user-json-api.model';
import { JsonApiService } from '@osf/shared/services/json-api.service';

import { getCurrentUserData, getUserDataJsonApi } from '@testing/data/user/user.data';
import { MOCK_USER } from '@testing/mocks/data.mock';
import { JsonApiServiceMock, JsonApiServiceMockType } from '@testing/providers/json-api.service.mock';

import { ENVIRONMENT } from '../provider/environment.provider';

import { UserService } from './user.service';

describe('UserService', () => {
let service: UserService;
let jsonApiService: JsonApiServiceMockType;

const apiResponse = getCurrentUserData();

const mappedUserData: UserData = {
activeFlags: [FEATURE_FLAGS.WORKFLOW_LAUNCHER],
currentUser: MOCK_USER,
};

beforeEach(() => {
jsonApiService = JsonApiServiceMock.simple();

TestBed.configureTestingModule({
providers: [
UserService,
MockProvider(JsonApiService, jsonApiService),
MockProvider(ENVIRONMENT, { apiDomainUrl: 'https://api.test' }),
],
});

service = TestBed.inject(UserService);
});

it('should expose apiUrl from environment', () => {
expect(service.apiUrl).toBe('https://api.test/v2');
});

it('should fetch and map current user from /v2/', async () => {
jsonApiService.get.mockReturnValue(of(apiResponse));
const mapperSpy = vi.spyOn(UserMapper, 'fromUserDataGetResponse').mockReturnValue(mappedUserData);

const result = await firstValueFrom(service.getCurrentUser());

expect(jsonApiService.get).toHaveBeenCalledWith('https://api.test/v2/');
expect(mapperSpy).toHaveBeenCalledWith(apiResponse);
expect(result).toEqual(mappedUserData);
});

it('should share in-flight getCurrentUser request across concurrent subscribers', async () => {
const response$ = new Subject<UserDataResponseJsonApi>();
jsonApiService.get.mockReturnValue(response$.asObservable());
vi.spyOn(UserMapper, 'fromUserDataGetResponse').mockReturnValue(mappedUserData);

const first = firstValueFrom(service.getCurrentUser());
const second = firstValueFrom(service.getCurrentUser());

expect(jsonApiService.get).toHaveBeenCalledTimes(1);

response$.next(apiResponse);
response$.complete();

await expect(first).resolves.toEqual(mappedUserData);
await expect(second).resolves.toEqual(mappedUserData);
});

it('should fetch again after previous getCurrentUser completes', async () => {
jsonApiService.get.mockReturnValue(of(apiResponse));
vi.spyOn(UserMapper, 'fromUserDataGetResponse').mockReturnValue(mappedUserData);

await firstValueFrom(service.getCurrentUser());
await firstValueFrom(service.getCurrentUser());

expect(jsonApiService.get).toHaveBeenCalledTimes(2);
});

it('should fetch again after resetCurrentUserCache', async () => {
const response$ = new Subject<UserDataResponseJsonApi>();
jsonApiService.get.mockReturnValue(response$.asObservable());
vi.spyOn(UserMapper, 'fromUserDataGetResponse').mockReturnValue(mappedUserData);

const first = firstValueFrom(service.getCurrentUser());
expect(jsonApiService.get).toHaveBeenCalledTimes(1);

service.resetCurrentUserCache();

const second = firstValueFrom(service.getCurrentUser());
expect(jsonApiService.get).toHaveBeenCalledTimes(2);

response$.next(apiResponse);
response$.complete();

await expect(first).resolves.toEqual(mappedUserData);
await expect(second).resolves.toEqual(mappedUserData);
});

it('should fetch and map user by id', async () => {
const userResponse = { data: getUserDataJsonApi() };
jsonApiService.get.mockReturnValue(of(userResponse));
const mapperSpy = vi.spyOn(UserMapper, 'fromUserGetResponse').mockReturnValue(MOCK_USER);

const result = await firstValueFrom(service.getUserById(MOCK_USER.id));

expect(jsonApiService.get).toHaveBeenCalledWith(`https://api.test/v2/users/${MOCK_USER.id}/`);
expect(mapperSpy).toHaveBeenCalledWith(userResponse.data);
expect(result).toEqual(MOCK_USER);
});

it('should patch user profile attributes', async () => {
const userResponse = getUserDataJsonApi();
jsonApiService.patch.mockReturnValue(of(userResponse));
const mapperSpy = vi.spyOn(UserMapper, 'fromUserGetResponse').mockReturnValue(MOCK_USER);
const employment = MOCK_USER.employment;

const result = await firstValueFrom(
service.updateUserProfile(MOCK_USER.id, ProfileSettingsKey.Employment, employment)
);

expect(jsonApiService.patch).toHaveBeenCalledWith(`https://api.test/v2/users/${MOCK_USER.id}/`, {
data: {
type: 'users',
id: MOCK_USER.id,
attributes: { [ProfileSettingsKey.Employment]: employment },
},
});
expect(mapperSpy).toHaveBeenCalledWith(userResponse);
expect(result).toEqual(MOCK_USER);
});

it('should patch accepted terms of service', async () => {
const userResponse = getUserDataJsonApi();
jsonApiService.patch.mockReturnValue(of(userResponse));
const mapperSpy = vi.spyOn(UserMapper, 'fromUserGetResponse').mockReturnValue(MOCK_USER);
const payload: UserAcceptedTermsOfServiceJsonApi = { accepted_terms_of_service: true };

const result = await firstValueFrom(service.updateUserAcceptedTermsOfService(MOCK_USER.id, payload));

expect(jsonApiService.patch).toHaveBeenCalledWith(`https://api.test/v2/users/${MOCK_USER.id}/`, {
data: {
type: 'users',
id: MOCK_USER.id,
attributes: payload,
},
});
expect(mapperSpy).toHaveBeenCalledWith(userResponse);
expect(result).toEqual(MOCK_USER);
});
});
21 changes: 17 additions & 4 deletions src/app/core/services/user.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { map, Observable } from 'rxjs';
import { finalize, map, Observable, shareReplay } from 'rxjs';

import { inject, Injectable } from '@angular/core';

Expand All @@ -21,6 +21,7 @@ import {
export class UserService {
private readonly jsonApiService = inject(JsonApiService);
private readonly environment = inject(ENVIRONMENT);
private currentUserRequest: Observable<UserData> | null = null;

get apiUrl() {
return `${this.environment.apiDomainUrl}/v2`;
Expand All @@ -33,9 +34,21 @@ export class UserService {
}

getCurrentUser(): Observable<UserData> {
return this.jsonApiService
.get<UserDataResponseJsonApi>(`${this.apiUrl}/`)
.pipe(map((response) => UserMapper.fromUserDataGetResponse(response)));
if (!this.currentUserRequest) {
this.currentUserRequest = this.jsonApiService.get<UserDataResponseJsonApi>(`${this.apiUrl}/`).pipe(
map((response) => UserMapper.fromUserDataGetResponse(response)),
finalize(() => {
this.currentUserRequest = null;
}),
shareReplay({ bufferSize: 1, refCount: true })
);
}

return this.currentUserRequest;
}

resetCurrentUserCache(): void {
this.currentUserRequest = null;
}

updateUserProfile(userId: string, key: string, data: ProfileSettingsUpdate): Observable<UserModel> {
Expand Down
Loading
Loading