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
27 changes: 14 additions & 13 deletions src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { provideRouter, withInMemoryScrolling } from '@angular/router';

import { STATES } from '@core/constants';
import { APPLICATION_INITIALIZATION_PROVIDER } from '@core/factory/application.initialization.factory';
import { SENTRY_PROVIDER } from '@core/factory/sentry.factory';
import { provideTranslation } from '@core/helpers';

import { authInterceptor, errorInterceptor, viewOnlyInterceptor } from './core/interceptors';
Expand All @@ -23,9 +24,15 @@ import * as Sentry from '@sentry/angular';

export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'top', anchorScrolling: 'enabled' })),
provideStore(STATES, withNgxsReduxDevtoolsPlugin({ disabled: false })),
APPLICATION_INITIALIZATION_PROVIDER,
ConfirmationService,
{
provide: ErrorHandler,
useFactory: () => Sentry.createErrorHandler({ showDialog: false }),
},
importProvidersFrom(TranslateModule.forRoot(provideTranslation())),
MessageService,
provideAnimations(),
providePrimeNG({
theme: {
preset: CustomPreset,
Expand All @@ -38,16 +45,10 @@ export const appConfig: ApplicationConfig = {
},
},
}),
provideAnimations(),
provideHttpClient(withInterceptors([authInterceptor, viewOnlyInterceptor, errorInterceptor])),
importProvidersFrom(TranslateModule.forRoot(provideTranslation())),
ConfirmationService,
MessageService,

APPLICATION_INITIALIZATION_PROVIDER,
{
provide: ErrorHandler,
useFactory: () => Sentry.createErrorHandler({ showDialog: false }),
},
provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'top', anchorScrolling: 'enabled' })),
provideStore(STATES, withNgxsReduxDevtoolsPlugin({ disabled: false })),
provideZoneChangeDetection({ eventCoalescing: true }),
SENTRY_PROVIDER,
],
};
19 changes: 19 additions & 0 deletions src/app/core/factory/sentry.factory.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { TestBed } from '@angular/core/testing';

import { SENTRY_PROVIDER, SENTRY_TOKEN } from './sentry.factory';

import * as Sentry from '@sentry/angular';

describe('Factory: Sentry', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [SENTRY_PROVIDER],
});
});

it('should provide the Sentry module via the injection token', () => {
const provided = TestBed.inject(SENTRY_TOKEN);
expect(provided).toBe(Sentry);
expect(typeof provided.captureException).toBe('function');
});
});
49 changes: 49 additions & 0 deletions src/app/core/factory/sentry.factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { InjectionToken } from '@angular/core';

import * as Sentry from '@sentry/angular';

/**
* Injection token used to provide the Sentry module via Angular's dependency injection system.
*
* This token represents the entire Sentry module (`@sentry/angular`), allowing you to inject
* and use Sentry APIs (e.g., `captureException`, `init`, `setUser`, etc.) in Angular services
* or components.
*
* @example
* ```ts
* const Sentry = inject(SENTRY_TOKEN);
* Sentry.captureException(new Error('Something went wrong'));
* ```
*/
export const SENTRY_TOKEN = new InjectionToken<typeof Sentry>('Sentry');

/**
* Angular provider that binds the `SENTRY_TOKEN` to the actual `@sentry/angular` module.
*
* Use this provider in your module or application configuration to make Sentry injectable.
*
* @example
* ```ts
* providers: [
* SENTRY_PROVIDER,
* ]
* ```
*
* Inject the Sentry module via the factory token
* private readonly Sentry = inject(SENTRY_TOKEN);
*
* throwError(): void {
* try {
* throw new Error('Test error for Sentry capture');
* } catch (error) {
* Send the error to Sentry
* this.Sentry.captureException(error);
* }
* }
*
* @see SENTRY_TOKEN
*/
export const SENTRY_PROVIDER = {
provide: SENTRY_TOKEN,
useValue: Sentry,
};
8 changes: 0 additions & 8 deletions src/app/core/handlers/global-error.handler.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/app/core/handlers/index.ts

This file was deleted.

30 changes: 14 additions & 16 deletions src/app/core/store/user/user.state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,23 +268,21 @@ export class UserState {
};
const apiRequest = UserMapper.toAcceptedTermsOfServiceRequest(updatePayload);

