From 92383356f93b85992490f8d70769cf6372c10bc0 Mon Sep 17 00:00:00 2001 From: nsemets Date: Mon, 21 Sep 2026 18:03:01 +0300 Subject: [PATCH 1/2] fix(maintenance): fixed error handling --- src/app/core/enums/maintenance-status.enum.ts | 5 ++++ .../core/services/maintenance-mode.service.ts | 26 +++++++++++++------ 2 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 src/app/core/enums/maintenance-status.enum.ts diff --git a/src/app/core/enums/maintenance-status.enum.ts b/src/app/core/enums/maintenance-status.enum.ts new file mode 100644 index 000000000..775e082a4 --- /dev/null +++ b/src/app/core/enums/maintenance-status.enum.ts @@ -0,0 +1,5 @@ +export enum MaintenanceStatus { + Active = 'active', + Inactive = 'inactive', + Unknown = 'unknown', +} diff --git a/src/app/core/services/maintenance-mode.service.ts b/src/app/core/services/maintenance-mode.service.ts index 5cc350a8a..6d89c27f1 100644 --- a/src/app/core/services/maintenance-mode.service.ts +++ b/src/app/core/services/maintenance-mode.service.ts @@ -1,8 +1,9 @@ import { catchError, map, Observable, of, Subscription, switchMap, timer } from 'rxjs'; -import { HttpClient, HttpContext } from '@angular/common/http'; +import { HttpClient, HttpContext, HttpErrorResponse } from '@angular/common/http'; import { inject, Injectable, OnDestroy, signal } from '@angular/core'; +import { MaintenanceStatus } from '@core/enums/maintenance-status.enum'; import { MaintenanceResponse } from '@core/models/maintenance-response.model'; import { ENVIRONMENT } from '@core/provider/environment.provider'; @@ -28,8 +29,8 @@ export class MaintenanceModeService implements OnDestroy { * If the application is in maintenance mode, activate the service and start polling for when maintenance mode ends. */ checkOnce(): void { - this.checkMaintenanceStatus().subscribe((isMaintenance) => { - if (isMaintenance) { + this.checkMaintenanceStatus().subscribe((status) => { + if (status === MaintenanceStatus.Active) { this.activate(); } }); @@ -55,8 +56,8 @@ export class MaintenanceModeService implements OnDestroy { private startPolling(): void { this.pollingSubscription = timer(0, this.POLL_INTERVAL_MS) .pipe(switchMap(() => this.checkMaintenanceStatus())) - .subscribe((isMaintenance) => { - if (!isMaintenance) { + .subscribe((status) => { + if (status === MaintenanceStatus.Inactive) { this.deactivate(); } }); @@ -67,12 +68,21 @@ export class MaintenanceModeService implements OnDestroy { this.pollingSubscription = null; } - private checkMaintenanceStatus(): Observable { + private checkMaintenanceStatus(): Observable { return this.http .get(`${this.environment.apiDomainUrl}/v2/`, { context: this.bypassContext }) .pipe( - map((response) => response.meta?.maintenance_mode === true), - catchError(() => of(true)) + map((response) => + response.meta?.maintenance_mode === true ? MaintenanceStatus.Active : MaintenanceStatus.Inactive + ), + catchError((error: HttpErrorResponse) => of(this.statusFromError(error))) ); } + + private statusFromError(error: HttpErrorResponse): MaintenanceStatus { + const response = error.error as MaintenanceResponse | null; + return error.status === 503 && response?.meta?.maintenance_mode === true + ? MaintenanceStatus.Active + : MaintenanceStatus.Unknown; + } } From 376451dad2a915e522f0b2cd1852a6365409fa98 Mon Sep 17 00:00:00 2001 From: nsemets Date: Tue, 22 Sep 2026 15:01:02 +0300 Subject: [PATCH 2/2] test(maintenance-mode): added tests --- .../services/maintenance-mode.service.spec.ts | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/app/core/services/maintenance-mode.service.spec.ts diff --git a/src/app/core/services/maintenance-mode.service.spec.ts b/src/app/core/services/maintenance-mode.service.spec.ts new file mode 100644 index 000000000..39c988442 --- /dev/null +++ b/src/app/core/services/maintenance-mode.service.spec.ts @@ -0,0 +1,119 @@ +import { HttpTestingController, TestRequest } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { BYPASS_ERROR_INTERCEPTOR } from '@core/interceptors/error-interceptor.tokens'; +import { MaintenanceResponse } from '@core/models/maintenance-response.model'; + +import { provideOSFCore, provideOSFHttp } from '@testing/osf.testing.provider'; + +import { MaintenanceModeService } from './maintenance-mode.service'; + +describe('MaintenanceModeService', () => { + let service: MaintenanceModeService; + let httpMock: HttpTestingController; + + const apiUrl = 'http://localhost:8000/v2/'; + const pollIntervalMs = 5 * 60 * 1_000; + const maintenanceOn: MaintenanceResponse = { meta: { maintenance_mode: true } }; + const maintenanceOff: MaintenanceResponse = { meta: { maintenance_mode: false } }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideOSFCore(), provideOSFHttp()], + }); + service = TestBed.inject(MaintenanceModeService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + service.ngOnDestroy(); + httpMock.verify(); + vi.useRealTimers(); + }); + + function expectStatusRequest(): TestRequest { + const req = httpMock.expectOne(apiUrl); + expect(req.request.method).toBe('GET'); + expect(req.request.context.get(BYPASS_ERROR_INTERCEPTOR)).toBe(true); + return req; + } + + function flushPoll(response: MaintenanceResponse | null, status = 200): void { + vi.advanceTimersByTime(0); + const req = expectStatusRequest(); + if (status === 200) { + req.flush(response ?? {}); + return; + } + req.flush(response, { status, statusText: 'Error' }); + } + + it('should GET /v2/ with the error interceptor bypassed on checkOnce', () => { + service.checkOnce(); + expectStatusRequest().flush(maintenanceOff); + expect(service.isActive()).toBe(false); + }); + + it('should activate when /v2/ succeeds with maintenance_mode true', () => { + service.checkOnce(); + expectStatusRequest().flush(maintenanceOn); + expect(service.isActive()).toBe(true); + }); + + it('should not activate when /v2/ succeeds without maintenance_mode', () => { + service.checkOnce(); + expectStatusRequest().flush(maintenanceOff); + expect(service.isActive()).toBe(false); + }); + + it('should activate when /v2/ returns 503 with maintenance_mode true', () => { + service.checkOnce(); + expectStatusRequest().flush(maintenanceOn, { status: 503, statusText: 'Service Unavailable' }); + expect(service.isActive()).toBe(true); + }); + + it('should not activate when /v2/ returns 503 without maintenance_mode', () => { + service.checkOnce(); + expectStatusRequest().flush(null, { status: 503, statusText: 'Service Unavailable' }); + expect(service.isActive()).toBe(false); + }); + + it('should not activate when the /v2/ request fails', () => { + service.checkOnce(); + expectStatusRequest().error(new ProgressEvent('error')); + expect(service.isActive()).toBe(false); + }); + + it('should deactivate when a poll receives a successful non-maintenance response', () => { + vi.useFakeTimers(); + service.activate(); + expect(service.isActive()).toBe(true); + flushPoll(maintenanceOff); + expect(service.isActive()).toBe(false); + }); + + it('should stay active when a poll request fails', () => { + vi.useFakeTimers(); + service.activate(); + flushPoll(null, 502); + expect(service.isActive()).toBe(true); + }); + + it('should not start a second poll when already active', () => { + vi.useFakeTimers(); + service.activate(); + service.activate(); + flushPoll(maintenanceOn); + vi.advanceTimersByTime(pollIntervalMs); + expectStatusRequest().flush(maintenanceOn); + }); + + it('should stop polling on destroy', () => { + vi.useFakeTimers(); + service.activate(); + flushPoll(maintenanceOn); + service.ngOnDestroy(); + vi.advanceTimersByTime(pollIntervalMs); + httpMock.expectNone(apiUrl); + }); +});