From 7d123cd1c3b35abf80010c65fdbd7a551e46779d Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Thu, 18 Sep 2025 07:11:00 -0500 Subject: [PATCH 1/2] chore(merge-conflicts): fixed merge conflicts --- .../core/animations/fade.in-out.animation.ts | 39 +++++++ .../breadcrumb/breadcrumb.component.spec.ts | 2 +- src/app/core/components/index.ts | 2 +- .../maintenance-banner.component.ts | 48 --------- .../maintenance-banner.component.html | 7 +- .../maintenance-banner.component.scss | 0 .../maintenance-banner.component.spec.ts | 2 +- .../maintenance-banner.component.ts | 100 ++++++++++++++++++ .../osf-banners/models/maintenance.model.ts | 9 ++ .../osf-banners/osf-banner.component.html | 1 + .../osf-banners/osf-banner.component.spec.ts | 29 +++++ .../osf-banners/osf-banner.component.ts | 26 +++++ .../services/maintenance.service.ts | 12 +-- .../core/components/root/root.component.html | 2 +- .../components/root/root.component.spec.ts | 6 +- .../core/components/root/root.component.ts | 14 +-- src/app/core/models/maintenance.model.ts | 9 -- src/testing/osf.testing.module.ts | 9 +- 18 files changed, 229 insertions(+), 88 deletions(-) create mode 100644 src/app/core/animations/fade.in-out.animation.ts delete mode 100644 src/app/core/components/maintenance-banner/maintenance-banner.component.ts rename src/app/core/components/{ => osf-banners}/maintenance-banner/maintenance-banner.component.html (54%) rename src/app/core/components/{ => osf-banners}/maintenance-banner/maintenance-banner.component.scss (100%) rename src/app/core/components/{ => osf-banners}/maintenance-banner/maintenance-banner.component.spec.ts (99%) create mode 100644 src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts create mode 100644 src/app/core/components/osf-banners/models/maintenance.model.ts create mode 100644 src/app/core/components/osf-banners/osf-banner.component.html create mode 100644 src/app/core/components/osf-banners/osf-banner.component.spec.ts create mode 100644 src/app/core/components/osf-banners/osf-banner.component.ts rename src/app/core/{ => components/osf-banners}/services/maintenance.service.ts (68%) delete mode 100644 src/app/core/models/maintenance.model.ts diff --git a/src/app/core/animations/fade.in-out.animation.ts b/src/app/core/animations/fade.in-out.animation.ts new file mode 100644 index 000000000..7befb072b --- /dev/null +++ b/src/app/core/animations/fade.in-out.animation.ts @@ -0,0 +1,39 @@ +import { animate, style, transition, trigger } from '@angular/animations'; + +/** + * Angular animation trigger for fading elements in and out. + * + * This trigger can be used with Angular structural directives like `*ngIf` or `@if` + * to smoothly animate the appearance and disappearance of components or elements. + * + * ## Usage: + * + * In the component decorator: + * ```ts + * @Component({ + * selector: 'my-component', + * templateUrl: './my.component.html', + * animations: [fadeInOut] + * }) + * export class MyComponent {} + * ``` + * + * In the template: + * ```html + * @if (show) { + *
+ * Fades in and out! + *
+ * } + * ``` + * + * ## Transitions: + * - **:enter** — Fades in from opacity `0` to `1` over `200ms`. + * - **:leave** — Fades out from opacity `1` to `0` over `200ms`. + * + * @returns An Angular `AnimationTriggerMetadata` object used for component animations. + */ +export const fadeInOutAnimation = trigger('fadeInOut', [ + transition(':enter', [style({ opacity: 0 }), animate('200ms', style({ opacity: 1 }))]), + transition(':leave', [animate('200ms', style({ opacity: 0 }))]), +]); diff --git a/src/app/core/components/breadcrumb/breadcrumb.component.spec.ts b/src/app/core/components/breadcrumb/breadcrumb.component.spec.ts index edaaad4f9..53ea6b95a 100644 --- a/src/app/core/components/breadcrumb/breadcrumb.component.spec.ts +++ b/src/app/core/components/breadcrumb/breadcrumb.component.spec.ts @@ -7,7 +7,7 @@ import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { BreadcrumbComponent } from './breadcrumb.component'; -describe('BreadcrumbComponent', () => { +describe('Component: Breadcrumb', () => { let component: BreadcrumbComponent; let fixture: ComponentFixture; diff --git a/src/app/core/components/index.ts b/src/app/core/components/index.ts index 0beaef464..db8885c36 100644 --- a/src/app/core/components/index.ts +++ b/src/app/core/components/index.ts @@ -1,7 +1,7 @@ export { BreadcrumbComponent } from './breadcrumb/breadcrumb.component'; export { FooterComponent } from './footer/footer.component'; export { HeaderComponent } from './header/header.component'; -export { MaintenanceBannerComponent } from './maintenance-banner/maintenance-banner.component'; +export { MaintenanceBannerComponent } from './osf-banners/maintenance-banner/maintenance-banner.component'; export { PageNotFoundComponent } from './page-not-found/page-not-found.component'; export { RootComponent } from './root/root.component'; export { SidenavComponent } from './sidenav/sidenav.component'; diff --git a/src/app/core/components/maintenance-banner/maintenance-banner.component.ts b/src/app/core/components/maintenance-banner/maintenance-banner.component.ts deleted file mode 100644 index cdc78680f..000000000 --- a/src/app/core/components/maintenance-banner/maintenance-banner.component.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { CookieService } from 'ngx-cookie-service'; - -import { MessageModule } from 'primeng/message'; - -import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit } from '@angular/core'; - -import { Maintenance } from '../../models/maintenance.model'; -import { MaintenanceService } from '../../services/maintenance.service'; - -@Component({ - selector: 'osf-maintenance-banner', - imports: [CommonModule, MessageModule], - templateUrl: './maintenance-banner.component.html', - styleUrls: ['./maintenance-banner.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class MaintenanceBannerComponent implements OnInit { - private readonly maintenanceService = inject(MaintenanceService); - private readonly cookies = inject(CookieService); - private readonly cdr = inject(ChangeDetectorRef); - - dismissed = false; - readonly cookieName = 'osf-maintenance-dismissed'; - readonly cookieDurationHours = 24; - - maintenance: Maintenance | null = null; - - ngOnInit(): void { - this.dismissed = this.cookies.check(this.cookieName); - if (!this.dismissed) { - this.fetchMaintenanceStatus(); - } - } - - private fetchMaintenanceStatus(): void { - this.maintenanceService.fetchMaintenanceStatus().subscribe((maintenance) => { - this.maintenance = maintenance; - this.cdr.markForCheck(); - }); - } - - dismiss(): void { - this.cookies.set(this.cookieName, '1', this.cookieDurationHours, '/'); - this.dismissed = true; - this.maintenance = null; - } -} diff --git a/src/app/core/components/maintenance-banner/maintenance-banner.component.html b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html similarity index 54% rename from src/app/core/components/maintenance-banner/maintenance-banner.component.html rename to src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html index 0e7af0836..a936ebefc 100644 --- a/src/app/core/components/maintenance-banner/maintenance-banner.component.html +++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html @@ -1,12 +1,13 @@ -@if (maintenance && !dismissed) { +@if (maintenance() && !dismissed()) { } diff --git a/src/app/core/components/maintenance-banner/maintenance-banner.component.scss b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.scss similarity index 100% rename from src/app/core/components/maintenance-banner/maintenance-banner.component.scss rename to src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.scss diff --git a/src/app/core/components/maintenance-banner/maintenance-banner.component.spec.ts b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.spec.ts similarity index 99% rename from src/app/core/components/maintenance-banner/maintenance-banner.component.spec.ts rename to src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.spec.ts index c09e7b84f..1711da79c 100644 --- a/src/app/core/components/maintenance-banner/maintenance-banner.component.spec.ts +++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.spec.ts @@ -11,7 +11,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { MaintenanceBannerComponent } from './maintenance-banner.component'; -describe('MaintenanceBannerComponent', () => { +describe('Component: Maintenance Banner', () => { let fixture: ComponentFixture; let httpClient: { get: jest.Mock }; let cookieService: jest.Mocked; diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts new file mode 100644 index 000000000..2632ce03d --- /dev/null +++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts @@ -0,0 +1,100 @@ +import { CookieService } from 'ngx-cookie-service'; + +import { MessageModule } from 'primeng/message'; + +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core'; + +import { fadeInOutAnimation } from '@core/animations/fade.in-out.animation'; + +import { MaintenanceModel } from '../models/maintenance.model'; +import { MaintenanceService } from '../services/maintenance.service'; + +/** + * A banner component that displays a scheduled maintenance message to users. + * + * This component checks a cookie to determine whether the user has previously dismissed + * the banner. If not, it queries the maintenance status from the server and displays + * the maintenance message if one is active. + * + * The component supports animation via `fadeInOutAnimation` and is optimized with `OnPush` change detection. + * + * @example + * ```html + * + * ``` + */ +@Component({ + selector: 'osf-maintenance-banner', + imports: [CommonModule, MessageModule], + templateUrl: './maintenance-banner.component.html', + styleUrls: ['./maintenance-banner.component.scss'], + animations: [fadeInOutAnimation], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MaintenanceBannerComponent implements OnInit { + /** + * Signal to track whether the user has dismissed the banner. + */ + dismissed = signal(false); + + /** + * Signal that holds the current maintenance status fetched from the server. + */ + maintenance = signal(null); + + /** + * Service used to fetch maintenance status from the backend. + */ + private readonly maintenanceService = inject(MaintenanceService); + + /** + * Cookie service used to persist dismissal state in the browser. + */ + private readonly cookies = inject(CookieService); + + /** + * The cookie name used to store whether the user dismissed the banner. + */ + private readonly cookieName = 'osf-maintenance-dismissed'; + + /** + * Duration (in hours) to persist the dismissal cookie. + */ + private readonly cookieDurationHours = 24; + + /** + * Lifecycle hook that initializes the component: + * - Checks if dismissal cookie exists and sets `dismissed` + * - If not dismissed, triggers a fetch of current maintenance status + */ + ngOnInit(): void { + this.dismissed.set(this.cookies.check(this.cookieName)); + if (!this.dismissed()) { + this.fetchMaintenanceStatus(); + } + } + + /** + * Fetches the current maintenance status from the backend via the `MaintenanceService` + * and sets it to the `maintenance` signal. + * + * If no maintenance is active, the signal remains `null`. + */ + private fetchMaintenanceStatus(): void { + this.maintenanceService.fetchMaintenanceStatus().subscribe((maintenance: MaintenanceModel | null) => { + this.maintenance.set(maintenance); + }); + } + + /** + * Dismisses the banner: + * - Sets a cookie to remember dismissal for 24 hours + * - Updates the `dismissed` and `maintenance` signals + */ + dismiss(): void { + this.cookies.set(this.cookieName, '1', this.cookieDurationHours, '/'); + this.dismissed.set(true); + this.maintenance.set(null); + } +} diff --git a/src/app/core/components/osf-banners/models/maintenance.model.ts b/src/app/core/components/osf-banners/models/maintenance.model.ts new file mode 100644 index 000000000..ba67520ab --- /dev/null +++ b/src/app/core/components/osf-banners/models/maintenance.model.ts @@ -0,0 +1,9 @@ +export type MaintenanceSeverityType = 'info' | 'warn' | 'error'; + +export interface MaintenanceModel { + level: number; + message: string; + start: string; + end: string; + severity?: MaintenanceSeverityType; +} diff --git a/src/app/core/components/osf-banners/osf-banner.component.html b/src/app/core/components/osf-banners/osf-banner.component.html new file mode 100644 index 000000000..d75b004d5 --- /dev/null +++ b/src/app/core/components/osf-banners/osf-banner.component.html @@ -0,0 +1 @@ + diff --git a/src/app/core/components/osf-banners/osf-banner.component.spec.ts b/src/app/core/components/osf-banners/osf-banner.component.spec.ts new file mode 100644 index 000000000..d9700676a --- /dev/null +++ b/src/app/core/components/osf-banners/osf-banner.component.spec.ts @@ -0,0 +1,29 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { OSFBannerComponent } from './osf-banner.component'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { MockComponentWithSignal } from '@testing/providers/component-provider.mock'; + +describe('Component: OSF Banner', () => { + let fixture: ComponentFixture; + let component: OSFBannerComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + OSFTestingModule, + OSFBannerComponent, + NoopAnimationsModule, + MockComponentWithSignal('osf-maintenance-banner'), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(OSFBannerComponent); + component = fixture.componentInstance; + }); + it('should create the component', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/core/components/osf-banners/osf-banner.component.ts b/src/app/core/components/osf-banners/osf-banner.component.ts new file mode 100644 index 000000000..27567e6cf --- /dev/null +++ b/src/app/core/components/osf-banners/osf-banner.component.ts @@ -0,0 +1,26 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { MaintenanceBannerComponent } from './maintenance-banner/maintenance-banner.component'; + +/** + * Wrapper component responsible for rendering all global or conditional banners. + * + * Currently, it includes the `MaintenanceBannerComponent`, which displays scheduled + * maintenance notices based on server configuration and cookie state. + * + * This component is structured to allow future expansion for additional banners (e.g., announcements, alerts). + * + * Change detection is set to `OnPush` to improve performance. + * + * @example + * ```html + * + * ``` + */ +@Component({ + selector: 'osf-banner-component', + imports: [MaintenanceBannerComponent], + templateUrl: './osf-banner.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OSFBannerComponent {} diff --git a/src/app/core/services/maintenance.service.ts b/src/app/core/components/osf-banners/services/maintenance.service.ts similarity index 68% rename from src/app/core/services/maintenance.service.ts rename to src/app/core/components/osf-banners/services/maintenance.service.ts index 012f03e38..2b560d4b4 100644 --- a/src/app/core/services/maintenance.service.ts +++ b/src/app/core/components/osf-banners/services/maintenance.service.ts @@ -6,7 +6,7 @@ import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { Maintenance, MaintenanceSeverity } from '../models/maintenance.model'; +import { MaintenanceModel, MaintenanceSeverityType } from '../models/maintenance.model'; @Injectable({ providedIn: 'root' }) export class MaintenanceService { @@ -14,8 +14,8 @@ export class MaintenanceService { private readonly environment = inject(ENVIRONMENT); private readonly apiUrl = `${this.environment.apiDomainUrl}/v2`; - fetchMaintenanceStatus(): Observable { - return this.http.get<{ maintenance?: Maintenance }>(`${this.apiUrl}/status/`).pipe( + fetchMaintenanceStatus(): Observable { + return this.http.get<{ maintenance?: MaintenanceModel }>(`${this.apiUrl}/status/`).pipe( map((data) => { const maintenance = data.maintenance; if (maintenance && this.isWithinMaintenanceWindow(maintenance)) { @@ -27,12 +27,12 @@ export class MaintenanceService { ); } - getSeverity(level: number): MaintenanceSeverity { - const map: Record = { 1: 'info', 2: 'warn', 3: 'error' }; + getSeverity(level: number): MaintenanceSeverityType { + const map: Record = { 1: 'info', 2: 'warn', 3: 'error' }; return map[level] ?? 'info'; } - private isWithinMaintenanceWindow(maintenance: Maintenance): boolean { + private isWithinMaintenanceWindow(maintenance: MaintenanceModel): boolean { if (!maintenance.start || !maintenance.end) return false; const now = new Date(); return now >= new Date(maintenance.start) && now <= new Date(maintenance.end); diff --git a/src/app/core/components/root/root.component.html b/src/app/core/components/root/root.component.html index bf50403cf..d2892e0bd 100644 --- a/src/app/core/components/root/root.component.html +++ b/src/app/core/components/root/root.component.html @@ -4,7 +4,7 @@ }
- + @if (isWeb()) { diff --git a/src/app/core/components/root/root.component.spec.ts b/src/app/core/components/root/root.component.spec.ts index 3abb0c762..5af86e7e0 100644 --- a/src/app/core/components/root/root.component.spec.ts +++ b/src/app/core/components/root/root.component.spec.ts @@ -13,14 +13,14 @@ import { HeaderComponent } from '@core/components/header/header.component'; import { TopnavComponent } from '@core/components/topnav/topnav.component'; import { IS_WEB, IS_XSMALL } from '@osf/shared/helpers'; -import { MaintenanceBannerComponent } from '../maintenance-banner/maintenance-banner.component'; +import { OSFBannerComponent } from '../osf-banners/osf-banner.component'; import { SidenavComponent } from '../sidenav/sidenav.component'; import { RootComponent } from './root.component'; import { OSFTestingModule } from '@testing/osf.testing.module'; -describe('RootComponent', () => { +describe('Component: Root', () => { let component: RootComponent; let fixture: ComponentFixture; let isWebSubject: BehaviorSubject; @@ -41,7 +41,7 @@ describe('RootComponent', () => { ConfirmDialog, BreadcrumbComponent, SidenavComponent, - MaintenanceBannerComponent + OSFBannerComponent ), ], providers: [ diff --git a/src/app/core/components/root/root.component.ts b/src/app/core/components/root/root.component.ts index 7f7888e51..81b867196 100644 --- a/src/app/core/components/root/root.component.ts +++ b/src/app/core/components/root/root.component.ts @@ -13,24 +13,24 @@ import { IS_MEDIUM, IS_WEB } from '@osf/shared/helpers'; import { BreadcrumbComponent } from '../breadcrumb/breadcrumb.component'; import { FooterComponent } from '../footer/footer.component'; import { HeaderComponent } from '../header/header.component'; -import { MaintenanceBannerComponent } from '../maintenance-banner/maintenance-banner.component'; +import { OSFBannerComponent } from '../osf-banners/osf-banner.component'; import { SidenavComponent } from '../sidenav/sidenav.component'; import { TopnavComponent } from '../topnav/topnav.component'; @Component({ selector: 'osf-root', imports: [ + BreadcrumbComponent, CommonModule, - HeaderComponent, - FooterComponent, - TopnavComponent, ConfirmDialog, - BreadcrumbComponent, + FooterComponent, + HeaderComponent, + OSFBannerComponent, RouterOutlet, + ScrollTopOnRouteChangeDirective, SidenavComponent, - MaintenanceBannerComponent, + TopnavComponent, TranslatePipe, - ScrollTopOnRouteChangeDirective, ], templateUrl: './root.component.html', styleUrls: ['./root.component.scss'], diff --git a/src/app/core/models/maintenance.model.ts b/src/app/core/models/maintenance.model.ts deleted file mode 100644 index 3fc82e1f1..000000000 --- a/src/app/core/models/maintenance.model.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface Maintenance { - level: number; - message: string; - start: string; - end: string; - severity?: MaintenanceSeverity; -} - -export type MaintenanceSeverity = 'info' | 'warn' | 'error'; diff --git a/src/testing/osf.testing.module.ts b/src/testing/osf.testing.module.ts index 8687591d1..a4e376233 100644 --- a/src/testing/osf.testing.module.ts +++ b/src/testing/osf.testing.module.ts @@ -3,13 +3,11 @@ import { TranslateModule } from '@ngx-translate/core'; import { CommonModule } from '@angular/common'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { NgModule, PLATFORM_ID } from '@angular/core'; +import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { NoopAnimationsModule, provideNoopAnimations } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; -import { WINDOW, windowFactory } from '@core/provider/window.provider'; - import { DynamicDialogRefMock } from './mocks/dynamic-dialog-ref.mock'; import { EnvironmentTokenMock } from './mocks/environment.token.mock'; import { StoreMock } from './mocks/store.mock'; @@ -37,11 +35,6 @@ import { TranslationServiceMock } from './mocks/translation.service.mock'; DynamicDialogRefMock, EnvironmentTokenMock, ToastServiceMock, - { - provide: WINDOW, - useFactory: windowFactory, - deps: [PLATFORM_ID], - }, ], }) export class OSFTestingModule {} From edba5336d86ff37086feb85fc0e6c69120759dfe Mon Sep 17 00:00:00 2001 From: Brian Pilati Date: Thu, 18 Sep 2025 07:14:46 -0500 Subject: [PATCH 2/2] chore(merge-conflicts): fixed merge conflicts - 2 --- .../services/maintenance.service.spec.ts | 100 ++++++++++++++++++ .../services/maintenance.service.ts | 29 ++++- 2 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 src/app/core/components/osf-banners/services/maintenance.service.spec.ts diff --git a/src/app/core/components/osf-banners/services/maintenance.service.spec.ts b/src/app/core/components/osf-banners/services/maintenance.service.spec.ts new file mode 100644 index 000000000..6964d067d --- /dev/null +++ b/src/app/core/components/osf-banners/services/maintenance.service.spec.ts @@ -0,0 +1,100 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { MaintenanceModel } from '../models/maintenance.model'; + +import { MaintenanceService } from './maintenance.service'; + +import { environment } from 'src/environments/environment'; + +describe('MaintenanceService', () => { + let service: MaintenanceService; + let httpMock: HttpTestingController; + + const apiUrl = `${environment.apiDomainUrl}/v2/status/`; + + const futureDate = (offsetMinutes: number) => new Date(Date.now() + offsetMinutes * 60000).toISOString(); + + const validMaintenance: MaintenanceModel = { + start: futureDate(-10), + end: futureDate(10), + level: 2, + message: 'Scheduled maintenance', + }; + + const expiredMaintenance: MaintenanceModel = { + start: futureDate(-60), + end: futureDate(-30), + level: 1, + message: 'Old maintenance', + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [MaintenanceService], + }); + service = TestBed.inject(MaintenanceService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => {}); + + it('should return maintenance when within window and map severity correctly', (done) => { + service.fetchMaintenanceStatus().subscribe((result) => { + expect(result).toEqual({ + ...validMaintenance, + severity: 'warn', + }); + done(); + }); + + const req = httpMock.expectOne(apiUrl); + expect(req.request.method).toBe('GET'); + req.flush({ maintenance: validMaintenance }); + httpMock.verify(); + }); + + it('should return null when maintenance is outside window', (done) => { + service.fetchMaintenanceStatus().subscribe((result) => { + expect(result).toBeNull(); + done(); + }); + + const req = httpMock.expectOne(apiUrl); + req.flush({ maintenance: expiredMaintenance }); + httpMock.verify(); + }); + + it('should return null when maintenance is not present', (done) => { + service.fetchMaintenanceStatus().subscribe((result) => { + expect(result).toBeNull(); + done(); + }); + + const req = httpMock.expectOne(apiUrl); + req.flush({}); + httpMock.verify(); + }); + + it('should handle errors and return null', (done) => { + service.fetchMaintenanceStatus().subscribe((result) => { + expect(result).toBeNull(); + done(); + }); + + const req = httpMock.expectOne(apiUrl); + req.error(new ProgressEvent('error')); + }); + + it('should map unknown severity level to "info"', () => { + const result = (service as any).getSeverity(99); + expect(result).toBe('info'); + }); + + it('should return false if start or end is missing', () => { + const partial: Partial = { level: 1, message: 'Missing dates' }; + const result = (service as any).isWithinMaintenanceWindow(partial); + expect(result).toBe(false); + }); +}); diff --git a/src/app/core/components/osf-banners/services/maintenance.service.ts b/src/app/core/components/osf-banners/services/maintenance.service.ts index 2b560d4b4..3a9011597 100644 --- a/src/app/core/components/osf-banners/services/maintenance.service.ts +++ b/src/app/core/components/osf-banners/services/maintenance.service.ts @@ -8,12 +8,27 @@ import { ENVIRONMENT } from '@core/provider/environment.provider'; import { MaintenanceModel, MaintenanceSeverityType } from '../models/maintenance.model'; +/** + * `MaintenanceService` is responsible for retrieving the current maintenance status from the backend API. + * + * It transforms raw API responses into structured `MaintenanceModel` objects and adds computed severity levels. + * If the current time is outside the declared maintenance window, the service returns `null`. + */ @Injectable({ providedIn: 'root' }) export class MaintenanceService { + /** Injected Angular `HttpClient` for making API requests. */ private readonly http = inject(HttpClient); private readonly environment = inject(ENVIRONMENT); private readonly apiUrl = `${this.environment.apiDomainUrl}/v2`; + /** + * Fetches the maintenance status from the API and transforms the response. + * + * If a maintenance window is active and properly defined, the method adds a `severity` key to the response. + * Otherwise, it returns `null`. In case of an error (e.g., network failure), it gracefully returns `null`. + * + * @returns An observable emitting the active `MaintenanceModel` or `null` if none or on error. + */ fetchMaintenanceStatus(): Observable { return this.http.get<{ maintenance?: MaintenanceModel }>(`${this.apiUrl}/status/`).pipe( map((data) => { @@ -27,11 +42,23 @@ export class MaintenanceService { ); } - getSeverity(level: number): MaintenanceSeverityType { + /** + * Converts a numeric maintenance level (1–3) to a user-friendly severity string. + * + * @param level - The raw numeric severity level from the backend (1 = info, 2 = warn, 3 = error) + * @returns A mapped string-based severity level used in the UI + */ + private getSeverity(level: number): MaintenanceSeverityType { const map: Record = { 1: 'info', 2: 'warn', 3: 'error' }; return map[level] ?? 'info'; } + /** + * Checks whether the current time falls within the declared maintenance window. + * + * @param maintenance - The maintenance object containing `start` and `end` date strings + * @returns `true` if now is between the start and end, otherwise `false` + */ private isWithinMaintenanceWindow(maintenance: MaintenanceModel): boolean { if (!maintenance.start || !maintenance.end) return false; const now = new Date();