return this.userService
.updateUserAcceptedTermsOfService(currentUser.id, apiRequest)
.pipe(
tap((response: User): void => {
if (response.acceptedTermsOfService) {
ctx.patchState({
currentUser: {
...state.currentUser,
data: {
...currentUser,
acceptedTermsOfService: true,
},
return this.userService.updateUserAcceptedTermsOfService(currentUser.id, apiRequest).pipe(
tap((response: User): void => {
if (response.acceptedTermsOfService) {
ctx.patchState({
currentUser: {
...state.currentUser,
data: {
...currentUser,
acceptedTermsOfService: true,
},
});
}
})
);
},
});
}
})
);
}

@Action(ClearCurrentUser)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { effect, inject, Injectable, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';

import { SENTRY_TOKEN } from '@core/factory/sentry.factory';
import { collectionsSortOptions } from '@osf/features/collections/constants';
import { queryParamsKeys } from '@osf/features/collections/constants/query-params-keys.const';
import { CollectionQueryParams } from '@osf/features/collections/models';
Expand All @@ -13,6 +14,7 @@ import { SetPageNumber } from '@shared/stores/collections/collections.actions';

@Injectable()
export class CollectionsQuerySyncService {
private readonly Sentry = inject(SENTRY_TOKEN);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);

Expand Down Expand Up @@ -119,7 +121,7 @@ export class CollectionsQuerySyncService {
const parsedFilters: CollectionsFilters = JSON.parse(activeFilters);
this.handleParsedFilters(parsedFilters);
} catch (error) {
console.error('Error parsing activeFilters from URL:', error);
this.Sentry.captureException(error);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Store } from '@ngxs/store';

import { of } from 'rxjs';
import { Observable, of, throwError } from 'rxjs';

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

import { SENTRY_TOKEN } from '@core/factory/sentry.factory';

import { GoogleFilePickerDownloadService } from './service/google-file-picker.download.service';
import { GoogleFilePickerComponent } from './google-file-picker.component';

Expand All @@ -12,11 +14,21 @@ import { OSFTestingModule, OSFTestingStoreModule } from '@testing/osf.testing.mo
describe('Component: Google File Picker', () => {
let component: GoogleFilePickerComponent;
let fixture: ComponentFixture<GoogleFilePickerComponent>;

const googlePickerServiceSpy = {
loadScript: jest.fn().mockReturnValue(of(void 0)),
loadGapiModules: jest.fn().mockReturnValue(of(void 0)),
loadScript: jest.fn((): Observable<void> => {
return throwLoadScriptError ? throwError(() => new Error('loadScript failed')) : of(void 0);
}),
loadGapiModules: jest.fn((): Observable<void> => {
return throwLoadGapiError ? throwError(() => new Error('loadGapiModules failed')) : of(void 0);
}),
};

let sentrySpy: any;

let throwLoadScriptError = false;
let throwLoadGapiError = false;

const handleFolderSelection = jest.fn();
const setDeveloperKey = jest.fn().mockReturnThis();
const setAppId = jest.fn().mockReturnThis();
Expand All @@ -40,7 +52,16 @@ describe('Component: Google File Picker', () => {
selectSnapshot: jest.fn().mockReturnValue('mock-token'),
};

beforeEach(() => {
throwLoadScriptError = false;
throwLoadGapiError = false;
jest.clearAllMocks();
});

beforeAll(() => {
throwLoadScriptError = false;
throwLoadGapiError = false;

window.google = {
picker: {
Action: null,
Expand All @@ -54,8 +75,6 @@ describe('Component: Google File Picker', () => {

describe('isFolderPicker - true', () => {
beforeEach(async () => {
jest.clearAllMocks();

(window as any).google = {
picker: {
ViewId: {
Expand Down Expand Up @@ -86,6 +105,7 @@ describe('Component: Google File Picker', () => {
await TestBed.configureTestingModule({
imports: [OSFTestingModule, GoogleFilePickerComponent],
providers: [
{ provide: SENTRY_TOKEN, useValue: { captureException: jest.fn() } },
{ provide: GoogleFilePickerDownloadService, useValue: googlePickerServiceSpy },
{
provide: Store,
Expand All @@ -94,6 +114,9 @@ describe('Component: Google File Picker', () => {
],
}).compileComponents();

sentrySpy = TestBed.inject(SENTRY_TOKEN);
jest.spyOn(sentrySpy, 'captureException');

fixture = TestBed.createComponent(GoogleFilePickerComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('isFolderPicker', true);
Expand All @@ -108,6 +131,7 @@ describe('Component: Google File Picker', () => {
it('should load script and then GAPI modules and initialize picker', () => {
expect(googlePickerServiceSpy.loadScript).toHaveBeenCalled();
expect(googlePickerServiceSpy.loadGapiModules).toHaveBeenCalled();
expect(sentrySpy.captureException).not.toHaveBeenCalled();

expect(component.visible()).toBeTruthy();
expect(component.isGFPDisabled()).toBeFalsy();
Expand Down Expand Up @@ -172,7 +196,6 @@ describe('Component: Google File Picker', () => {

describe('isFolderPicker - false', () => {
beforeEach(async () => {
jest.clearAllMocks();
(window as any).google = {
picker: {
ViewId: {
Expand Down Expand Up @@ -203,6 +226,7 @@ describe('Component: Google File Picker', () => {
await TestBed.configureTestingModule({
imports: [OSFTestingStoreModule, GoogleFilePickerComponent],
providers: [
{ provide: SENTRY_TOKEN, useValue: { captureException: jest.fn() } },
{ provide: GoogleFilePickerDownloadService, useValue: googlePickerServiceSpy },
{
provide: Store,
Expand All @@ -211,25 +235,48 @@ describe('Component: Google File Picker', () => {
],
}).compileComponents();

sentrySpy = TestBed.inject(SENTRY_TOKEN);
jest.spyOn(sentrySpy, 'captureException');

fixture = TestBed.createComponent(GoogleFilePickerComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('isFolderPicker', false);
fixture.componentRef.setInput('rootFolder', {
itemId: 'root-folder-id',
});
fixture.componentRef.setInput('handleFolderSelection', jest.fn());
});

it('should fail to load script', () => {
throwLoadScriptError = true;
fixture.detectChanges();
expect(googlePickerServiceSpy.loadScript).toHaveBeenCalled();
expect(sentrySpy.captureException).toHaveBeenCalledWith(Error('loadScript failed'), {
tags: {
feature: 'google-picker load',
},
});

expect(component.visible()).toBeFalsy();
expect(component.isGFPDisabled()).toBeTruthy();
});

it('should load script and then GAPI modules and initialize picker', () => {
it('should load script and then failr GAPI modules', () => {
throwLoadGapiError = true;
fixture.detectChanges();
expect(googlePickerServiceSpy.loadScript).toHaveBeenCalled();
expect(googlePickerServiceSpy.loadGapiModules).toHaveBeenCalled();

expect(sentrySpy.captureException).toHaveBeenCalledWith(Error('loadGapiModules failed'), {
tags: {
feature: 'google-picker auth',
},
});
expect(component.visible()).toBeFalsy();
expect(component.isGFPDisabled()).toBeTruthy();
});

it('should build the picker with correct configuration', () => {
fixture.detectChanges();
component.createPicker();

expect(window.google.picker.DocsView).toHaveBeenCalledWith('docs');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Button } from 'primeng/button';
import { ChangeDetectionStrategy, Component, inject, input, OnInit, signal } from '@angular/core';

import { ENVIRONMENT } from '@core/constants/environment.token';
import { SENTRY_TOKEN } from '@core/factory/sentry.factory';
import { StorageItemModel } from '@osf/shared/models';
import { GoogleFileDataModel } from '@osf/shared/models/files/google-file.data.model';
import { GoogleFilePickerModel } from '@osf/shared/models/files/google-file.picker.model';
Expand All @@ -24,6 +25,7 @@ import { GoogleFilePickerDownloadService } from './service/google-file-picker.do
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GoogleFilePickerComponent implements OnInit {
private readonly Sentry = inject(SENTRY_TOKEN);
readonly #translateService = inject(TranslateService);
readonly #googlePicker = inject(GoogleFilePickerDownloadService);
readonly #environment = inject(ENVIRONMENT);
Expand Down Expand Up @@ -68,12 +70,10 @@ export class GoogleFilePickerComponent implements OnInit {
this.#initializePicker();
this.#loadOauthToken();
},
// TODO add this error when the Sentry service is working
//error: (err) => console.error('GAPI modules failed:', err),
error: (err) => this.Sentry.captureException(err, { tags: { feature: 'google-picker auth' } }),
});
},
// TODO add this error when the Sentry service is working
// error: (err) => console.error('Script load failed:', err),
error: (err) => this.Sentry.captureException(err, { tags: { feature: 'google-picker load' } }),
});
}

Expand Down
4 changes: 3 additions & 1 deletion src/app/shared/helpers/state-error.handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ describe('Helper: State Error Handler', () => {
},
});

expect(Sentry.captureException).toHaveBeenCalledWith(error);
expect(Sentry.captureException).toHaveBeenCalledWith(error, {
tags: { feature: 'state error section: mySection', 'state.section': 'mySection' },
});
await expect(firstValueFrom(result$)).rejects.toThrow('Something went wrong');
});
});
Loading