Skip to content
Original file line number Diff line number Diff line change
@@ -1,27 +1,33 @@
import { Store } from '@ngxs/store';

import { TranslatePipe } from '@ngx-translate/core';
import { MockPipe, MockProvider } from 'ng-mocks';
import { MockComponents, MockPipe, MockProvider } from 'ng-mocks';

import { of } from 'rxjs';

import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { FormBuilder } from '@angular/forms';

import { UserSelectors } from '@osf/core/store/user';
import { LoaderService, ToastService } from '@osf/shared/services';
import { SubscriptionEvent, SubscriptionFrequency } from '@shared/enums';
import { InfoIconComponent } from '@osf/shared/components/info-icon/info-icon.component';
import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component';
import { SubscriptionEvent } from '@osf/shared/enums/subscriptions/subscription-event.enum';
import { SubscriptionFrequency } from '@osf/shared/enums/subscriptions/subscription-frequency.enum';
import { LoaderService } from '@osf/shared/services/loader.service';
import { ToastService } from '@osf/shared/services/toast.service';

import { AccountSettings } from '../account-settings/models';
import { AccountSettingsSelectors } from '../account-settings/store';

import { NotificationsComponent } from './notifications.component';
import { NotificationSubscriptionSelectors } from './store';

import { MOCK_STORE, MOCK_USER, TranslateServiceMock } from '@testing/mocks';
import { MOCK_USER } from '@testing/mocks/data.mock';
import { MOCK_STORE } from '@testing/mocks/mock-store.mock';
import { TranslateServiceMock } from '@testing/mocks/translate.service.mock';
import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock';

describe('NotificationsComponent', () => {
Expand All @@ -35,13 +41,19 @@ describe('NotificationsComponent', () => {
subscribeOsfHelpEmail: false,
};

// new_pending_submissions → global_reviews
// files_updated → global_file_updated
const mockNotificationSubscriptions = [
{ id: 'id1', event: SubscriptionEvent.GlobalMentions, frequency: SubscriptionFrequency.Daily },
{
id: 'id2',
event: SubscriptionEvent.GlobalMentions,
id: 'osf_new_pending_submissions',
event: 'new_pending_submissions',
frequency: SubscriptionFrequency.Instant,
},
{
id: 'cuzg4_global_file_updated',
event: 'files_updated',
frequency: SubscriptionFrequency.Daily,
},
];

beforeEach(async () => {
Expand Down Expand Up @@ -71,10 +83,14 @@ describe('NotificationsComponent', () => {
return signal(null);
});

MOCK_STORE.dispatch.mockImplementation(() => of());
MOCK_STORE.dispatch.mockReturnValue(of({}));

await TestBed.configureTestingModule({
imports: [NotificationsComponent, MockPipe(TranslatePipe), ReactiveFormsModule],
imports: [
NotificationsComponent,
...MockComponents(InfoIconComponent, SubHeaderComponent),
MockPipe(TranslatePipe),
],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
Expand Down Expand Up @@ -106,9 +122,9 @@ describe('NotificationsComponent', () => {

return signal(null);
});
component.emailPreferencesFormSubmit();

expect(loaderService.hide).not.toHaveBeenCalled();
component.emailPreferencesFormSubmit();
expect(loaderService.hide).toHaveBeenCalledTimes(1);
});

it('should handle subscription completion correctly', () => {
Expand All @@ -126,11 +142,17 @@ describe('NotificationsComponent', () => {
it('should call dispatch only once per subscription change', () => {
const mockDispatch = jest.fn().mockReturnValue(of({}));
MOCK_STORE.dispatch.mockImplementation(mockDispatch);
const event = SubscriptionEvent.GlobalMentions;
const frequency = SubscriptionFrequency.Daily;

component.onSubscriptionChange(event, frequency);
component.onSubscriptionChange(SubscriptionEvent.GlobalFileUpdated, SubscriptionFrequency.Daily);

expect(mockDispatch).toHaveBeenCalledTimes(1);
});

it('should default to API value', () => {
const subs = component.notificationSubscriptionsForm.value;

expect(subs.global_reviews).toBe(SubscriptionFrequency.Instant);

expect(subs.global_file_updated).toBe(SubscriptionFrequency.Daily);
});
});
51 changes: 47 additions & 4 deletions src/app/features/settings/notifications/notifications.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,25 @@ export class NotificationsComponent implements OnInit {
notificationSubscriptionsForm = this.fb.group(
SUBSCRIPTION_EVENTS.reduce(
(control, { event }) => {
control[event] = this.fb.control<SubscriptionFrequency>(SubscriptionFrequency.Never, { nonNullable: true });
control[event as string] = this.fb.control<SubscriptionFrequency>(SubscriptionFrequency.Never, {
nonNullable: true,
});
return control;
},
{} as Record<string, FormControl<SubscriptionFrequency>>
)
);

private readonly API_EVENT_TO_FORM_EVENT: Record<string, string> = {
new_pending_submissions: 'global_reviews',
files_updated: 'global_file_updated',
};

private readonly FORM_EVENT_TO_API_EVENT: Record<string, string> = {
global_reviews: 'new_pending_submissions',
global_file_updated: 'files_updated',
};
Comment thread
Johnetordoff marked this conversation as resolved.
Outdated

constructor() {
effect(() => {
if (this.emailPreferences()) {
Expand Down Expand Up @@ -125,7 +137,24 @@ export class NotificationsComponent implements OnInit {
onSubscriptionChange(event: SubscriptionEvent, frequency: SubscriptionFrequency) {
const user = this.currentUser();
if (!user) return;
const id = `${user.id}_${event}`;

const eventKey = event as unknown as string;
Comment thread
Johnetordoff marked this conversation as resolved.
Outdated

const apiEventName = this.FORM_EVENT_TO_API_EVENT[eventKey] ?? eventKey;

let id: string | undefined;

if (event === SubscriptionEvent.GlobalReviews) {
const subs = this.notificationSubscriptions();
const match = subs.find((s) => (s.event as string) === 'new_pending_submissions');
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

provider id should new_pending_submissions always is for provider submissions, id pattern for that is <provider_name>_new_pending_submissions so it must be the same as the sub.id

if (match) {
id = match.id;
} else {
return;
}
} else {
id = `${user.id}_${apiEventName}`;
}
Comment thread
Johnetordoff marked this conversation as resolved.

this.loaderService.show();
this.actions.updateNotificationSubscription({ id, frequency }).subscribe(() => {
Expand All @@ -142,10 +171,24 @@ export class NotificationsComponent implements OnInit {
}

private updateNotificationSubscriptionsForm() {
const subs = this.notificationSubscriptions();
if (!subs?.length) {
return;
}

const patch: Record<string, SubscriptionFrequency> = {};

for (const sub of this.notificationSubscriptions()) {
patch[sub.event] = sub.frequency;
for (const sub of subs) {
const apiEvent = sub.event as string | null;
if (!apiEvent) {
continue;
}

const formEventKey = this.API_EVENT_TO_FORM_EVENT[apiEvent];

if (formEventKey) {
patch[formEventKey] = sub.frequency;
}
}

this.notificationSubscriptionsForm.patchValue(patch);
Expand Down