diff --git a/CLAUDE.md b/CLAUDE.md index 520b1137..616ec93e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,5 +212,5 @@ make migrate-users name=YourMigrationName - **Sidebar** stays dark purple in both themes - uses sidebar-specific CSS variables - The project uses **Clean Architecture** - respect layer boundaries - **Settings dirty tracking** uses JSON snapshot comparison (`buildSnapshot()` + `hasPendingChanges()`) — keep this even with Signal Forms; Signal Forms `dirty()` means "touched", not "differs from saved" -- **Resource API** (`rxResource`): `value()` throws in the error state — always set a `defaultValue` (lists) or guard with `hasValue()` before reading +- **Resource API** (`rxResource`): `value()` throws in the error state, even with a `defaultValue`. Guard every read: `r.hasValue() ? r.value() : fallback` - **Signal Forms** (`[formField]`) owns `min`/`max`/`disabled`/`required` — set these via schema validators, not template bindings. Custom controls satisfy the contract via `model()` signals (`chip-input` exposes a `value` model; `size-input`'s numeric-min input is named `minValue` to avoid clashing with the field min) diff --git a/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/PlexPinResponse.cs b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/PlexPinStatusResponse.cs similarity index 58% rename from code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/PlexPinResponse.cs rename to code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/PlexPinStatusResponse.cs index 7d728241..0b4baef8 100644 --- a/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/PlexPinResponse.cs +++ b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/PlexPinStatusResponse.cs @@ -5,9 +5,3 @@ public sealed record PlexPinStatusResponse public required int PinId { get; init; } public required string AuthUrl { get; init; } } - -public sealed record PlexVerifyResponse -{ - public required bool Completed { get; init; } - public TokenResponse? Tokens { get; init; } -} diff --git a/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/PlexVerifyResponse.cs b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/PlexVerifyResponse.cs new file mode 100644 index 00000000..e16a417f --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/Auth/Contracts/Responses/PlexVerifyResponse.cs @@ -0,0 +1,17 @@ +namespace Cleanuparr.Api.Features.Auth.Contracts.Responses; + +/// +/// Result of polling a Plex PIN for authorization. +/// +public sealed record PlexVerifyResponse +{ + /// + /// Whether the user has authorized the PIN on Plex. + /// + public required bool Completed { get; init; } + + /// + /// Session tokens, issued only when a Plex login completes. + /// + public TokenResponse? Tokens { get; init; } +} diff --git a/code/frontend/src/app/features/auth/setup/setup.component.spec.ts b/code/frontend/src/app/features/auth/setup/setup.component.spec.ts index 083efcea..9d84db7f 100644 --- a/code/frontend/src/app/features/auth/setup/setup.component.spec.ts +++ b/code/frontend/src/app/features/auth/setup/setup.component.spec.ts @@ -3,6 +3,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { Observable, of, throwError } from 'rxjs'; import { AuthStatus, AuthService, TotpSetupResponse } from '@core/auth/auth.service'; +import { ApiError } from '@core/interceptors/error.interceptor'; +import { ToastService } from '@core/services/toast.service'; import { SetupComponent } from './setup.component'; import { ROUTES } from '@shared/routes'; @@ -21,6 +23,7 @@ interface SetupOptions { completeSetup?: Observable<{ message: string }>; connectionError?: boolean; setupComplete?: boolean; + verifyPlexPin?: Observable<{ completed: boolean }>; } interface Harness { @@ -34,6 +37,8 @@ interface Harness { describe('SetupComponent', () => { afterEach(() => { vi.useRealTimers(); + vi.unstubAllGlobals(); + delete (navigator as { clipboard?: Clipboard }).clipboard; }); function setup(options: SetupOptions = {}): Harness { @@ -61,7 +66,7 @@ describe('SetupComponent', () => { }, completeSetup: () => options.completeSetup ?? of({ message: 'ok' }), requestSetupPlexPin: () => of({ pinId: 1, authUrl: 'https://plex.tv/link' }), - verifySetupPlexPin: () => of({ completed: false }), + verifySetupPlexPin: () => options.verifyPlexPin ?? of({ completed: false }), }; TestBed.configureTestingModule({ @@ -293,4 +298,47 @@ describe('SetupComponent', () => { expect(fixture.componentInstance.retrying()).toBe(false); expect(navigations).toEqual([[ROUTES.login]]); }); + + function stubClipboard(writeText: () => Promise): void { + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + } + + it('toasts success once the recovery codes reach the clipboard', async () => { + const { fixture } = setup(); + const writeText = vi.fn(() => Promise.resolve()); + stubClipboard(writeText); + fixture.componentInstance.recoveryCodes.set(['CODE-1', 'CODE-2']); + + fixture.componentInstance.copyRecoveryCodes(); + await Promise.resolve(); + + expect(writeText).toHaveBeenCalledWith('CODE-1\nCODE-2'); + expect(TestBed.inject(ToastService).toasts().at(-1)?.message).toBe('Recovery codes copied to clipboard'); + }); + + it('toasts an error when copying the recovery codes fails', async () => { + const { fixture } = setup(); + stubClipboard(() => Promise.reject(new Error('denied'))); + fixture.componentInstance.recoveryCodes.set(['CODE-1']); + + fixture.componentInstance.copyRecoveryCodes(); + await Promise.resolve(); + await Promise.resolve(); + + expect(TestBed.inject(ToastService).toasts().at(-1)?.message).toBe('Failed to copy recovery codes'); + }); + + it('shows the backend message when polling the Plex PIN fails', () => { + vi.useFakeTimers(); + vi.stubGlobal('open', vi.fn(() => null)); + const { fixture } = setup({ verifyPlexPin: throwError(() => new ApiError('Plex is down')) }); + const component = fixture.componentInstance; + + component.startPlexLink(); + vi.advanceTimersByTime(2000); + fixture.detectChanges(); + + expect(component.plexLinking()).toBe(false); + expect(component.error()).toBe('Plex is down'); + }); }); diff --git a/code/frontend/src/app/features/auth/setup/setup.component.ts b/code/frontend/src/app/features/auth/setup/setup.component.ts index 850d52db..081a018e 100644 --- a/code/frontend/src/app/features/auth/setup/setup.component.ts +++ b/code/frontend/src/app/features/auth/setup/setup.component.ts @@ -10,6 +10,7 @@ import { NgIconComponent, provideIcons } from '@ng-icons/core'; import { tablerCheck, tablerCopy, tablerShieldLock } from '@ng-icons/tabler-icons'; import { QRCodeComponent } from 'angularx-qrcode'; import { forkJoin, timer } from 'rxjs'; +import { copyToClipboard } from '@shared/utils/clipboard.util'; @Component({ selector: 'app-setup', @@ -167,8 +168,10 @@ export class SetupComponent { copyRecoveryCodes(): void { const text = this.recoveryCodes().join('\n'); - navigator.clipboard.writeText(text); - this.toast.success('Recovery codes copied to clipboard'); + copyToClipboard(text).then( + () => this.toast.success('Recovery codes copied to clipboard'), + () => this.toast.error('Failed to copy recovery codes'), + ); } downloadRecoveryCodes(): void { @@ -217,7 +220,7 @@ export class SetupComponent { }, onError: (error) => { this.plexLinking.set(false); - this.error.set((error as { message?: string })?.message || 'Plex linking failed'); + this.error.set(error.message); }, onTimeout: () => { this.plexLinking.set(false); diff --git a/code/frontend/src/app/features/dashboard/dashboard.component.spec.ts b/code/frontend/src/app/features/dashboard/dashboard.component.spec.ts index 2e36ac9f..95fab1fd 100644 --- a/code/frontend/src/app/features/dashboard/dashboard.component.spec.ts +++ b/code/frontend/src/app/features/dashboard/dashboard.component.spec.ts @@ -3,17 +3,19 @@ vi.mock('@unovis/angular', async () => (await import('../../../testing/unovis.st import { WritableSignal, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { AppHubService } from '@core/realtime/app-hub.service'; import { EventsApi } from '@core/api/events.api'; import { JobsApi } from '@core/api/jobs.api'; import { GeneralConfigApi } from '@core/api/general-config.api'; import { CfScoreApi, CfScoreStats } from '@core/api/cf-score.api'; import { StatsApi } from '@core/api/stats.api'; +import { ToastService } from '@core/services/toast.service'; import { ConfirmService } from '@core/services/confirm.service'; import { ManualEvent, ManualEventFilter } from '@core/models/event.models'; import { StatsV2Response } from '@core/models/stats.models'; import { GeneralConfig } from '@shared/models/general-config.model'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { DashboardComponent } from './dashboard.component'; const ROW_ORDER_KEY = 'dashboard-row-order'; @@ -68,6 +70,7 @@ interface Harness { resolved: string[]; resolveAllCalls: () => number; confirmState: { answer: boolean; asked: number }; + toastError: ReturnType; } describe('DashboardComponent', () => { @@ -82,6 +85,13 @@ describe('DashboardComponent', () => { pages?: StubPage[]; storedOrder?: string; cfStats?: CfScoreStats | null; + manualEventsError?: ApiError; + manualEventsErrorFromCall?: number; + dismissManualEventError?: ApiError; + dismissAllManualEventsError?: ApiError; + triggerJobError?: ApiError; + cfScoreStatsError?: ApiError; + cfScoreUpgradesError?: ApiError; } = {}): Harness { localStorage.clear(); if (options.storedOrder !== undefined) { @@ -95,6 +105,8 @@ describe('DashboardComponent', () => { const pages = [...(options.pages ?? [{ items: [], totalCount: 0 }])]; const confirmState = { answer: true, asked: 0 }; let resolveAll = 0; + let getManualEventsCallCount = 0; + const toastErrorSpy = vi.fn(); TestBed.configureTestingModule({ providers: [ @@ -119,6 +131,11 @@ describe('DashboardComponent', () => { useValue: { getManualEvents: (filter: ManualEventFilter) => { requests.push(filter); + getManualEventsCallCount += 1; + const shouldFail = options.manualEventsError && (!options.manualEventsErrorFromCall || getManualEventsCallCount >= options.manualEventsErrorFromCall); + if (shouldFail) { + return throwError(() => options.manualEventsError); + } const page = pages.shift() ?? { items: [], totalCount: 0 }; return of({ items: page.items, @@ -130,15 +147,31 @@ describe('DashboardComponent', () => { }, resolveManualEvent: (id: string) => { resolved.push(id); + if (options.dismissManualEventError) { + return throwError(() => options.dismissManualEventError); + } return of(undefined); }, resolveAllManualEvents: () => { resolveAll += 1; + if (options.dismissAllManualEventsError) { + return throwError(() => options.dismissAllManualEventsError); + } return of({ resolvedCount: 12 }); }, }, }, - { provide: JobsApi, useValue: { trigger: () => of({ message: 'ok' }) } }, + { + provide: JobsApi, + useValue: { + trigger: () => { + if (options.triggerJobError) { + return throwError(() => options.triggerJobError); + } + return of({ message: 'ok' }); + }, + }, + }, { provide: GeneralConfigApi, useValue: { get: () => of({ displaySupportBanner: false } as GeneralConfig) }, @@ -146,14 +179,18 @@ describe('DashboardComponent', () => { { provide: CfScoreApi, useValue: { - getStats: () => of(options.cfStats ?? null), - getRecentUpgrades: () => of({ items: [], page: 1, pageSize: 5, totalCount: 0, totalPages: 0 }), + getStats: () => (options.cfScoreStatsError ? throwError(() => options.cfScoreStatsError) : of(options.cfStats ?? null)), + getRecentUpgrades: () => (options.cfScoreUpgradesError ? throwError(() => options.cfScoreUpgradesError) : of({ items: [], page: 1, pageSize: 5, totalCount: 0, totalPages: 0 })), }, }, { provide: StatsApi, useValue: { getStats: () => of(STATS), getTimeline: () => of([]) }, }, + { + provide: ToastService, + useValue: { error: toastErrorSpy, success: vi.fn() }, + }, { provide: ConfirmService, useValue: { @@ -168,7 +205,7 @@ describe('DashboardComponent', () => { const fixture = TestBed.createComponent(DashboardComponent); fixture.detectChanges(); - return { fixture, hubManualEvents, requests, resolved, resolveAllCalls: () => resolveAll, confirmState }; + return { fixture, hubManualEvents, requests, resolved, resolveAllCalls: () => resolveAll, confirmState, toastError: toastErrorSpy }; } function eventIds(fixture: ComponentFixture): string[] { @@ -371,4 +408,74 @@ describe('DashboardComponent', () => { 'cf-scores', ]); }); + + it('toasts the backend message when loading more events fails', () => { + const err = new ApiError('network timeout'); + const firstPage = manualEventPage('p1', 20); + const { fixture, requests, toastError } = setup({ + pages: [ + { items: firstPage, totalCount: 30 }, + { items: manualEventPage('p2', 5, 20), totalCount: 30 }, + ], + manualEventsError: err, + manualEventsErrorFromCall: 2, + }); + const component = fixture.componentInstance; + + expect(requests).toHaveLength(1); + + component.manualEventIndex.set(19); + fixture.detectChanges(); + component.nextManualEvent(); + fixture.detectChanges(); + + expect(requests).toHaveLength(2); + expect(toastError).toHaveBeenCalledWith('Failed to load more events: network timeout'); + }); + + it('toasts the backend message when dismissing a manual event fails', async () => { + const err = new ApiError('unauthorized'); + const { fixture, toastError } = setup({ + dismissManualEventError: err, + pages: [{ items: [manualEvent('m1', 0)], totalCount: 1 }], + }); + const component = fixture.componentInstance; + + await component.dismissManualEvent(component.currentManualEvent()!); + fixture.detectChanges(); + + expect(toastError).toHaveBeenCalledWith('unauthorized'); + }); + + it('toasts the backend message when dismissing all manual events fails', async () => { + const err = new ApiError('server error'); + const { fixture, toastError } = setup({ dismissAllManualEventsError: err }); + + await fixture.componentInstance.dismissAllManualEvents(); + fixture.detectChanges(); + + expect(toastError).toHaveBeenCalledWith('server error'); + }); + + it('toasts the backend message when triggering a job fails', () => { + const err = new ApiError('job already running'); + const { fixture, toastError } = setup({ triggerJobError: err }); + + fixture.componentInstance.triggerJob('Seeker'); + fixture.detectChanges(); + + expect(toastError).toHaveBeenCalledWith('job already running'); + }); + + it('renders without throwing when cf score stats load fails and exposes null', () => { + const { fixture } = setup({ cfScoreStatsError: new ApiError('cf score error') }); + + expect(fixture.componentInstance.cfScoreStats()).toBeNull(); + }); + + it('renders without throwing when cf score upgrades load fails and exposes empty array', () => { + const { fixture } = setup({ cfScoreUpgradesError: new ApiError('upgrades error') }); + + expect(fixture.componentInstance.cfScoreUpgrades()).toEqual([]); + }); }); diff --git a/code/frontend/src/app/features/dashboard/dashboard.component.ts b/code/frontend/src/app/features/dashboard/dashboard.component.ts index a0a54c56..5c2e61c5 100644 --- a/code/frontend/src/app/features/dashboard/dashboard.component.ts +++ b/code/frontend/src/app/features/dashboard/dashboard.component.ts @@ -30,6 +30,7 @@ import { logIcon, logLevelLabel, logSeverity } from '@shared/utils/log-display.u import { jobDisplayName, jobStatusSeverity } from '@shared/utils/job-display.util'; import { formatStrikeType, strikeTypeSeverity } from '@shared/utils/strike-display.util'; import { instanceTypeSeverity } from '@shared/utils/instance-display.util'; +import { ApiError } from '@core/interceptors/error.interceptor'; const DASHBOARD_ROW_ORDER_KEY = 'dashboard-row-order'; const DEFAULT_ROW_ORDER = ['strikes', 'logs-events', 'cf-scores', 'jobs'] as const; @@ -93,8 +94,8 @@ export class DashboardComponent { readonly showSupportSection = computed(() => this.generalConfigResource.hasValue() ? this.generalConfigResource.value().displaySupportBanner : false, ); - readonly cfScoreStats = computed(() => this.cfScoreStatsResource.value()); - readonly cfScoreUpgrades = computed(() => this.cfScoreUpgradesResource.value().items); + readonly cfScoreStats = computed(() => this.cfScoreStatsResource.hasValue() ? this.cfScoreStatsResource.value() : null); + readonly cfScoreUpgrades = computed(() => this.cfScoreUpgradesResource.hasValue() ? this.cfScoreUpgradesResource.value().items : []); readonly rowOrder = signal(this.loadOrder()); readonly visibleRowOrder = computed(() => { @@ -188,9 +189,9 @@ export class DashboardComponent { this.loadingMoreManualEvents.set(false); after?.(); }, - error: () => { + error: (err: ApiError) => { this.loadingMoreManualEvents.set(false); - this.toast.error('Failed to load more events'); + this.toast.error(`Failed to load more events: ${err.message}`); }, }); } @@ -211,7 +212,7 @@ export class DashboardComponent { } this.toast.success('Event dismissed'); }, - error: () => this.toast.error('Failed to dismiss event'), + error: (err: ApiError) => this.toast.error(err.message), }); } @@ -233,14 +234,14 @@ export class DashboardComponent { this.hasMoreBacklog.set(false); this.toast.success(`Dismissed ${res.resolvedCount} events`); }, - error: () => this.toast.error('Failed to dismiss events'), + error: (err: ApiError) => this.toast.error(err.message), }); } triggerJob(jobType: string): void { this.jobsApi.trigger(jobType as JobType).subscribe({ next: () => this.toast.success(`${this.jobDisplayName(jobType)} triggered`), - error: () => this.toast.error(`Failed to trigger ${this.jobDisplayName(jobType)}`), + error: (err: ApiError) => this.toast.error(err.message), }); } diff --git a/code/frontend/src/app/features/dashboard/stats-card/stats-card.component.spec.ts b/code/frontend/src/app/features/dashboard/stats-card/stats-card.component.spec.ts new file mode 100644 index 00000000..f19ae654 --- /dev/null +++ b/code/frontend/src/app/features/dashboard/stats-card/stats-card.component.spec.ts @@ -0,0 +1,75 @@ +vi.mock('@unovis/angular', async () => (await import('../../../../testing/unovis.stub')).createUnovisStub()); + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { StatsApi } from '@core/api/stats.api'; +import { StatsV2Response } from '@core/models/stats.models'; +import { ApiError } from '@core/interceptors/error.interceptor'; +import { StatsCardComponent } from './stats-card.component'; + +const STATS: StatsV2Response = { + events: { total: 0, byType: {}, bySeverity: {} }, + strikes: { total: 0, byType: {}, recovered: 0 }, + removals: { total: 0, byReason: {} }, + cleaned: { total: 0, byReason: {} }, + searches: { total: 0, completed: 0, failed: 0, grabbed: 0, byReason: {} }, + jobs: { total: 0, completed: 0, failed: 0, byType: {} }, + timeframeHours: 24, + generatedAt: '2026-07-30T12:00:00Z', +}; + +const TIMELINE = [ + { date: '2026-07-30T10:00:00Z', count: 5 }, + { date: '2026-07-30T11:00:00Z', count: 3 }, +]; + +describe('StatsCardComponent', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function setup(options: { statsError?: boolean; timelineError?: boolean } = {}): ComponentFixture { + vi.stubGlobal('matchMedia', () => ({ matches: false })); + + TestBed.configureTestingModule({ + providers: [ + { + provide: StatsApi, + useValue: { + getStats: () => (options.statsError ? throwError(() => new ApiError('stats error')) : of(STATS)), + getTimeline: () => (options.timelineError ? throwError(() => new ApiError('timeline error')) : of(TIMELINE)), + }, + }, + ], + }); + + const fixture = TestBed.createComponent(StatsCardComponent); + fixture.detectChanges(); + return fixture; + } + + it('renders without throwing when stats load fails and exposes empty stats', () => { + const fixture = setup({ statsError: true }); + + const tiles = fixture.componentInstance.tiles(); + expect(tiles).toHaveLength(5); + expect(tiles[0].key).toBe('removed'); + expect(tiles[0].value).toBe(0); + expect(tiles[1].key).toBe('recovered'); + expect(tiles[1].value).toBe(0); + expect(tiles[2].key).toBe('issued'); + expect(tiles[2].value).toBe(0); + expect(tiles[3].key).toBe('malware'); + expect(tiles[3].value).toBe(0); + expect(tiles[4].key).toBe('jobs'); + expect(tiles[4].value).toBe(0); + expect(fixture.nativeElement).toBeDefined(); + }); + + it('renders without throwing when timeline load fails and exposes empty array', () => { + const fixture = setup({ timelineError: true }); + + expect(fixture.componentInstance.timeline()).toEqual([]); + expect(fixture.nativeElement).toBeDefined(); + }); +}); diff --git a/code/frontend/src/app/features/dashboard/stats-card/stats-card.component.ts b/code/frontend/src/app/features/dashboard/stats-card/stats-card.component.ts index 465774ed..009deacb 100644 --- a/code/frontend/src/app/features/dashboard/stats-card/stats-card.component.ts +++ b/code/frontend/src/app/features/dashboard/stats-card/stats-card.component.ts @@ -86,7 +86,7 @@ export class StatsCardComponent { readonly hasError = computed(() => !!this.statsResource.error()); readonly tiles = computed(() => { - const s = this.statsResource.value(); + const s = this.statsResource.hasValue() ? this.statsResource.value() : EMPTY_STATS; return [ { key: 'removed', @@ -130,7 +130,7 @@ export class StatsCardComponent { ]; }); - readonly timeline = computed(() => this.timelineResource.value()); + readonly timeline = computed(() => this.timelineResource.hasValue() ? this.timelineResource.value() : []); readonly hasActivity = computed(() => this.timeline().some((b) => b.count > 0)); readonly chartColor = computed(() => METRIC_COLORS[this.selectedMetric()]); diff --git a/code/frontend/src/app/features/events/events-stats-card/events-stats-card.component.spec.ts b/code/frontend/src/app/features/events/events-stats-card/events-stats-card.component.spec.ts index 26222f27..ffdf322b 100644 --- a/code/frontend/src/app/features/events/events-stats-card/events-stats-card.component.spec.ts +++ b/code/frontend/src/app/features/events/events-stats-card/events-stats-card.component.spec.ts @@ -1,9 +1,10 @@ vi.mock('@unovis/angular', async () => (await import('../../../../testing/unovis.stub')).createUnovisStub()); import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { EventsApi } from '@core/api/events.api'; import { EventTypeTimelineResponse } from '@core/models/event.models'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { EventsStatsCardComponent } from './events-stats-card.component'; const TIMELINE: EventTypeTimelineResponse = { @@ -35,14 +36,14 @@ describe('EventsStatsCardComponent', () => { vi.unstubAllGlobals(); }); - function setup(timeline: EventTypeTimelineResponse = TIMELINE): ComponentFixture { + function setup(options: { timeline?: EventTypeTimelineResponse; timelineError?: boolean } = {}): ComponentFixture { vi.stubGlobal('matchMedia', () => ({ matches: false })); TestBed.configureTestingModule({ providers: [ { provide: EventsApi, - useValue: { getEventTypeTimeline: () => of(timeline) }, + useValue: { getEventTypeTimeline: () => (options.timelineError ? throwError(() => new ApiError('timeline error')) : of(options.timeline ?? TIMELINE)) }, }, ], }); @@ -61,7 +62,7 @@ describe('EventsStatsCardComponent', () => { }); it('resolves a tie to the first type in the declared order', () => { - const fixture = setup(TIED_TIMELINE); + const fixture = setup({ timeline: TIED_TIMELINE }); expect(fixture.componentInstance.current()).toBe('StalledStrike'); }); @@ -96,9 +97,16 @@ describe('EventsStatsCardComponent', () => { }); it('uses a unit y domain for an all-zero series instead of a degenerate one', () => { - const fixture = setup(ZEROED_TIMELINE); + const fixture = setup({ timeline: ZEROED_TIMELINE }); expect(fixture.componentInstance.yDomain()).toEqual([0, 1]); expect(fixture.componentInstance.yBaseline()).toBe(0); }); + + it('renders without throwing when timeline load fails and exposes empty arrays', () => { + const fixture = setup({ timelineError: true }); + + expect(fixture.componentInstance.data()).toEqual([]); + expect(fixture.componentInstance.allTypes()).toEqual([]); + }); }); diff --git a/code/frontend/src/app/features/events/events-stats-card/events-stats-card.component.ts b/code/frontend/src/app/features/events/events-stats-card/events-stats-card.component.ts index 62f939b7..17d1bc2a 100644 --- a/code/frontend/src/app/features/events/events-stats-card/events-stats-card.component.ts +++ b/code/frontend/src/app/features/events/events-stats-card/events-stats-card.component.ts @@ -71,8 +71,8 @@ export class EventsStatsCardComponent { readonly isLoading = computed(() => this.timelineResource.isLoading()); readonly hasError = computed(() => !!this.timelineResource.error()); - readonly data = computed(() => this.timelineResource.value().buckets); - readonly allTypes = computed(() => this.timelineResource.value().types); + readonly data = computed(() => this.timelineResource.hasValue() ? this.timelineResource.value().buckets : []); + readonly allTypes = computed(() => this.timelineResource.hasValue() ? this.timelineResource.value().types : []); readonly hasActivity = computed(() => this.allTypes().length > 0); private readonly busiestType = computed(() => { diff --git a/code/frontend/src/app/features/events/events.component.spec.ts b/code/frontend/src/app/features/events/events.component.spec.ts index 1f7cbc65..6f42bc76 100644 --- a/code/frontend/src/app/features/events/events.component.spec.ts +++ b/code/frontend/src/app/features/events/events.component.spec.ts @@ -2,11 +2,13 @@ vi.mock('@unovis/angular', async () => (await import('../../../testing/unovis.st import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { EventsApi } from '@core/api/events.api'; +import { ToastService } from '@core/services/toast.service'; import { AppEvent, EventFilter, EventTypeTimelineResponse } from '@core/models/event.models'; import { PaginatedResult } from '@core/models/pagination.model'; import { PAGE_SIZE_STORAGE_KEYS } from '@core/services/pagination.service'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { EventsComponent } from './events.component'; import { IntersectionObserverStub } from '../../../testing/intersection-observer.stub'; @@ -44,6 +46,8 @@ const EVENTS: AppEvent[] = [ interface Harness { fixture: ComponentFixture; filters: EventFilter[]; + toastError: ReturnType; + toastSuccess: ReturnType; } describe('EventsComponent', () => { @@ -51,10 +55,18 @@ describe('EventsComponent', () => { vi.unstubAllGlobals(); vi.useRealTimers(); vi.restoreAllMocks(); + delete (navigator as { clipboard?: Clipboard }).clipboard; localStorage.clear(); }); - function setup(options: { storedPageSize?: string; totalCount?: number } = {}): Harness { + function stubClipboard(writeText: () => Promise): void { + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + } + + function setup(options: { storedPageSize?: string; totalCount?: number; eventsLoadFails?: boolean; severitiesLoadFails?: boolean; typesLoadFails?: boolean } = {}): Harness { localStorage.clear(); if (options.storedPageSize !== undefined) { localStorage.setItem(PAGE_SIZE_KEY, options.storedPageSize); @@ -63,6 +75,8 @@ describe('EventsComponent', () => { vi.stubGlobal('matchMedia', () => ({ matches: false })); const filters: EventFilter[] = []; + const toastErrorSpy = vi.fn(); + const toastSuccessSpy = vi.fn(); TestBed.configureTestingModule({ providers: [ @@ -72,6 +86,9 @@ describe('EventsComponent', () => { useValue: { getEvents: (filter: EventFilter) => { filters.push(filter); + if (options.eventsLoadFails) { + return throwError(() => new ApiError('failed to fetch events')); + } return of({ items: EVENTS, page: filter.page ?? 1, @@ -80,17 +97,21 @@ describe('EventsComponent', () => { totalPages: 1, } as PaginatedResult); }, - getSeverities: () => of(['Information', 'Warning', 'Error']), - getEventTypes: () => of(['StalledStrike', 'QueueItemDeleted']), + getSeverities: () => (options.severitiesLoadFails ? throwError(() => new ApiError('severities error')) : of(['Information', 'Warning', 'Error'])), + getEventTypes: () => (options.typesLoadFails ? throwError(() => new ApiError('types error')) : of(['StalledStrike', 'QueueItemDeleted'])), getEventTypeTimeline: () => of(EMPTY_TIMELINE), }, }, + { + provide: ToastService, + useValue: { error: toastErrorSpy, success: toastSuccessSpy }, + }, ], }); const fixture = TestBed.createComponent(EventsComponent); fixture.detectChanges(); - return { fixture, filters }; + return { fixture, filters, toastError: toastErrorSpy, toastSuccess: toastSuccessSpy }; } function lastFilter(filters: EventFilter[]): EventFilter { @@ -293,4 +314,55 @@ describe('EventsComponent', () => { expect(filters.length).toBe(afterInit + 1); }); + + it('toasts the error message when loading events fails', () => { + const { fixture, toastError } = setup({ eventsLoadFails: true }); + + expect(toastError).toHaveBeenCalledWith('Failed to load events: failed to fetch events'); + expect(fixture.nativeElement).toBeDefined(); + }); + + it('renders without throwing when events load fails and exposes empty array', () => { + const { fixture } = setup({ eventsLoadFails: true }); + const component = fixture.componentInstance; + + expect(component.events()).toEqual([]); + expect(component.totalRecords()).toBe(0); + expect(fixture.nativeElement).toBeDefined(); + }); + + it('renders without throwing when severity options load fails and shows all severities entry', () => { + const { fixture } = setup({ severitiesLoadFails: true }); + + expect(fixture.componentInstance.severityOptions()).toEqual([{ label: 'All Severities', value: '' }]); + }); + + it('renders without throwing when event type options load fails and shows all types entry', () => { + const { fixture } = setup({ typesLoadFails: true }); + + expect(fixture.componentInstance.typeOptions()).toEqual([{ label: 'All Types', value: '' }]); + }); + + it('copies the event to clipboard and shows success toast', async () => { + const { fixture, toastSuccess } = setup(); + const writeTextSpy = vi.fn(() => Promise.resolve()); + stubClipboard(writeTextSpy); + + const event = EVENTS[0]; + fixture.componentInstance.copyEvent(event); + await Promise.resolve(); + + expect(writeTextSpy).toHaveBeenCalledWith(`[${event.timestamp}] [${event.severity}] ${event.eventType}: ${event.message}`); + expect(toastSuccess).toHaveBeenCalledWith('Event copied'); + }); + + it('shows error toast when copying event to clipboard fails', async () => { + const { fixture, toastError } = setup(); + stubClipboard(vi.fn(() => Promise.reject(new Error('copy failed')))); + + fixture.componentInstance.copyEvent(EVENTS[0]); + await Promise.resolve(); + + expect(toastError).toHaveBeenCalledWith('Failed to copy event'); + }); }); diff --git a/code/frontend/src/app/features/events/events.component.ts b/code/frontend/src/app/features/events/events.component.ts index 2395fe59..e155eaf2 100644 --- a/code/frontend/src/app/features/events/events.component.ts +++ b/code/frontend/src/app/features/events/events.component.ts @@ -22,6 +22,7 @@ import { formatEventType, } from '@shared/utils/event-display.util'; import { EventsStatsCardComponent } from './events-stats-card/events-stats-card.component'; +import { copyToClipboard } from '@shared/utils/clipboard.util'; const POLL_INTERVAL_MS = 10_000; @@ -120,21 +121,22 @@ export class EventsComponent implements OnInit, OnDestroy { defaultValue: [] as string[], }); - readonly events = computed(() => this.eventsResource.value().items); - readonly totalRecords = computed(() => this.eventsResource.value().totalCount); + readonly events = computed(() => this.eventsResource.hasValue() ? this.eventsResource.value().items : []); + readonly totalRecords = computed(() => this.eventsResource.hasValue() ? this.eventsResource.value().totalCount : 0); readonly severityOptions = computed(() => [ { label: 'All Severities', value: '' }, - ...this.severitiesResource.value().map((s) => ({ label: s, value: s })), + ...(this.severitiesResource.hasValue() ? this.severitiesResource.value() : []).map((s) => ({ label: s, value: s })), ]); readonly typeOptions = computed(() => [ { label: 'All Types', value: '' }, - ...this.eventTypesResource.value().map((t) => ({ label: this.formatEventType(t), value: t })), + ...(this.eventTypesResource.hasValue() ? this.eventTypesResource.value() : []).map((t) => ({ label: this.formatEventType(t), value: t })), ]); constructor() { effect(() => { - if (this.eventsResource.error()) { - this.toast.error('Failed to load events'); + const err = this.eventsResource.error(); + if (err) { + this.toast.error(`Failed to load events: ${err.message}`); } }); } @@ -174,8 +176,10 @@ export class EventsComponent implements OnInit, OnDestroy { copyEvent(event: AppEvent): void { const text = `[${event.timestamp}] [${event.severity}] ${event.eventType}: ${event.message}`; - navigator.clipboard.writeText(text); - this.toast.success('Event copied'); + copyToClipboard(text).then( + () => this.toast.success('Event copied'), + () => this.toast.error('Failed to copy event'), + ); } refresh(): void { diff --git a/code/frontend/src/app/features/logs-component/logs.component.spec.ts b/code/frontend/src/app/features/logs-component/logs.component.spec.ts index c0e38651..9992024f 100644 --- a/code/frontend/src/app/features/logs-component/logs.component.spec.ts +++ b/code/frontend/src/app/features/logs-component/logs.component.spec.ts @@ -2,6 +2,7 @@ import { WritableSignal, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { AppHubService } from '@core/realtime/app-hub.service'; +import { ToastService } from '@core/services/toast.service'; import { LogEntry } from '@core/models/signalr.models'; import { LogsComponent } from './logs.component'; import { IntersectionObserverStub } from '../../../testing/intersection-observer.stub'; @@ -45,13 +46,23 @@ const LOGS: LogEntry[] = [ describe('LogsComponent', () => { afterEach(() => { vi.unstubAllGlobals(); + delete (navigator as { clipboard?: Clipboard }).clipboard; }); + function stubClipboard(writeText: () => Promise): void { + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + } + function setup( logs: LogEntry[] = LOGS, queryParams: Record = {}, - ): { fixture: ComponentFixture; logsSignal: WritableSignal } { + ): { fixture: ComponentFixture; logsSignal: WritableSignal; toastSuccess: ReturnType; toastError: ReturnType } { const logsSignal = signal(logs); + const toastSuccessSpy = vi.fn(); + const toastErrorSpy = vi.fn(); vi.stubGlobal('IntersectionObserver', IntersectionObserverStub); TestBed.configureTestingModule({ @@ -69,12 +80,16 @@ describe('LogsComponent', () => { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap(queryParams) } }, }, + { + provide: ToastService, + useValue: { success: toastSuccessSpy, error: toastErrorSpy }, + }, ], }); const fixture = TestBed.createComponent(LogsComponent); fixture.detectChanges(); - return { fixture, logsSignal }; + return { fixture, logsSignal, toastSuccess: toastSuccessSpy, toastError: toastErrorSpy }; } function messages(fixture: ComponentFixture): string[] { @@ -189,4 +204,64 @@ describe('LogsComponent', () => { { label: 'Sonarr', value: 'Sonarr' }, ]); }); + + it('copies a single log to clipboard and shows success toast', async () => { + const { fixture, toastSuccess } = setup(); + const writeTextSpy = vi.fn(() => Promise.resolve()); + stubClipboard(writeTextSpy); + + const log = LOGS[0]; + fixture.componentInstance.copyLog(log); + await Promise.resolve(); + + expect(writeTextSpy).toHaveBeenCalledWith(`[${log.timestamp}] [${log.level}] [${log.category}] ${log.message}`); + expect(toastSuccess).toHaveBeenCalledWith('Log copied'); + }); + + it('shows error toast when copying single log fails', async () => { + const { fixture, toastError } = setup(); + stubClipboard(vi.fn(() => Promise.reject(new Error('copy failed')))); + + fixture.componentInstance.copyLog(LOGS[0]); + await Promise.resolve(); + + expect(toastError).toHaveBeenCalledWith('Failed to copy log'); + }); + + it('copies all filtered logs to clipboard and shows success toast with count', async () => { + const { fixture, toastSuccess } = setup(); + const writeTextSpy = vi.fn(() => Promise.resolve()); + stubClipboard(writeTextSpy); + + fixture.componentInstance.copyAllLogs(); + await Promise.resolve(); + + expect(writeTextSpy).toHaveBeenCalled(); + expect(toastSuccess).toHaveBeenCalledWith('3 logs copied'); + }); + + it('counts only the logs that were copied when a log arrives mid-copy', async () => { + const { fixture, logsSignal, toastSuccess } = setup(); + let finishWrite!: () => void; + stubClipboard(() => new Promise((resolve) => { + finishWrite = resolve; + })); + + fixture.componentInstance.copyAllLogs(); + logsSignal.update((logs) => [...logs, { ...logs[0], message: 'late arrival' }]); + finishWrite(); + await Promise.resolve(); + + expect(toastSuccess).toHaveBeenCalledWith('3 logs copied'); + }); + + it('shows error toast when copying all logs fails', async () => { + const { fixture, toastError } = setup(); + stubClipboard(vi.fn(() => Promise.reject(new Error('copy failed')))); + + fixture.componentInstance.copyAllLogs(); + await Promise.resolve(); + + expect(toastError).toHaveBeenCalledWith('Failed to copy logs'); + }); }); diff --git a/code/frontend/src/app/features/logs-component/logs.component.ts b/code/frontend/src/app/features/logs-component/logs.component.ts index a9d2ab74..7a7c88ee 100644 --- a/code/frontend/src/app/features/logs-component/logs.component.ts +++ b/code/frontend/src/app/features/logs-component/logs.component.ts @@ -13,6 +13,7 @@ import { LogEntry } from '@core/models/signalr.models'; import { LogEventLevel } from '@shared/models/enums'; import { logLevelLabel, logSeverity } from '@shared/utils/log-display.util'; import { jobDisplayName } from '@shared/utils/job-display.util'; +import { copyToClipboard } from '@shared/utils/clipboard.util'; const LOG_LEVELS: SelectOption[] = [ { label: 'All Levels', value: '' }, @@ -142,16 +143,21 @@ export class LogsComponent implements OnInit { copyLog(log: LogEntry): void { const text = `[${log.timestamp}] [${log.level}] ${log.category ? `[${log.category}] ` : ''}${log.message}${log.exception ? '\n' + log.exception : ''}`; - navigator.clipboard.writeText(text); - this.toast.success('Log copied'); + copyToClipboard(text).then( + () => this.toast.success('Log copied'), + () => this.toast.error('Failed to copy log'), + ); } copyAllLogs(): void { - const text = this.filteredLogs() + const logs = this.filteredLogs(); + const text = logs .map((l) => `[${l.timestamp}] [${l.level}] ${l.category ? `[${l.category}] ` : ''}${l.message}`) .join('\n'); - navigator.clipboard.writeText(text); - this.toast.success(`${this.filteredLogs().length} logs copied`); + copyToClipboard(text).then( + () => this.toast.success(`${logs.length} logs copied`), + () => this.toast.error('Failed to copy logs'), + ); } exportLogs(format: 'json' | 'csv' | 'text'): void { diff --git a/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.spec.ts b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.spec.ts index 213c586b..4e15392d 100644 --- a/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.spec.ts +++ b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.spec.ts @@ -10,6 +10,8 @@ import type { CfScoreStats, CfScoresQuery, } from '@core/api/cf-score.api'; import { AppHubService } from '@core/realtime/app-hub.service'; +import { ToastService } from '@core/services/toast.service'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { QualityTabComponent } from './quality-tab.component'; import { IntersectionObserverStub } from '../../../../testing/intersection-observer.stub'; @@ -84,6 +86,7 @@ interface Harness { queries: CfScoresQuery[]; lastQuery: () => CfScoresQuery; historyCalls: [string, number, number][]; + toastError: ReturnType; } describe('QualityTabComponent', () => { @@ -92,20 +95,34 @@ describe('QualityTabComponent', () => { localStorage.removeItem(PAGE_SIZE_KEY); }); - function setup(options: { historyFails?: boolean } = {}): Harness { + function setup(options: { historyFails?: boolean; scoresLoadFails?: boolean; statsLoadFails?: boolean; instancesLoadFails?: boolean } = {}): Harness { vi.stubGlobal('IntersectionObserver', IntersectionObserverStub); const queries: CfScoresQuery[] = []; const historyCalls: [string, number, number][] = []; + const toastErrorSpy = vi.fn(); TestBed.configureTestingModule({ providers: [ { provide: CfScoreApi, useValue: { - getStats: () => of(STATS), - getInstances: () => of({ instances: INSTANCES }), + getStats: () => { + if (options.statsLoadFails) { + return throwError(() => new ApiError('stats unavailable')); + } + return of(STATS); + }, + getInstances: () => { + if (options.instancesLoadFails) { + return throwError(() => new ApiError('instances unavailable')); + } + return of({ instances: INSTANCES }); + }, getScores: (query: CfScoresQuery) => { queries.push(query); + if (options.scoresLoadFails) { + return throwError(() => new ApiError('scores unavailable')); + } return of(SCORES); }, getItemHistory: (instanceId: string, itemId: number, episodeId: number) => { @@ -121,6 +138,10 @@ describe('QualityTabComponent', () => { provide: AppHubService, useValue: { cfScoresVersion: signal(0) }, }, + { + provide: ToastService, + useValue: { error: toastErrorSpy, success: vi.fn() }, + }, ], }); @@ -133,6 +154,7 @@ describe('QualityTabComponent', () => { queries, lastQuery: () => queries[queries.length - 1], historyCalls, + toastError: toastErrorSpy, }; } @@ -340,4 +362,34 @@ describe('QualityTabComponent', () => { expect(component.itemTypeSeverity('Radarr')).toBe('info'); expect(component.itemTypeSeverity('Readarr')).toBe('default'); }); + + it('toasts a generic message when loading score history fails', () => { + const { fixture, component, toastError } = setup({ historyFails: true }); + + component.toggleExpand(SCORES.items[0]); + fixture.detectChanges(); + + expect(toastError).toHaveBeenCalledWith('Failed to load score history'); + }); + + it('toasts the error message when loading CF scores fails', () => { + const { fixture, toastError } = setup({ scoresLoadFails: true }); + + expect(toastError).toHaveBeenCalledWith('Failed to load CF scores: scores unavailable'); + expect(fixture.nativeElement).toBeDefined(); + }); + + it('toasts the error message when loading CF score stats fails', () => { + const { fixture, toastError } = setup({ statsLoadFails: true }); + + expect(toastError).toHaveBeenCalledWith('Failed to load CF score stats: stats unavailable'); + expect(fixture.nativeElement).toBeDefined(); + }); + + it('toasts the error message when loading instances fails', () => { + const { fixture, toastError } = setup({ instancesLoadFails: true }); + + expect(toastError).toHaveBeenCalledWith('Failed to load instances: instances unavailable'); + expect(fixture.nativeElement).toBeDefined(); + }); }); diff --git a/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.ts b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.ts index a77f70df..5524b0b7 100644 --- a/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.ts +++ b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.ts @@ -129,13 +129,13 @@ export class QualityTabComponent { defaultValue: { instances: [] as CfScoreInstance[] }, }); - readonly items = computed(() => this.scoresResource.value().items); - readonly totalRecords = computed(() => this.scoresResource.value().totalCount); - readonly stats = computed(() => this.statsResource.value()); - readonly instances = computed(() => this.instancesResource.value().instances); + readonly items = computed(() => this.scoresResource.hasValue() ? this.scoresResource.value().items : []); + readonly totalRecords = computed(() => this.scoresResource.hasValue() ? this.scoresResource.value().totalCount : 0); + readonly stats = computed(() => this.statsResource.hasValue() ? this.statsResource.value() : null); + readonly instances = computed(() => this.instancesResource.hasValue() ? this.instancesResource.value().instances : []); readonly instanceOptions = computed(() => [ { label: 'All Instances', value: '' }, - ...this.instancesResource.value().instances.map((i) => ({ label: `${i.name} (${i.itemType})`, value: i.id })), + ...(this.instancesResource.hasValue() ? this.instancesResource.value().instances : []).map((i) => ({ label: `${i.name} (${i.itemType})`, value: i.id })), ]); readonly displayStats = computed(() => { @@ -193,18 +193,21 @@ export class QualityTabComponent { this.statsResource.reload(); }); effect(() => { - if (this.scoresResource.error()) { - this.toast.error('Failed to load CF scores'); + const err = this.scoresResource.error(); + if (err) { + this.toast.error(`Failed to load CF scores: ${err.message}`); } }); effect(() => { - if (this.statsResource.error()) { - this.toast.error('Failed to load CF score stats'); + const err = this.statsResource.error(); + if (err) { + this.toast.error(`Failed to load CF score stats: ${err.message}`); } }); effect(() => { - if (this.instancesResource.error()) { - this.toast.error('Failed to load instances'); + const err = this.instancesResource.error(); + if (err) { + this.toast.error(`Failed to load instances: ${err.message}`); } }); } diff --git a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.spec.ts b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.spec.ts index 89d995bf..38396c43 100644 --- a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.spec.ts +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.spec.ts @@ -1,13 +1,15 @@ import { PAGE_SIZE_STORAGE_KEYS } from '@core/services/pagination.service'; import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { SearchStatsApi, SearchEventsSortBy, SortDirection } from '@core/api/search-stats.api'; import type { SearchEventsQuery } from '@core/api/search-stats.api'; import { SeekerSearchType, SeekerSearchReason, SearchCommandStatus } from '@core/models/search-stats.models'; import type { InstanceSearchStat, SearchEvent, SearchStatsSummary } from '@core/models/search-stats.models'; import type { PaginatedResult } from '@core/models/pagination.model'; import { AppHubService } from '@core/realtime/app-hub.service'; +import { ToastService } from '@core/services/toast.service'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { SearchesTabComponent } from './searches-tab.component'; import { IntersectionObserverStub } from '../../../../testing/intersection-observer.stub'; @@ -101,6 +103,7 @@ interface Harness { component: SearchesTabComponent; queries: SearchEventsQuery[]; lastQuery: () => SearchEventsQuery; + toastError: ReturnType; } describe('SearchesTabComponent', () => { @@ -110,18 +113,27 @@ describe('SearchesTabComponent', () => { localStorage.removeItem(PAGE_SIZE_KEY); }); - function setup(summary: SearchStatsSummary = SUMMARY): Harness { + function setup(options: { summary?: SearchStatsSummary; summaryLoadFails?: boolean; eventsLoadFails?: boolean } = {}): Harness { vi.stubGlobal('IntersectionObserver', IntersectionObserverStub); const queries: SearchEventsQuery[] = []; + const toastErrorSpy = vi.fn(); TestBed.configureTestingModule({ providers: [ { provide: SearchStatsApi, useValue: { - getSummary: () => of(summary), + getSummary: () => { + if (options.summaryLoadFails) { + return throwError(() => new ApiError('summary unavailable')); + } + return of(options.summary ?? SUMMARY); + }, getEvents: (query: SearchEventsQuery) => { queries.push(query); + if (options.eventsLoadFails) { + return throwError(() => new ApiError('events unavailable')); + } return of(EVENTS); }, }, @@ -130,6 +142,10 @@ describe('SearchesTabComponent', () => { provide: AppHubService, useValue: { searchStatsVersion: signal(0) }, }, + { + provide: ToastService, + useValue: { error: toastErrorSpy, success: vi.fn() }, + }, ], }); @@ -141,6 +157,7 @@ describe('SearchesTabComponent', () => { component: fixture.componentInstance, queries, lastQuery: () => queries[queries.length - 1], + toastError: toastErrorSpy, }; } @@ -362,4 +379,57 @@ describe('SearchesTabComponent', () => { expect(component.formatCycleDuration('2026-07-31T07:00:00Z')).toBe('5h'); expect(component.formatCycleDuration('2026-07-31T11:30:00Z')).toBe('30m'); }); + + it('toasts the error message when loading search stats fails', () => { + const { fixture, toastError } = setup({ summaryLoadFails: true }); + + expect(toastError).toHaveBeenCalledWith('Failed to load search stats: summary unavailable'); + expect(fixture.nativeElement).toBeDefined(); + }); + + it('toasts the error message when loading search events fails', () => { + const { fixture, toastError } = setup({ eventsLoadFails: true }); + + expect(toastError).toHaveBeenCalledWith('Failed to load search events: events unavailable'); + expect(fixture.nativeElement).toBeDefined(); + }); + + it('passes currentCycleId to getEvents when current cycle filter is applied with an instance that has a currentCycleId', () => { + const { fixture, component, lastQuery } = setup(); + + component.openFilters(); + component.updateDraft('instanceId', 'sonarr-beta'); + component.updateDraft('cycleFilter', 'current'); + component.applyFilters(); + fixture.detectChanges(); + + expect(lastQuery().cycleId).toBe('cycle-aaaaaaaa-1111'); + expect(lastQuery().instanceId).toBe('sonarr-beta'); + }); + + it('does not request events when current cycle filter is applied with an instance lacking currentCycleId', () => { + const { fixture, component, queries } = setup(); + const initialQueryCount = queries.length; + + component.openFilters(); + component.updateDraft('instanceId', 'radarr-alpha'); + component.updateDraft('cycleFilter', 'current'); + component.applyFilters(); + fixture.detectChanges(); + + expect(queries.length).toBe(initialQueryCount); + }); + + it('does not request events when current cycle filter is applied but summary load fails', () => { + const { fixture, component, queries } = setup({ summaryLoadFails: true }); + const initialQueryCount = queries.length; + + component.openFilters(); + component.updateDraft('instanceId', 'sonarr-beta'); + component.updateDraft('cycleFilter', 'current'); + component.applyFilters(); + fixture.detectChanges(); + + expect(queries.length).toBe(initialQueryCount); + }); }); diff --git a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.ts b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.ts index d6040e0c..0ed6422d 100644 --- a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.ts +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.ts @@ -97,7 +97,7 @@ export class SearchesTabComponent { defaultValue: null, }); - readonly summary = computed(() => this.summaryResource.value()); + readonly summary = computed(() => this.summaryResource.hasValue() ? this.summaryResource.value() : null); readonly sortedInstanceStats = computed(() => [...(this.summary()?.perInstanceStats ?? [])].sort((a, b) => { @@ -110,7 +110,7 @@ export class SearchesTabComponent { readonly instanceOptions = computed(() => { return [ { label: 'All Instances', value: '' }, - ...(this.summaryResource.value()?.perInstanceStats ?? []).map((st) => ({ label: st.instanceName, value: st.instanceId })), + ...(this.summaryResource.hasValue() ? (this.summaryResource.value()?.perInstanceStats ?? []) : []).map((st) => ({ label: st.instanceName, value: st.instanceId })), ]; }); @@ -130,15 +130,21 @@ export class SearchesTabComponent { this.pagination.getPageSize(PAGE_SIZE_STORAGE_KEYS.seekerSearches, PaginationService.DEFAULT_PAGE_SIZE), ); - private readonly eventsParams = computed(() => { + private readonly eventsParams = computed(() => { const instanceId = this.selectedInstanceId() || undefined; const search = this.searchQuery() || undefined; const a = this.applied(); let cycleId: string | undefined; if (a.cycleFilter === 'current' && instanceId) { + if (!this.summaryResource.hasValue()) { + return undefined; + } const instance = this.summaryResource.value()?.perInstanceStats.find((s) => s.instanceId === instanceId); - cycleId = instance?.currentCycleId ?? undefined; + if (!instance?.currentCycleId) { + return undefined; + } + cycleId = instance.currentCycleId; } const triToBool = (v: TriState): boolean | undefined => (v === 'any' ? undefined : v === 'true'); @@ -164,8 +170,8 @@ export class SearchesTabComponent { defaultValue: { items: [], page: 1, pageSize: 50, totalCount: 0, totalPages: 0 } as PaginatedResult, }); - readonly events = computed(() => this.eventsResource.value().items); - readonly eventsTotalRecords = computed(() => this.eventsResource.value().totalCount); + readonly events = computed(() => this.eventsResource.hasValue() ? this.eventsResource.value().items : []); + readonly eventsTotalRecords = computed(() => this.eventsResource.hasValue() ? this.eventsResource.value().totalCount : 0); readonly sortOptions: SelectOption[] = [ { label: 'Timestamp', value: SearchEventsSortBy.Timestamp }, @@ -219,13 +225,15 @@ export class SearchesTabComponent { this.eventsResource.reload(); }); effect(() => { - if (this.summaryResource.error()) { - this.toast.error('Failed to load search stats'); + const err = this.summaryResource.error(); + if (err) { + this.toast.error(`Failed to load search stats: ${err.message}`); } }); effect(() => { - if (this.eventsResource.error()) { - this.toast.error('Failed to load search events'); + const err = this.eventsResource.error(); + if (err) { + this.toast.error(`Failed to load search events: ${err.message}`); } }); } diff --git a/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.spec.ts b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.spec.ts index 6370f828..af93bd35 100644 --- a/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.spec.ts +++ b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.spec.ts @@ -1,10 +1,12 @@ import { PAGE_SIZE_STORAGE_KEYS } from '@core/services/pagination.service'; import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { CfScoreApi, CfUpgradesSortBy, SortDirection } from '@core/api/cf-score.api'; import type { CfScoreInstance, CfScoreUpgradesQuery, CfScoreUpgradesResponse } from '@core/api/cf-score.api'; import { AppHubService } from '@core/realtime/app-hub.service'; +import { ToastService } from '@core/services/toast.service'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { UpgradesTabComponent } from './upgrades-tab.component'; import { IntersectionObserverStub } from '../../../../testing/intersection-observer.stub'; @@ -51,6 +53,7 @@ interface Harness { component: UpgradesTabComponent; queries: CfScoreUpgradesQuery[]; lastQuery: () => CfScoreUpgradesQuery; + toastError: ReturnType; } describe('UpgradesTabComponent', () => { @@ -59,18 +62,27 @@ describe('UpgradesTabComponent', () => { localStorage.removeItem(PAGE_SIZE_KEY); }); - function setup(): Harness { + function setup(options: { upgradesLoadFails?: boolean; instancesLoadFails?: boolean } = {}): Harness { vi.stubGlobal('IntersectionObserver', IntersectionObserverStub); const queries: CfScoreUpgradesQuery[] = []; + const toastErrorSpy = vi.fn(); TestBed.configureTestingModule({ providers: [ { provide: CfScoreApi, useValue: { - getInstances: () => of({ instances: INSTANCES }), + getInstances: () => { + if (options.instancesLoadFails) { + return throwError(() => new ApiError('instances unavailable')); + } + return of({ instances: INSTANCES }); + }, getRecentUpgrades: (query: CfScoreUpgradesQuery) => { queries.push(query); + if (options.upgradesLoadFails) { + return throwError(() => new ApiError('upgrades unavailable')); + } return of(UPGRADES); }, }, @@ -79,6 +91,10 @@ describe('UpgradesTabComponent', () => { provide: AppHubService, useValue: { cfScoresVersion: signal(0) }, }, + { + provide: ToastService, + useValue: { error: toastErrorSpy, success: vi.fn() }, + }, ], }); @@ -90,6 +106,7 @@ describe('UpgradesTabComponent', () => { component: fixture.componentInstance, queries, lastQuery: () => queries[queries.length - 1], + toastError: toastErrorSpy, }; } @@ -218,4 +235,18 @@ describe('UpgradesTabComponent', () => { expect(component.itemTypeSeverity('Radarr')).toBe('info'); expect(component.itemTypeSeverity('Whisparr')).toBe('default'); }); + + it('toasts the error message when loading upgrades fails', () => { + const { fixture, toastError } = setup({ upgradesLoadFails: true }); + + expect(toastError).toHaveBeenCalledWith('Failed to load upgrades: upgrades unavailable'); + expect(fixture.nativeElement).toBeDefined(); + }); + + it('toasts the error message when loading instances fails', () => { + const { fixture, toastError } = setup({ instancesLoadFails: true }); + + expect(toastError).toHaveBeenCalledWith('Failed to load instances: instances unavailable'); + expect(fixture.nativeElement).toBeDefined(); + }); }); diff --git a/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.ts b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.ts index 357ac615..20b210a7 100644 --- a/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.ts +++ b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.ts @@ -103,11 +103,11 @@ export class UpgradesTabComponent { defaultValue: { instances: [] as CfScoreInstance[] }, }); - readonly upgrades = computed(() => this.upgradesResource.value().items); - readonly totalRecords = computed(() => this.upgradesResource.value().totalCount); + readonly upgrades = computed(() => this.upgradesResource.hasValue() ? this.upgradesResource.value().items : []); + readonly totalRecords = computed(() => this.upgradesResource.hasValue() ? this.upgradesResource.value().totalCount : 0); readonly instanceOptions = computed(() => [ { label: 'All Instances', value: '' }, - ...this.instancesResource.value().instances.map((i) => ({ label: `${i.name} (${i.itemType})`, value: i.id })), + ...(this.instancesResource.hasValue() ? this.instancesResource.value().instances : []).map((i) => ({ label: `${i.name} (${i.itemType})`, value: i.id })), ]); readonly sortOptions: SelectOption[] = [ @@ -141,13 +141,15 @@ export class UpgradesTabComponent { this.upgradesResource.reload(); }); effect(() => { - if (this.upgradesResource.error()) { - this.toast.error('Failed to load upgrades'); + const err = this.upgradesResource.error(); + if (err) { + this.toast.error(`Failed to load upgrades: ${err.message}`); } }); effect(() => { - if (this.instancesResource.error()) { - this.toast.error('Failed to load instances'); + const err = this.instancesResource.error(); + if (err) { + this.toast.error(`Failed to load instances: ${err.message}`); } }); } diff --git a/code/frontend/src/app/features/settings/account/account-settings.component.spec.ts b/code/frontend/src/app/features/settings/account/account-settings.component.spec.ts index 8049eacf..5cdf623d 100644 --- a/code/frontend/src/app/features/settings/account/account-settings.component.spec.ts +++ b/code/frontend/src/app/features/settings/account/account-settings.component.spec.ts @@ -84,11 +84,11 @@ describe('AccountSettingsComponent', () => { getOidcConfig: () => of(options.oidc ?? OIDC), updateOidcConfig: (config: Partial) => { savedConfigs.push(config); - return options.saveFails ? throwError(() => new Error('boom')) : of(undefined); + return options.saveFails ? throwError(() => ({ message: 'Invalid issuer URL' })) : of(undefined); }, unlinkOidc: () => { unlinkCalls++; - return options.unlinkFails ? throwError(() => new Error('boom')) : of(undefined); + return options.unlinkFails ? throwError(() => ({ message: 'Unlink failed' })) : of(undefined); }, getApiKey: () => of({ apiKey: 'live-key-1234' }), regenerateApiKey: () => of({ apiKey: 'fresh-key-9999' }), @@ -106,7 +106,7 @@ describe('AccountSettingsComponent', () => { { provide: AuthService, useValue: { - startOidcLink: () => throwError(() => new Error('boom')), + startOidcLink: () => throwError(() => ({ message: 'OIDC configuration invalid' })), logout: () => undefined, }, }, @@ -199,7 +199,7 @@ describe('AccountSettingsComponent', () => { 'Could not connect to server', ); expect(cardTitles(fixture)).toEqual([]); - expect(toasts).toEqual(['error:Failed to load account information']); + expect(toasts).toEqual(['error:Failed to load account information: boom']); expect(infoCalls()).toBe(1); button(fixture, 'Retry').click(); @@ -354,7 +354,7 @@ describe('AccountSettingsComponent', () => { await fixture.componentInstance.saveOidcConfig(); fixture.detectChanges(); - expect(toasts).toEqual(['error:Failed to save OIDC settings']); + expect(toasts).toEqual(['error:Invalid issuer URL']); expect(fixture.componentInstance.oidcSaving()).toBe(false); expect(fixture.componentInstance.oidcSaved()).toBe(false); }); @@ -408,7 +408,18 @@ describe('AccountSettingsComponent', () => { button(fixture, 'Re-link').click(); fixture.detectChanges(); - expect(toasts).toEqual(['error:Failed to start OIDC account linking']); + expect(toasts).toEqual(['error:OIDC configuration invalid']); expect(fixture.componentInstance.oidcLinking()).toBe(false); }); + + it('reports a failed unlink of OIDC account', async () => { + const { fixture, toasts } = setup({ oidc: ENABLED_OIDC, unlinkFails: true }); + + await fixture.componentInstance.confirmUnlinkOidc(); + fixture.detectChanges(); + + expect(toasts).toEqual(['error:Unlink failed']); + expect(fixture.componentInstance.oidcAuthorizedSubject()).toBe('subject-123'); + expect(fixture.componentInstance.oidcUnlinking()).toBe(false); + }); }); diff --git a/code/frontend/src/app/features/settings/account/account-settings.component.ts b/code/frontend/src/app/features/settings/account/account-settings.component.ts index 7a11d1d4..1ea40600 100644 --- a/code/frontend/src/app/features/settings/account/account-settings.component.ts +++ b/code/frontend/src/app/features/settings/account/account-settings.component.ts @@ -10,6 +10,7 @@ import { } from '@ui'; import { forkJoin } from 'rxjs'; import { AccountApi } from '@core/api/account.api'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { AuthService } from '@core/auth/auth.service'; import { ToastService } from '@core/services/toast.service'; import { ConfirmService } from '@core/services/confirm.service'; @@ -118,8 +119,9 @@ export class AccountSettingsComponent implements OnInit { }); effect(() => { - if (this.accountResource.error()) { - this.toast.error('Failed to load account information'); + const err = this.accountResource.error(); + if (err) { + this.toast.error(`Failed to load account information: ${err.message}`); } }); @@ -190,8 +192,8 @@ export class AccountSettingsComponent implements OnInit { this.oidcSaved.set(true); setTimeout(() => this.oidcSaved.set(false), 1500); }, - error: () => { - this.toast.error('Failed to save OIDC settings'); + error: (err: ApiError) => { + this.toast.error(err.message); this.oidcSaving.set(false); }, }); @@ -203,8 +205,8 @@ export class AccountSettingsComponent implements OnInit { next: (result) => { window.location.href = result.authorizationUrl; }, - error: () => { - this.toast.error('Failed to start OIDC account linking'); + error: (err: ApiError) => { + this.toast.error(err.message); this.oidcLinking.set(false); }, }); @@ -226,8 +228,8 @@ export class AccountSettingsComponent implements OnInit { this.toast.success('OIDC account unlinked'); this.oidcUnlinking.set(false); }, - error: () => { - this.toast.error('Failed to unlink OIDC account'); + error: (err: ApiError) => { + this.toast.error(err.message); this.oidcUnlinking.set(false); }, }); diff --git a/code/frontend/src/app/features/settings/account/api-key-card.component.spec.ts b/code/frontend/src/app/features/settings/account/api-key-card.component.spec.ts index de28b187..85594580 100644 --- a/code/frontend/src/app/features/settings/account/api-key-card.component.spec.ts +++ b/code/frontend/src/app/features/settings/account/api-key-card.component.spec.ts @@ -30,11 +30,11 @@ describe('ApiKeyCardComponent', () => { provide: AccountApi, useValue: { getApiKey: () => - options.revealFails ? throwError(() => new Error('boom')) : of({ apiKey: 'live-key-1234' }), + options.revealFails ? throwError(() => ({ message: 'boom' })) : of({ apiKey: 'live-key-1234' }), regenerateApiKey: () => { regenerateCalls++; return options.regenerateFails - ? throwError(() => new Error('boom')) + ? throwError(() => ({ message: 'boom' })) : of({ apiKey: 'fresh-key-9999' }); }, }, @@ -113,7 +113,7 @@ describe('ApiKeyCardComponent', () => { expect(fixture.componentInstance.apiKeyRevealed()).toBe(false); expect(shownKey(fixture)).toBe('live****1234'); - expect(toasts).toEqual(['error:Failed to load API key']); + expect(toasts).toEqual(['error:Failed to load API key: boom']); }); it('copies the revealed key to the clipboard', async () => { @@ -178,6 +178,6 @@ describe('ApiKeyCardComponent', () => { expect(shownKey(fixture)).toBe('live-key-1234'); expect(fixture.componentInstance.regeneratingApiKey()).toBe(false); - expect(toasts).toEqual(['error:Failed to regenerate API key']); + expect(toasts).toEqual(['error:boom']); }); }); diff --git a/code/frontend/src/app/features/settings/account/api-key-card.component.ts b/code/frontend/src/app/features/settings/account/api-key-card.component.ts index 811edff8..2920c2b6 100644 --- a/code/frontend/src/app/features/settings/account/api-key-card.component.ts +++ b/code/frontend/src/app/features/settings/account/api-key-card.component.ts @@ -1,8 +1,10 @@ import { Component, ChangeDetectionStrategy, inject, input, signal } from '@angular/core'; import { CardComponent, ButtonComponent, SpinnerComponent } from '@ui'; import { AccountApi } from '@core/api/account.api'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { ToastService } from '@core/services/toast.service'; import { ConfirmService } from '@core/services/confirm.service'; +import { copyToClipboard } from '@shared/utils/clipboard.util'; @Component({ selector: 'app-api-key-card', @@ -35,12 +37,12 @@ export class ApiKeyCardComponent { this.apiKey.set(result.apiKey); this.apiKeyRevealed.set(true); }, - error: () => this.toast.error('Failed to load API key'), + error: (err: ApiError) => this.toast.error(`Failed to load API key: ${err.message}`), }); } copyApiKey(): void { - navigator.clipboard.writeText(this.apiKey()).then( + copyToClipboard(this.apiKey()).then( () => this.toast.success('API key copied to clipboard'), () => this.toast.error('Failed to copy API key'), ); @@ -63,8 +65,8 @@ export class ApiKeyCardComponent { this.toast.success('API key regenerated'); this.regeneratingApiKey.set(false); }, - error: () => { - this.toast.error('Failed to regenerate API key'); + error: (err: ApiError) => { + this.toast.error(err.message); this.regeneratingApiKey.set(false); }, }); diff --git a/code/frontend/src/app/features/settings/account/change-password-card.component.spec.ts b/code/frontend/src/app/features/settings/account/change-password-card.component.spec.ts index e257e4b9..29ab854f 100644 --- a/code/frontend/src/app/features/settings/account/change-password-card.component.spec.ts +++ b/code/frontend/src/app/features/settings/account/change-password-card.component.spec.ts @@ -18,7 +18,7 @@ describe('ChangePasswordCardComponent', () => { changePassword: (request: ChangePasswordRequest) => { requests.push(request); if (options.fails) { - return throwError(() => new Error('boom')); + return throwError(() => ({ message: 'Current password is incorrect' })); } return options.pending ? pending : of(undefined); }, @@ -161,7 +161,7 @@ describe('ChangePasswordCardComponent', () => { fixture.detectChanges(); expect(requests).toEqual([{ currentPassword: 'wrong-one', newPassword: 'Str0ng-passw0rd' }]); - expect(toasts).toEqual(['error:Failed to change password']); + expect(toasts).toEqual(['error:Current password is incorrect']); expect(fixture.componentInstance.passwordForm.currentPassword().value()).toBe('wrong-one'); expect(fixture.componentInstance.changingPassword()).toBe(false); }); diff --git a/code/frontend/src/app/features/settings/account/change-password-card.component.ts b/code/frontend/src/app/features/settings/account/change-password-card.component.ts index 40d82f18..3e2802bd 100644 --- a/code/frontend/src/app/features/settings/account/change-password-card.component.ts +++ b/code/frontend/src/app/features/settings/account/change-password-card.component.ts @@ -2,6 +2,7 @@ import { Component, ChangeDetectionStrategy, inject, input, signal, computed } f import { form, required, minLength, validate, FormField } from '@angular/forms/signals'; import { CardComponent, InputComponent, ButtonComponent, SpinnerComponent } from '@ui'; import { AccountApi } from '@core/api/account.api'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { ToastService } from '@core/services/toast.service'; interface ChangePasswordFormModel { @@ -72,8 +73,8 @@ export class ChangePasswordCardComponent { this.model.set({ currentPassword: '', newPassword: '', confirmPassword: '' }); this.changingPassword.set(false); }, - error: () => { - this.toast.error('Failed to change password'); + error: (err: ApiError) => { + this.toast.error(err.message); this.changingPassword.set(false); }, }); diff --git a/code/frontend/src/app/features/settings/account/change-username-card.component.spec.ts b/code/frontend/src/app/features/settings/account/change-username-card.component.spec.ts index 88b786f2..50dddc5f 100644 --- a/code/frontend/src/app/features/settings/account/change-username-card.component.spec.ts +++ b/code/frontend/src/app/features/settings/account/change-username-card.component.spec.ts @@ -38,7 +38,7 @@ describe('ChangeUsernameCardComponent', () => { changeUsername: (request: ChangeUsernameRequest) => { requests.push(request); if (options.fails) { - return throwError(() => new Error('boom')); + return throwError(() => ({ message: 'boom' })); } return options.pending ? pending : of(undefined); }, @@ -162,7 +162,7 @@ describe('ChangeUsernameCardComponent', () => { fixture.detectChanges(); expect(requests).toEqual([{ currentPassword: 'wrong-one', newUsername: 'renamed' }]); - expect(toasts).toEqual(['error:Failed to change username']); + expect(toasts).toEqual(['error:boom']); expect(fixture.componentInstance.usernameForm.newUsername().value()).toBe('renamed'); expect(fixture.componentInstance.changingUsername()).toBe(false); expect(logoutCount()).toBe(0); diff --git a/code/frontend/src/app/features/settings/account/change-username-card.component.ts b/code/frontend/src/app/features/settings/account/change-username-card.component.ts index 5a08735d..f6af400b 100644 --- a/code/frontend/src/app/features/settings/account/change-username-card.component.ts +++ b/code/frontend/src/app/features/settings/account/change-username-card.component.ts @@ -2,6 +2,7 @@ import { Component, ChangeDetectionStrategy, inject, input, signal, effect, untr import { form, required, validate, FormField } from '@angular/forms/signals'; import { CardComponent, InputComponent, ButtonComponent, SpinnerComponent } from '@ui'; import { AccountApi } from '@core/api/account.api'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { AuthService } from '@core/auth/auth.service'; import { ToastService } from '@core/services/toast.service'; @@ -69,8 +70,8 @@ export class ChangeUsernameCardComponent { this.changingUsername.set(false); this.auth.logout(); }, - error: () => { - this.toast.error('Failed to change username'); + error: (err: ApiError) => { + this.toast.error(err.message); this.changingUsername.set(false); }, }); diff --git a/code/frontend/src/app/features/settings/account/plex-integration-card.component.spec.ts b/code/frontend/src/app/features/settings/account/plex-integration-card.component.spec.ts index 8c07351e..4f19a159 100644 --- a/code/frontend/src/app/features/settings/account/plex-integration-card.component.spec.ts +++ b/code/frontend/src/app/features/settings/account/plex-integration-card.component.spec.ts @@ -58,12 +58,12 @@ describe('PlexIntegrationCardComponent', () => { { provide: AccountApi, useValue: { - linkPlex: () => (options.linkFails ? throwError(() => new Error('boom')) : of(PIN)), + linkPlex: () => (options.linkFails ? throwError(() => ({ message: 'boom' })) : of(PIN)), verifyPlexLink: (pinId: number) => { verifiedPins.push(pinId); return options.verify ? options.verify() : of({ completed: false } as PlexPinStatus); }, - unlinkPlex: () => (options.unlinkFails ? throwError(() => new Error('boom')) : of(undefined)), + unlinkPlex: () => (options.unlinkFails ? throwError(() => ({ message: 'boom' })) : of(undefined)), }, }, { @@ -131,7 +131,7 @@ describe('PlexIntegrationCardComponent', () => { vi.advanceTimersByTime(10000); expect(authWindow.close).toHaveBeenCalledTimes(1); - expect(toasts).toEqual(['error:Failed to start Plex linking']); + expect(toasts).toEqual(['error:boom']); expect(card(fixture).plexLinking()).toBe(false); expect(verifiedPins).toEqual([]); }); @@ -167,11 +167,25 @@ describe('PlexIntegrationCardComponent', () => { fixture.detectChanges(); expect(verifiedPins).toEqual([4242]); - expect(toasts).toEqual(['error:Plex linking failed']); + expect(toasts).toEqual(['error:plex down']); expect(card(fixture).plexLinking()).toBe(false); expect(fixture.componentInstance.changes).toEqual([]); }); + it('reports poll onError with the ApiError message', () => { + const { fixture, toasts, verifiedPins } = setup({ + verify: () => throwError(() => new Error('Plex service down')), + }); + + click(fixture); + vi.advanceTimersByTime(10000); + fixture.detectChanges(); + + expect(verifiedPins).toEqual([4242]); + expect(toasts).toEqual(['error:Plex service down']); + expect(card(fixture).plexLinking()).toBe(false); + }); + it('times out after the poller gives up', () => { const { fixture, toasts, verifiedPins } = setup(); diff --git a/code/frontend/src/app/features/settings/account/plex-integration-card.component.ts b/code/frontend/src/app/features/settings/account/plex-integration-card.component.ts index b0fc40cb..8994e89c 100644 --- a/code/frontend/src/app/features/settings/account/plex-integration-card.component.ts +++ b/code/frontend/src/app/features/settings/account/plex-integration-card.component.ts @@ -1,6 +1,7 @@ import { Component, ChangeDetectionStrategy, inject, input, output, signal, DestroyRef } from '@angular/core'; import { CardComponent, ButtonComponent, SpinnerComponent } from '@ui'; import { AccountApi } from '@core/api/account.api'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { ToastService } from '@core/services/toast.service'; import { ConfirmService } from '@core/services/confirm.service'; import { pollPlexPin } from '@shared/utils/plex-pin-poller'; @@ -43,9 +44,9 @@ export class PlexIntegrationCardComponent { } this.pollPlexLink(result.pinId); }, - error: () => { + error: (err: ApiError) => { authWindow?.close(); - this.toast.error('Failed to start Plex linking'); + this.toast.error(err.message); this.plexLinking.set(false); }, }); @@ -59,9 +60,9 @@ export class PlexIntegrationCardComponent { this.toast.success('Plex account linked'); this.changed.emit(); }, - onError: () => { + onError: (err) => { this.plexLinking.set(false); - this.toast.error('Plex linking failed'); + this.toast.error(err.message); }, onTimeout: () => { this.plexLinking.set(false); @@ -87,8 +88,8 @@ export class PlexIntegrationCardComponent { this.plexUnlinking.set(false); this.changed.emit(); }, - error: () => { - this.toast.error('Failed to unlink Plex account'); + error: (err: ApiError) => { + this.toast.error(err.message); this.plexUnlinking.set(false); }, }); diff --git a/code/frontend/src/app/features/settings/account/two-factor-card.component.spec.ts b/code/frontend/src/app/features/settings/account/two-factor-card.component.spec.ts index ec0747a2..d9417114 100644 --- a/code/frontend/src/app/features/settings/account/two-factor-card.component.spec.ts +++ b/code/frontend/src/app/features/settings/account/two-factor-card.component.spec.ts @@ -40,7 +40,7 @@ describe('TwoFactorCardComponent', () => { const failure = () => options.rateLimitedSeconds ? { message: 'Account is locked', retryAfterSeconds: options.rateLimitedSeconds } - : new Error('boom'); + : { message: '2FA operation failed' }; const toasts: string[] = []; const confirmations: ConfirmOptions[] = []; const enablePasswords: string[] = []; @@ -55,11 +55,11 @@ describe('TwoFactorCardComponent', () => { useValue: { enable2fa: (password: string) => { enablePasswords.push(password); - return options.enableFails ? throwError(() => new Error('boom')) : of(SETUP); + return options.enableFails ? throwError(() => ({ message: 'Wrong password' })) : of(SETUP); }, verifyEnable2fa: (code: string) => { verifiedCodes.push(code); - return options.verifyFails ? throwError(() => new Error('boom')) : of(undefined); + return options.verifyFails ? throwError(() => ({ message: 'Invalid verification code' })) : of(undefined); }, disable2fa: (password: string, totpCode: string) => { disableCalls.push([password, totpCode]); @@ -167,7 +167,7 @@ describe('TwoFactorCardComponent', () => { expect(card(fixture).enableSetup()).toBe(false); expect(secret(fixture)).toBeNull(); - expect(toasts).toEqual(['error:Failed to start 2FA setup. Check your password.']); + expect(toasts).toEqual(['error:Wrong password']); }); it('rejects an invalid verification code without enabling 2FA', () => { @@ -429,9 +429,53 @@ describe('TwoFactorCardComponent', () => { await card(fixture).confirmRegenerate2fa(); fixture.detectChanges(); - expect(toasts).toEqual(['error:Failed to regenerate 2FA. Check your password and code.']); + expect(toasts).toEqual(['error:2FA operation failed']); expect(secret(fixture)).toBeNull(); expect(recoveryCodes(fixture)).toEqual([]); expect(card(fixture).twoFaPassword()).toBe('my-password'); }); + + it('toasts the default error message when regeneration fails without retryAfterSeconds', async () => { + const { fixture, toasts } = setup({ enabled: true, regenerateFails: true }); + + type(fixture, 'Enter your password', 'my-password'); + type(fixture, 'Enter 6-digit code or recovery code', '123456'); + await card(fixture).confirmRegenerate2fa(); + fixture.detectChanges(); + + expect(toasts).toEqual(['error:2FA operation failed']); + }); + + it('reports the retry countdown when regeneration is rate limited', async () => { + const { fixture, toasts } = setup({ enabled: true, regenerateFails: true, rateLimitedSeconds: 12 }); + + type(fixture, 'Enter your password', 'my-password'); + type(fixture, 'Enter 6-digit code or recovery code', '123456'); + await card(fixture).confirmRegenerate2fa(); + fixture.detectChanges(); + + expect(toasts).toEqual(['error:Too many failed attempts. Try again in 12s.']); + }); + + it('toasts the default error message when disable fails without retryAfterSeconds', async () => { + const { fixture, toasts } = setup({ enabled: true, disableFails: true }); + + type(fixture, 'Enter your password', 'my-password'); + type(fixture, 'Enter 6-digit code or recovery code', '123456'); + await card(fixture).confirmDisable2fa(); + fixture.detectChanges(); + + expect(toasts).toEqual(['error:2FA operation failed']); + }); + + it('reports the retry countdown when disable is rate limited', async () => { + const { fixture, toasts } = setup({ enabled: true, disableFails: true, rateLimitedSeconds: 6 }); + + type(fixture, 'Enter your password', 'my-password'); + type(fixture, 'Enter 6-digit code or recovery code', '123456'); + await card(fixture).confirmDisable2fa(); + fixture.detectChanges(); + + expect(toasts).toEqual(['error:Too many failed attempts. Try again in 6s.']); + }); }); diff --git a/code/frontend/src/app/features/settings/account/two-factor-card.component.ts b/code/frontend/src/app/features/settings/account/two-factor-card.component.ts index 7089497f..5344c084 100644 --- a/code/frontend/src/app/features/settings/account/two-factor-card.component.ts +++ b/code/frontend/src/app/features/settings/account/two-factor-card.component.ts @@ -6,6 +6,7 @@ import { AccountApi } from '@core/api/account.api'; import { ApiError } from '@core/interceptors/error.interceptor'; import { ToastService } from '@core/services/toast.service'; import { ConfirmService } from '@core/services/confirm.service'; +import { copyToClipboard } from '@shared/utils/clipboard.util'; @Component({ selector: 'app-two-factor-card', @@ -69,8 +70,8 @@ export class TwoFactorCardComponent { this.twoFaCode.set(''); this.regenerating2fa.set(false); }, - error: (err) => { - this.toast.error(this.rateLimitMessage(err) ?? 'Failed to regenerate 2FA. Check your password and code.'); + error: (err: ApiError) => { + this.toast.error(this.rateLimitMessage(err) ?? err.message); this.regenerating2fa.set(false); }, }); @@ -78,7 +79,7 @@ export class TwoFactorCardComponent { copyRecoveryCodes(): void { const codes = this.newRecoveryCodes().join('\n'); - navigator.clipboard.writeText(codes).then( + copyToClipboard(codes).then( () => this.toast.success('Recovery codes copied to clipboard'), () => this.toast.error('Failed to copy recovery codes'), ); @@ -100,8 +101,8 @@ export class TwoFactorCardComponent { this.enableSetup.set(true); this.enabling2fa.set(false); }, - error: () => { - this.toast.error('Failed to start 2FA setup. Check your password.'); + error: (err: ApiError) => { + this.toast.error(err.message); this.enabling2fa.set(false); }, }); @@ -120,8 +121,8 @@ export class TwoFactorCardComponent { this.enabling2fa.set(false); this.changed.emit(); }, - error: () => { - this.toast.error('Invalid verification code'); + error: (err: ApiError) => { + this.toast.error(err.message); this.enabling2fa.set(false); }, }); @@ -164,8 +165,8 @@ export class TwoFactorCardComponent { this.disabling2fa.set(false); this.changed.emit(); }, - error: (err) => { - this.toast.error(this.rateLimitMessage(err) ?? 'Failed to disable 2FA. Check your password and code.'); + error: (err: ApiError) => { + this.toast.error(this.rateLimitMessage(err) ?? err.message); this.disabling2fa.set(false); }, }); diff --git a/code/frontend/src/app/features/settings/arr/arr-settings.component.spec.ts b/code/frontend/src/app/features/settings/arr/arr-settings.component.spec.ts index abb96b75..b60f92e9 100644 --- a/code/frontend/src/app/features/settings/arr/arr-settings.component.spec.ts +++ b/code/frontend/src/app/features/settings/arr/arr-settings.component.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { of, throwError } from 'rxjs'; import { ArrApi } from '@core/api/arr.api'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { ConfirmService } from '@core/services/confirm.service'; import { ToastService } from '@core/services/toast.service'; import { ArrConfig, ArrInstance } from '@shared/models/arr-config.model'; @@ -276,7 +277,7 @@ describe('ArrSettingsComponent', () => { it('keeps the modal open and reports the failure when saving fails', () => { const api = createApi(); - api.createInstance.mockReturnValue(throwError(() => new Error('boom'))); + api.createInstance.mockReturnValue(throwError(() => ({ message: 'Name already exists' }))); const { fixture, component, toast } = setup('sonarr', api); component.openAddModal(); @@ -288,7 +289,7 @@ describe('ArrSettingsComponent', () => { component.saveInstance(); fixture.detectChanges(); - expect(toast.error).toHaveBeenCalledWith('Failed to save instance'); + expect(toast.error).toHaveBeenCalledWith('Name already exists'); expect(component.modalVisible()).toBe(true); expect(component.saving()).toBe(false); }); @@ -314,7 +315,7 @@ describe('ArrSettingsComponent', () => { it('reports a failed connection test and stops the testing spinner', () => { const api = createApi(); - api.testInstance.mockReturnValue(throwError(() => new Error('refused'))); + api.testInstance.mockReturnValue(throwError(() => ({ message: 'Connection refused' }))); const { fixture, component, toast } = setup('sonarr', api); component.openAddModal(); @@ -329,7 +330,7 @@ describe('ArrSettingsComponent', () => { version: 4, instanceId: undefined, }); - expect(toast.error).toHaveBeenCalledWith('Connection test failed'); + expect(toast.error).toHaveBeenCalledWith('Connection refused'); expect(component.testing()).toBe(false); }); @@ -387,7 +388,7 @@ describe('ArrSettingsComponent', () => { expect(component.loadError()).toBe(true); expect(component.instances()).toEqual([]); - expect(toast.error).toHaveBeenCalledWith('Failed to load Sonarr settings'); + expect(toast.error).toHaveBeenCalledWith('Failed to load Sonarr settings: offline'); expect(fixture.nativeElement.textContent).toContain('Could not connect to server'); api.getConfig.mockReturnValue(of(CONFIG)); @@ -397,4 +398,47 @@ describe('ArrSettingsComponent', () => { expect(component.loadError()).toBe(false); expect(text(fixture, '.instance-row__name')).toEqual(['Main Sonarr', 'Backup Sonarr']); }); + + it('reports the ApiError message when testing connection fails', () => { + const api = createApi(); + api.testInstance.mockReturnValue(throwError(() => new ApiError('Network timeout'))); + const { fixture, component, toast } = setup('sonarr', api); + + component.openAddModal(); + fixture.detectChanges(); + + component.testConnection(); + fixture.detectChanges(); + + expect(toast.error).toHaveBeenCalledWith('Network timeout'); + expect(component.testing()).toBe(false); + }); + + it('reports the ApiError message when saving an instance fails', () => { + const api = createApi(); + api.updateInstance.mockReturnValue(throwError(() => new ApiError('Save error'))); + const { fixture, component, toast } = setup('sonarr', api); + + component.openEditModal(INSTANCE); + fixture.detectChanges(); + + component.saveInstance(); + fixture.detectChanges(); + + expect(toast.error).toHaveBeenCalledWith('Save error'); + expect(component.saving()).toBe(false); + }); + + it('reports the ApiError message when deleting an instance fails', async () => { + const api = createApi(); + api.deleteInstance.mockReturnValue(throwError(() => new ApiError('Delete error'))); + const { fixture, component, toast, confirm } = setup('sonarr', api); + + const accepted = component.deleteInstance(INSTANCE); + confirm.accept(); + await accepted; + fixture.detectChanges(); + + expect(toast.error).toHaveBeenCalledWith('Delete error'); + }); }); diff --git a/code/frontend/src/app/features/settings/arr/arr-settings.component.ts b/code/frontend/src/app/features/settings/arr/arr-settings.component.ts index 92d414b9..c5c3813f 100644 --- a/code/frontend/src/app/features/settings/arr/arr-settings.component.ts +++ b/code/frontend/src/app/features/settings/arr/arr-settings.component.ts @@ -7,6 +7,7 @@ import { type SelectOption, } from '@ui'; import { ArrApi } from '@core/api/arr.api'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { ToastService } from '@core/services/toast.service'; import { ConfirmService } from '@core/services/confirm.service'; import { ArrInstance, CreateArrInstanceDto, TestArrInstanceRequest } from '@shared/models/arr-config.model'; @@ -163,8 +164,8 @@ export class ArrSettingsComponent implements HasPendingChanges { this.toast.success(result.message || 'Connection successful'); this.testing.set(false); }, - error: () => { - this.toast.error('Connection test failed'); + error: (err: ApiError) => { + this.toast.error(err.message); this.testing.set(false); }, }); @@ -197,8 +198,8 @@ export class ArrSettingsComponent implements HasPendingChanges { this.saving.set(false); this.configResource.reload(); }, - error: () => { - this.toast.error('Failed to save instance'); + error: (err: ApiError) => { + this.toast.error(err.message); this.saving.set(false); }, }); @@ -219,7 +220,7 @@ export class ArrSettingsComponent implements HasPendingChanges { this.toast.success('Instance deleted'); this.configResource.reload(); }, - error: () => this.toast.error('Failed to delete instance'), + error: (err: ApiError) => this.toast.error(err.message), }); } diff --git a/code/frontend/src/app/features/settings/blacklist-sync/blacklist-sync.component.spec.ts b/code/frontend/src/app/features/settings/blacklist-sync/blacklist-sync.component.spec.ts index 8c461202..38541451 100644 --- a/code/frontend/src/app/features/settings/blacklist-sync/blacklist-sync.component.spec.ts +++ b/code/frontend/src/app/features/settings/blacklist-sync/blacklist-sync.component.spec.ts @@ -150,7 +150,7 @@ describe('BlacklistSyncComponent', () => { component.save(); fixture.detectChanges(); - expect(toast.toasts().at(-1)?.message).toBe('Failed to save blacklist sync settings'); + expect(toast.toasts().at(-1)?.message).toBe('Internal Server Error'); }); it('shows the load error and recovers on retry', () => { @@ -163,7 +163,7 @@ describe('BlacklistSyncComponent', () => { const { fixture, component, toast } = setup(CONFIG, api); expect(component.loadError()).toBe(true); - expect(toast.toasts().at(-1)?.message).toBe('Failed to load blacklist sync settings'); + expect(toast.toasts().at(-1)?.message).toBe('Failed to load blacklist sync settings: offline'); expect(fixture.nativeElement.textContent).toContain('Could not connect'); component.retry(); diff --git a/code/frontend/src/app/features/settings/blacklist-sync/blacklist-sync.component.ts b/code/frontend/src/app/features/settings/blacklist-sync/blacklist-sync.component.ts index fa837b89..fc99a547 100644 --- a/code/frontend/src/app/features/settings/blacklist-sync/blacklist-sync.component.ts +++ b/code/frontend/src/app/features/settings/blacklist-sync/blacklist-sync.component.ts @@ -3,12 +3,11 @@ import { form, required, FormField } from '@angular/forms/signals'; import { PageHeaderComponent } from '@layout/page-header/page-header.component'; import { CardComponent, ButtonComponent, InputComponent, ToggleComponent, EmptyStateComponent, LoadingStateComponent } from '@ui'; import { BlacklistSyncApi } from '@core/api/blacklist-sync.api'; -import { ApiError } from '@core/interceptors/error.interceptor'; import { ToastService } from '@core/services/toast.service'; import { BlacklistSyncConfig } from '@shared/models/blacklist-sync-config.model'; import { HasPendingChanges } from '@core/guards/pending-changes.guard'; -import { createSettingsResource } from '@shared/utils/settings-resource.util'; -import { createDirtyTracker, SAVED_FLASH_MS } from '@shared/utils/dirty-tracker.util'; +import { createSettingsResource, saveSettings } from '@shared/utils/settings-resource.util'; +import { createDirtyTracker } from '@shared/utils/dirty-tracker.util'; interface BlacklistSyncFormModel { enabled: boolean; @@ -76,19 +75,13 @@ export class BlacklistSyncComponent implements HasPendingChanges { blacklistPath: m.blacklistPath || undefined, }; - this.saving.set(true); - this.api.updateConfig(config).subscribe({ - next: () => { - this.toast.success('Blacklist sync settings saved'); - this.saving.set(false); - this.saved.set(true); - setTimeout(() => this.saved.set(false), SAVED_FLASH_MS); - this.dirtyTracker.markSaved(m); - }, - error: (err: ApiError) => { - this.toast.error(err.statusCode === 400 ? err.message : 'Failed to save blacklist sync settings'); - this.saving.set(false); - }, + saveSettings({ + request: this.api.updateConfig(config), + toast: this.toast, + saving: this.saving, + saved: this.saved, + successMessage: 'Blacklist sync settings saved', + onSaved: () => this.dirtyTracker.markSaved(m), }); } diff --git a/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.spec.ts b/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.spec.ts index 87dcbb9a..0d915ed8 100644 --- a/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.spec.ts +++ b/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.spec.ts @@ -3,7 +3,9 @@ import { provideHttpClient } from '@angular/common/http'; import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { of, Subject, throwError } from 'rxjs'; import { DownloadCleanerApi } from '@core/api/download-cleaner.api'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { ConfirmService } from '@core/services/confirm.service'; +import { ToastService } from '@core/services/toast.service'; import { DownloadCleanerConfig, OrphanedFilesConfig, @@ -105,6 +107,7 @@ interface Setup { fixture: ComponentFixture; component: DownloadCleanerComponent; api: ReturnType; + toast: { success: ReturnType; error: ReturnType }; confirm: ConfirmService; } @@ -112,6 +115,10 @@ function dropEvent(previousIndex: number, currentIndex: number): CdkDragDrop; } +function createToast() { + return { success: vi.fn(), error: vi.fn() }; +} + describe('DownloadCleanerComponent', () => { afterEach(() => { vi.restoreAllMocks(); @@ -122,9 +129,14 @@ describe('DownloadCleanerComponent', () => { reloadedRules: SeedingRule[] = [RULE_B], ): Promise { const api = createApi(config, reloadedRules); + const toast = createToast(); TestBed.configureTestingModule({ - providers: [provideHttpClient(), { provide: DownloadCleanerApi, useValue: api }], + providers: [ + provideHttpClient(), + { provide: DownloadCleanerApi, useValue: api }, + { provide: ToastService, useValue: toast }, + ], }); const fixture = TestBed.createComponent(DownloadCleanerComponent); @@ -136,6 +148,7 @@ describe('DownloadCleanerComponent', () => { fixture, component: fixture.componentInstance, api, + toast, confirm: TestBed.inject(ConfirmService), }; } @@ -631,4 +644,44 @@ describe('DownloadCleanerComponent', () => { expect(component.clientOptions()).toEqual([]); expect(component.hasPendingChanges()).toBe(false); }); + + it('toasts the backend message when deleting a seeding rule fails', async () => { + const { fixture, component, api, toast, confirm } = await setup(); + + api.deleteSeedingRule.mockReturnValue(throwError(() => new ApiError('Network error'))); + + const accepted = component.deleteRule(RULE_A); + confirm.accept(); + await accepted; + fixture.detectChanges(); + + expect(toast.error).toHaveBeenCalledWith('Network error'); + }); + + it('toasts the backend message when reordering seeding rules fails', async () => { + const { fixture, component, api, toast } = await setup(); + + api.reorderSeedingRules.mockReturnValue(throwError(() => new ApiError('Save failed'))); + + component.onRulesReorder(dropEvent(0, 1)); + fixture.detectChanges(); + await Promise.resolve(); + fixture.detectChanges(); + + expect(toast.error).toHaveBeenCalledWith('Save failed'); + }); + + it('toasts the backend message when reloading seeding rules fails', async () => { + const { fixture, component, api, toast } = await setup(); + + api.getSeedingRules.mockReturnValue(throwError(() => new ApiError('reload failed'))); + + component.onSeedingRuleSaved(); + fixture.detectChanges(); + await Promise.resolve(); + fixture.detectChanges(); + + expect(toast.error).toHaveBeenCalledWith('Failed to reload seeding rules: reload failed'); + expect(component.rulesReloading()).toBe(false); + }); }); diff --git a/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.ts b/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.ts index 13b13229..c4537535 100644 --- a/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.ts +++ b/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.ts @@ -22,8 +22,8 @@ import { import { ScheduleOptions } from '@shared/models/queue-cleaner-config.model'; import { ScheduleUnit, DownloadClientTypeName, SeedingRuleAction } from '@shared/models/enums'; import { HasPendingChanges } from '@core/guards/pending-changes.guard'; -import { createSettingsResource } from '@shared/utils/settings-resource.util'; -import { createDirtyTracker, DirtyTracker, SAVED_FLASH_MS } from '@shared/utils/dirty-tracker.util'; +import { createSettingsResource, saveSettings } from '@shared/utils/settings-resource.util'; +import { createDirtyTracker, DirtyTracker } from '@shared/utils/dirty-tracker.util'; import { generateCronExpression, parseCronToJobSchedule } from '@shared/utils/schedule.util'; import { SeedingRuleModalComponent } from './seeding-rule-modal.component'; @@ -445,7 +445,7 @@ export class DownloadCleanerComponent implements HasPendingChanges { this.toast.success('Seeding rule deleted'); this.reloadSeedingRules(clientId); }, - error: () => this.toast.error('Failed to delete seeding rule'), + error: (err: ApiError) => this.toast.error(err.message), }); } @@ -464,8 +464,8 @@ export class DownloadCleanerComponent implements HasPendingChanges { const orderedIds = rules.map(r => r.id!).filter(Boolean); this.api.reorderSeedingRules(clientId, orderedIds).subscribe({ - error: () => { - this.toast.error('Failed to reorder seeding rules'); + error: (err: ApiError) => { + this.toast.error(err.message); this.reloadSeedingRules(clientId); }, }); @@ -480,8 +480,8 @@ export class DownloadCleanerComponent implements HasPendingChanges { ); this.rulesReloading.set(false); }, - error: () => { - this.toast.error('Failed to reload seeding rules'); + error: (err: ApiError) => { + this.toast.error(`Failed to reload seeding rules: ${err.message}`); this.rulesReloading.set(false); }, }); @@ -521,22 +521,18 @@ export class DownloadCleanerComponent implements HasPendingChanges { }; const switchCountAtSave = this.clientSwitchCount; - this.unlinkedSaving.set(true); - this.api.updateUnlinkedConfig(clientId, dto).subscribe({ - next: () => { - this.toast.success('Unlinked config saved'); - this.unlinkedSaving.set(false); - this.unlinkedSaved.set(true); - setTimeout(() => this.unlinkedSaved.set(false), SAVED_FLASH_MS); + saveSettings({ + request: this.api.updateUnlinkedConfig(clientId, dto), + toast: this.toast, + saving: this.unlinkedSaving, + saved: this.unlinkedSaved, + successMessage: 'Unlinked config saved', + onSaved: () => { this.unlinkedSnapshots.update(s => ({ ...s, [clientId]: JSON.stringify(m) })); if (this.selectedClientId() === clientId) { this.applySavedClientModel(this.unlinkedModel, this.unlinkedDirtyTracker, switchCountAtSave, m); } }, - error: (err: ApiError) => { - this.toast.error(err.statusCode === 400 ? err.message : 'Failed to save unlinked config'); - this.unlinkedSaving.set(false); - }, }); } @@ -557,22 +553,18 @@ export class DownloadCleanerComponent implements HasPendingChanges { }; const switchCountAtSave = this.clientSwitchCount; - this.deadTorrentSaving.set(true); - this.api.updateDeadTorrentConfig(clientId, dto).subscribe({ - next: () => { - this.toast.success('Dead torrent config saved'); - this.deadTorrentSaving.set(false); - this.deadTorrentSaved.set(true); - setTimeout(() => this.deadTorrentSaved.set(false), SAVED_FLASH_MS); + saveSettings({ + request: this.api.updateDeadTorrentConfig(clientId, dto), + toast: this.toast, + saving: this.deadTorrentSaving, + saved: this.deadTorrentSaved, + successMessage: 'Dead torrent config saved', + onSaved: () => { this.deadTorrentSnapshots.update(s => ({ ...s, [clientId]: JSON.stringify(m) })); if (this.selectedClientId() === clientId) { this.applySavedClientModel(this.deadTorrentModel, this.deadTorrentDirtyTracker, switchCountAtSave, m); } }, - error: (err: ApiError) => { - this.toast.error(err.statusCode === 400 ? err.message : 'Failed to save dead torrent config'); - this.deadTorrentSaving.set(false); - }, }); } @@ -594,22 +586,18 @@ export class DownloadCleanerComponent implements HasPendingChanges { }; const switchCountAtSave = this.clientSwitchCount; - this.orphanedFilesSaving.set(true); - this.api.updateOrphanedFilesConfig(clientId, dto).subscribe({ - next: () => { - this.toast.success('Orphaned files settings saved'); - this.orphanedFilesSaving.set(false); - this.orphanedFilesSaved.set(true); - setTimeout(() => this.orphanedFilesSaved.set(false), SAVED_FLASH_MS); + saveSettings({ + request: this.api.updateOrphanedFilesConfig(clientId, dto), + toast: this.toast, + saving: this.orphanedFilesSaving, + saved: this.orphanedFilesSaved, + successMessage: 'Orphaned files settings saved', + onSaved: () => { this.orphanedFilesSnapshots.update(s => ({ ...s, [clientId]: JSON.stringify(m) })); if (this.selectedClientId() === clientId) { this.applySavedClientModel(this.orphanedFilesModel, this.orphanedFilesDirtyTracker, switchCountAtSave, m); } }, - error: (err: ApiError) => { - this.toast.error(err.statusCode === 400 ? err.message : 'Failed to save orphaned files settings'); - this.orphanedFilesSaving.set(false); - }, }); } @@ -633,21 +621,13 @@ export class DownloadCleanerComponent implements HasPendingChanges { ignoredDownloads: m.ignoredDownloads, }; - this.saving.set(true); - this.api.updateConfig(config).subscribe({ - next: () => { - this.toast.success('Download cleaner settings saved'); - this.saving.set(false); - this.saved.set(true); - setTimeout(() => this.saved.set(false), SAVED_FLASH_MS); - this.dirtyTracker.markSaved(m); - }, - error: (err: ApiError) => { - this.toast.error(err.statusCode === 400 - ? err.message - : 'Failed to save download cleaner settings'); - this.saving.set(false); - }, + saveSettings({ + request: this.api.updateConfig(config), + toast: this.toast, + saving: this.saving, + saved: this.saved, + successMessage: 'Download cleaner settings saved', + onSaved: () => this.dirtyTracker.markSaved(m), }); } diff --git a/code/frontend/src/app/features/settings/download-cleaner/seeding-rule-modal.component.spec.ts b/code/frontend/src/app/features/settings/download-cleaner/seeding-rule-modal.component.spec.ts index 04119453..0f2ff417 100644 --- a/code/frontend/src/app/features/settings/download-cleaner/seeding-rule-modal.component.spec.ts +++ b/code/frontend/src/app/features/settings/download-cleaner/seeding-rule-modal.component.spec.ts @@ -337,14 +337,14 @@ describe('SeedingRuleModalComponent', () => { it('reports a generic failure for a non-validation error', () => { const { fixture, modal, api, toast } = setup(RULE); - const error = new ApiError('boom'); + const error = new ApiError('An unexpected error occurred'); error.statusCode = 500; api.updateSeedingRule.mockReturnValue(throwError(() => error)); modal.save(); fixture.detectChanges(); - expect(toast.toasts()[0]).toMatchObject({ severity: 'error', message: 'Failed to save seeding rule' }); + expect(toast.toasts()[0]).toMatchObject({ severity: 'error', message: 'An unexpected error occurred' }); }); it('refuses to save an incomplete form or one without a client', () => { diff --git a/code/frontend/src/app/features/settings/download-cleaner/seeding-rule-modal.component.ts b/code/frontend/src/app/features/settings/download-cleaner/seeding-rule-modal.component.ts index f5a96cb7..882d2b24 100644 --- a/code/frontend/src/app/features/settings/download-cleaner/seeding-rule-modal.component.ts +++ b/code/frontend/src/app/features/settings/download-cleaner/seeding-rule-modal.component.ts @@ -179,8 +179,8 @@ export class SeedingRuleModalComponent { this.visible.set(false); this.saved.emit(); }, - error: (e: ApiError) => { - this.toast.error(e.statusCode === 400 ? e.message : 'Failed to save seeding rule'); + error: (err: ApiError) => { + this.toast.error(err.message); this.saving.set(false); }, }); diff --git a/code/frontend/src/app/features/settings/download-clients/download-clients.component.spec.ts b/code/frontend/src/app/features/settings/download-clients/download-clients.component.spec.ts index a49f8ebf..bf803395 100644 --- a/code/frontend/src/app/features/settings/download-clients/download-clients.component.spec.ts +++ b/code/frontend/src/app/features/settings/download-clients/download-clients.component.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { of, throwError } from 'rxjs'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { DownloadClientApi } from '@core/api/download-client.api'; import { ConfirmService } from '@core/services/confirm.service'; import { ToastService } from '@core/services/toast.service'; @@ -338,7 +339,7 @@ describe('DownloadClientsComponent', () => { it('keeps the modal open and reports the failure when the update fails', () => { const api = createApi(); - api.update.mockReturnValue(throwError(() => new Error('boom'))); + api.update.mockReturnValue(throwError(() => ({ message: 'Invalid host address' }))); const { fixture, component, toast } = setup(api); component.openEditModal(QBIT); @@ -347,7 +348,7 @@ describe('DownloadClientsComponent', () => { component.saveClient(); fixture.detectChanges(); - expect(toast.error).toHaveBeenCalledWith('Failed to update client'); + expect(toast.error).toHaveBeenCalledWith('Invalid host address'); expect(component.modalVisible()).toBe(true); expect(component.saving()).toBe(false); }); @@ -376,7 +377,7 @@ describe('DownloadClientsComponent', () => { it('reports a failed connection test and stops the testing spinner', () => { const api = createApi(); - api.test.mockReturnValue(throwError(() => new Error('refused'))); + api.test.mockReturnValue(throwError(() => ({ message: 'Connection refused' }))); const { fixture, component, toast } = setup(api); component.openAddModal(); @@ -391,7 +392,7 @@ describe('DownloadClientsComponent', () => { clientId: undefined, }), ); - expect(toast.error).toHaveBeenCalledWith('Connection test failed'); + expect(toast.error).toHaveBeenCalledWith('Connection refused'); expect(component.testing()).toBe(false); }); @@ -448,7 +449,7 @@ describe('DownloadClientsComponent', () => { expect(component.loadError()).toBe(true); expect(component.clients()).toEqual([]); - expect(toast.error).toHaveBeenCalledWith('Failed to load download clients'); + expect(toast.error).toHaveBeenCalledWith('Failed to load download clients: offline'); expect(fixture.nativeElement.textContent).toContain('Could not connect to server'); api.getConfig.mockReturnValue(of(CONFIG)); @@ -458,4 +459,36 @@ describe('DownloadClientsComponent', () => { expect(component.loadError()).toBe(false); expect(text(fixture, '.item-row__name')).toEqual(['qBit box', 'Deluge box']); }); + + it('reports a failed create and keeps the modal open', () => { + const api = createApi(); + api.create.mockReturnValue(throwError(() => new ApiError('Invalid port'))); + const { fixture, component, toast } = setup(api); + + component.openAddModal(); + component.clientForm.name().value.set('New client'); + component.clientForm.host().value.set('http://localhost:9091'); + fixture.detectChanges(); + + component.saveClient(); + fixture.detectChanges(); + + expect(toast.error).toHaveBeenCalledWith('Invalid port'); + expect(component.modalVisible()).toBe(true); + expect(component.saving()).toBe(false); + }); + + it('reports a failed delete and keeps the client list open', async () => { + const api = createApi(); + api.delete.mockReturnValue(throwError(() => new ApiError('Client in use'))); + const { fixture, component, toast, confirm } = setup(api); + + const accepted = component.deleteClient(QBIT); + confirm.accept(); + await accepted; + fixture.detectChanges(); + + expect(toast.error).toHaveBeenCalledWith('Client in use'); + expect(component.clients()).toEqual([QBIT, DELUGE]); + }); }); diff --git a/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts b/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts index fffcd898..df93ad0f 100644 --- a/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts +++ b/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts @@ -7,6 +7,7 @@ import { type SelectOption, } from '@ui'; import { DownloadClientApi } from '@core/api/download-client.api'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { ToastService } from '@core/services/toast.service'; import { ConfirmService } from '@core/services/confirm.service'; import { @@ -191,8 +192,8 @@ export class DownloadClientsComponent implements HasPendingChanges { this.toast.success(result.message || 'Connection successful'); this.testing.set(false); }, - error: () => { - this.toast.error('Connection test failed'); + error: (err: ApiError) => { + this.toast.error(err.message); this.testing.set(false); }, }); @@ -227,8 +228,8 @@ export class DownloadClientsComponent implements HasPendingChanges { this.saving.set(false); this.clientsResource.reload(); }, - error: () => { - this.toast.error('Failed to update client'); + error: (err: ApiError) => { + this.toast.error(err.message); this.saving.set(false); }, }); @@ -253,8 +254,8 @@ export class DownloadClientsComponent implements HasPendingChanges { this.saving.set(false); this.clientsResource.reload(); }, - error: () => { - this.toast.error('Failed to add client'); + error: (err: ApiError) => { + this.toast.error(err.message); this.saving.set(false); }, }); @@ -277,7 +278,7 @@ export class DownloadClientsComponent implements HasPendingChanges { this.toast.success('Client deleted'); this.clientsResource.reload(); }, - error: () => this.toast.error('Failed to delete client'), + error: (err: ApiError) => this.toast.error(err.message), }); } diff --git a/code/frontend/src/app/features/settings/general/general-settings.component.spec.ts b/code/frontend/src/app/features/settings/general/general-settings.component.spec.ts index ce1318c1..bea37a43 100644 --- a/code/frontend/src/app/features/settings/general/general-settings.component.spec.ts +++ b/code/frontend/src/app/features/settings/general/general-settings.component.spec.ts @@ -329,14 +329,14 @@ describe('GeneralSettingsComponent', () => { it('reports a failed save without clearing the pending changes', () => { const { fixture, component, api, toast } = setup(); - api.update.mockReturnValue(throwError(() => new Error('boom'))); + api.update.mockReturnValue(throwError(() => ({ message: 'Invalid configuration' }))); component.genForm.dryRun().value.set(false); fixture.detectChanges(); component.save(); fixture.detectChanges(); - expect(toast.toasts().at(-1)?.message).toBe('Failed to save general settings'); + expect(toast.toasts().at(-1)?.message).toBe('Invalid configuration'); expect(component.saving()).toBe(false); expect(component.saved()).toBe(false); expect(component.dirty()).toBe(true); @@ -353,7 +353,7 @@ describe('GeneralSettingsComponent', () => { const { fixture, component, toast } = setup(CONFIG, api); expect(component.loadError()).toBe(true); - expect(toast.toasts().at(-1)?.message).toBe('Failed to load general settings'); + expect(toast.toasts().at(-1)?.message).toBe('Failed to load general settings: offline'); expect(fixture.nativeElement.textContent).toContain('Could not connect'); component.retry(); diff --git a/code/frontend/src/app/features/settings/general/general-settings.component.ts b/code/frontend/src/app/features/settings/general/general-settings.component.ts index db1cd39f..aec4ad24 100644 --- a/code/frontend/src/app/features/settings/general/general-settings.component.ts +++ b/code/frontend/src/app/features/settings/general/general-settings.component.ts @@ -8,13 +8,14 @@ import { type SelectOption, } from '@ui'; import { GeneralConfigApi } from '@core/api/general-config.api'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { ToastService } from '@core/services/toast.service'; import { ConfirmService } from '@core/services/confirm.service'; import { GeneralConfig } from '@shared/models/general-config.model'; import { CertificateValidationType, LogEventLevel } from '@shared/models/enums'; import { HasPendingChanges } from '@core/guards/pending-changes.guard'; -import { createSettingsResource } from '@shared/utils/settings-resource.util'; -import { createDirtyTracker, SAVED_FLASH_MS } from '@shared/utils/dirty-tracker.util'; +import { createSettingsResource, saveSettings } from '@shared/utils/settings-resource.util'; +import { createDirtyTracker } from '@shared/utils/dirty-tracker.util'; const CERT_OPTIONS: SelectOption[] = [ { label: 'Enabled', value: CertificateValidationType.Enabled }, @@ -248,19 +249,13 @@ export class GeneralSettingsComponent implements HasPendingChanges { }, }; - this.saving.set(true); - this.api.update(config).subscribe({ - next: () => { - this.toast.success('General settings saved'); - this.saving.set(false); - this.saved.set(true); - setTimeout(() => this.saved.set(false), SAVED_FLASH_MS); - this.dirtyTracker.markSaved(m); - }, - error: () => { - this.toast.error('Failed to save general settings'); - this.saving.set(false); - }, + saveSettings({ + request: this.api.update(config), + toast: this.toast, + saving: this.saving, + saved: this.saved, + successMessage: 'General settings saved', + onSaved: () => this.dirtyTracker.markSaved(m), }); } @@ -287,8 +282,8 @@ export class GeneralSettingsComponent implements HasPendingChanges { this.toast.success(`Purged ${result.deletedStrikes} strikes`); this.purgingStrikes.set(false); }, - error: () => { - this.toast.error('Failed to purge strikes'); + error: (err: ApiError) => { + this.toast.error(err.message); this.purgingStrikes.set(false); }, }); diff --git a/code/frontend/src/app/features/settings/malware-blocker/malware-blocker.component.spec.ts b/code/frontend/src/app/features/settings/malware-blocker/malware-blocker.component.spec.ts index 47c0f83b..f3098ddb 100644 --- a/code/frontend/src/app/features/settings/malware-blocker/malware-blocker.component.spec.ts +++ b/code/frontend/src/app/features/settings/malware-blocker/malware-blocker.component.spec.ts @@ -346,7 +346,7 @@ describe('MalwareBlockerComponent', () => { const { fixture, component, toast } = setup(CONFIG, api); expect(component.loadError()).toBe(true); - expect(toast.toasts().at(-1)?.message).toBe('Failed to load malware blocker settings'); + expect(toast.toasts().at(-1)?.message).toBe('Failed to load malware blocker settings: offline'); expect(fixture.nativeElement.textContent).toContain('Could not connect'); component.retry(); diff --git a/code/frontend/src/app/features/settings/malware-blocker/malware-blocker.component.ts b/code/frontend/src/app/features/settings/malware-blocker/malware-blocker.component.ts index 24c52bb3..9ea3b48c 100644 --- a/code/frontend/src/app/features/settings/malware-blocker/malware-blocker.component.ts +++ b/code/frontend/src/app/features/settings/malware-blocker/malware-blocker.component.ts @@ -8,13 +8,12 @@ import { type SelectOption, } from '@ui'; import { MalwareBlockerApi } from '@core/api/malware-blocker.api'; -import { ApiError } from '@core/interceptors/error.interceptor'; import { ToastService } from '@core/services/toast.service'; import { MalwareBlockerConfig, BlocklistSettings, MalwareScheduleOptions } from '@shared/models/malware-blocker-config.model'; import { BlocklistType, ScheduleUnit } from '@shared/models/enums'; import { HasPendingChanges } from '@core/guards/pending-changes.guard'; -import { createSettingsResource } from '@shared/utils/settings-resource.util'; -import { createDirtyTracker, SAVED_FLASH_MS } from '@shared/utils/dirty-tracker.util'; +import { createSettingsResource, saveSettings } from '@shared/utils/settings-resource.util'; +import { createDirtyTracker } from '@shared/utils/dirty-tracker.util'; import { generateCronExpression, resolveSchedule } from '@shared/utils/schedule.util'; const BLOCKLIST_TYPE_OPTIONS: SelectOption[] = [ @@ -260,21 +259,13 @@ export class MalwareBlockerComponent implements HasPendingChanges { lazyLibrarian: { ...m.lazyLibrarian }, }; - this.saving.set(true); - this.api.updateConfig(config).subscribe({ - next: () => { - this.toast.success('Malware blocker settings saved'); - this.saving.set(false); - this.saved.set(true); - setTimeout(() => this.saved.set(false), SAVED_FLASH_MS); - this.dirtyTracker.markSaved(m); - }, - error: (err: ApiError) => { - this.toast.error(err.statusCode === 400 - ? err.message - : 'Failed to save malware blocker settings'); - this.saving.set(false); - }, + saveSettings({ + request: this.api.updateConfig(config), + toast: this.toast, + saving: this.saving, + saved: this.saved, + successMessage: 'Malware blocker settings saved', + onSaved: () => this.dirtyTracker.markSaved(m), }); } diff --git a/code/frontend/src/app/features/settings/notifications/notification-provider-modal.component.spec.ts b/code/frontend/src/app/features/settings/notifications/notification-provider-modal.component.spec.ts index 3cb1b514..f912421a 100644 --- a/code/frontend/src/app/features/settings/notifications/notification-provider-modal.component.spec.ts +++ b/code/frontend/src/app/features/settings/notifications/notification-provider-modal.component.spec.ts @@ -651,7 +651,7 @@ describe('NotificationProviderModalComponent', () => { component.testNotification(); fixture.detectChanges(); - expect(toast.error).toHaveBeenCalledWith('Test failed'); + expect(toast.error).toHaveBeenCalledWith('offline'); expect(component.testing()).toBe(false); }); @@ -770,7 +770,7 @@ describe('NotificationProviderModalComponent', () => { component.saveProvider(); fixture.detectChanges(); - expect(toast.error).toHaveBeenCalledWith('Failed to save provider'); + expect(toast.error).toHaveBeenCalledWith('boom'); expect(toast.success).not.toHaveBeenCalled(); expect(component.visible()).toBe(true); expect(component.saving()).toBe(false); diff --git a/code/frontend/src/app/features/settings/notifications/notification-provider-modal.component.ts b/code/frontend/src/app/features/settings/notifications/notification-provider-modal.component.ts index 16aa8512..222dd4c8 100644 --- a/code/frontend/src/app/features/settings/notifications/notification-provider-modal.component.ts +++ b/code/frontend/src/app/features/settings/notifications/notification-provider-modal.component.ts @@ -6,6 +6,7 @@ import { type SelectOption, } from '@ui'; import { NotificationApi } from '@core/api/notification.api'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { ToastService } from '@core/services/toast.service'; import { NotificationProviderDto, AppriseCliStatus } from '@shared/models/notification-provider.model'; import { @@ -365,7 +366,7 @@ export class NotificationProviderModalComponent { this.testing.set(true); this.api.test(descriptor, descriptor.buildTestRequest(this.modalModel(), providerId)).subscribe({ next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); }, - error: () => { this.toast.error('Test failed'); this.testing.set(false); }, + error: (err: ApiError) => { this.toast.error(err.message); this.testing.set(false); }, }); } @@ -378,7 +379,7 @@ export class NotificationProviderModalComponent { const obs = editing ? this.api.update(descriptor, editing.id, request) : this.api.create(descriptor, request); - obs.subscribe({ next: () => this.onSaveSuccess(editing), error: () => this.onSaveError() }); + obs.subscribe({ next: () => this.onSaveSuccess(editing), error: (err: ApiError) => this.onSaveError(err.message) }); } private onSaveSuccess(editing: NotificationProviderDto | null): void { @@ -388,8 +389,8 @@ export class NotificationProviderModalComponent { this.saved.emit(); } - private onSaveError(): void { - this.toast.error('Failed to save provider'); + private onSaveError(message: string): void { + this.toast.error(message); this.saving.set(false); } } diff --git a/code/frontend/src/app/features/settings/notifications/notifications.component.spec.ts b/code/frontend/src/app/features/settings/notifications/notifications.component.spec.ts index 18345de5..77402896 100644 --- a/code/frontend/src/app/features/settings/notifications/notifications.component.spec.ts +++ b/code/frontend/src/app/features/settings/notifications/notifications.component.spec.ts @@ -217,12 +217,12 @@ describe('NotificationsComponent', () => { it('toasts and keeps the list when the delete request fails', async () => { const { component, api, toast, confirm } = setup(); - api.deleteProvider.mockReturnValue(throwError(() => new Error('boom'))); + api.deleteProvider.mockReturnValue(throwError(() => ({ message: 'Provider is in use' }))); const pending = component.deleteProvider(TELEGRAM_PROVIDER); confirm.accept(); await pending; - expect(toast.error).toHaveBeenCalledWith('Failed to delete provider'); + expect(toast.error).toHaveBeenCalledWith('Provider is in use'); expect(toast.success).not.toHaveBeenCalled(); expect(api.getProviders).toHaveBeenCalledTimes(1); }); @@ -246,7 +246,7 @@ describe('NotificationsComponent', () => { expect(fixture.componentInstance.loadError()).toBe(true); expect(fixture.componentInstance.providers()).toEqual([]); expect(root(fixture).textContent).toContain('Could not connect to server'); - expect(toast.error).toHaveBeenCalledWith('Failed to load notification providers'); + expect(toast.error).toHaveBeenCalledWith('Failed to load notification providers: offline'); api.getProviders.mockReturnValue(of({ providers: [DISCORD_PROVIDER] })); (root(fixture).querySelector('app-empty-state button') as HTMLButtonElement).click(); diff --git a/code/frontend/src/app/features/settings/notifications/notifications.component.ts b/code/frontend/src/app/features/settings/notifications/notifications.component.ts index 324785d1..e1e94c79 100644 --- a/code/frontend/src/app/features/settings/notifications/notifications.component.ts +++ b/code/frontend/src/app/features/settings/notifications/notifications.component.ts @@ -6,6 +6,7 @@ import { BadgeComponent, LoadingStateComponent, } from '@ui'; import { NotificationApi } from '@core/api/notification.api'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { ToastService } from '@core/services/toast.service'; import { ConfirmService } from '@core/services/confirm.service'; import { ThemeService } from '@core/services/theme.service'; @@ -65,8 +66,9 @@ export class NotificationsComponent implements HasPendingChanges { constructor() { effect(() => { - if (this.providersResource.error()) { - this.toast.error('Failed to load notification providers'); + const err = this.providersResource.error(); + if (err) { + this.toast.error(`Failed to load notification providers: ${err.message}`); } }); @@ -117,7 +119,7 @@ export class NotificationsComponent implements HasPendingChanges { this.toast.success('Provider deleted'); this.providersResource.reload(); }, - error: () => this.toast.error('Failed to delete provider'), + error: (err: ApiError) => this.toast.error(err.message), }); } diff --git a/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.spec.ts b/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.spec.ts index 93e080da..c3c3fd9f 100644 --- a/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.spec.ts +++ b/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.spec.ts @@ -2,6 +2,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { of, throwError } from 'rxjs'; import { QueueCleanerApi } from '@core/api/queue-cleaner.api'; +import { ApiError } from '@core/interceptors/error.interceptor'; +import { ToastService } from '@core/services/toast.service'; import { ConfirmService } from '@core/services/confirm.service'; import { QueueCleanerConfig } from '@shared/models/queue-cleaner-config.model'; import { SlowRule, StallRule } from '@shared/models/queue-rule.model'; @@ -73,10 +75,15 @@ function createApi(config: QueueCleanerConfig, stall: StallRule[], slow: SlowRul }; } +function createToast() { + return { success: vi.fn(), error: vi.fn() }; +} + interface Setup { fixture: ComponentFixture; component: QueueCleanerComponent; api: ReturnType; + toast: { success: ReturnType; error: ReturnType }; confirm: ConfirmService; } @@ -95,8 +102,14 @@ describe('QueueCleanerComponent', () => { api = createApi(config, stall, slow); } + const toast = createToast(); + TestBed.configureTestingModule({ - providers: [provideHttpClient(), { provide: QueueCleanerApi, useValue: api }], + providers: [ + provideHttpClient(), + { provide: QueueCleanerApi, useValue: api }, + { provide: ToastService, useValue: toast }, + ], }); const fixture = TestBed.createComponent(QueueCleanerComponent); @@ -106,6 +119,7 @@ describe('QueueCleanerComponent', () => { fixture, component: fixture.componentInstance, api, + toast, confirm: TestBed.inject(ConfirmService), }; } @@ -454,4 +468,30 @@ describe('QueueCleanerComponent', () => { expect(component.qcForm.enabled().value()).toBe(true); expect(api.getConfig).toHaveBeenCalledTimes(2); }); + + it('toasts which rule load failed alongside the backend message', () => { + const api = createApi(CONFIG, STALL_RULES, SLOW_RULES); + api.getStallRules.mockReturnValue(throwError(() => new ApiError('stall boom'))); + api.getSlowRules.mockReturnValue(throwError(() => new ApiError('slow boom'))); + const { toast } = setup(CONFIG, STALL_RULES, SLOW_RULES, api); + + expect(toast.error).toHaveBeenCalledWith('Failed to load stall rules: stall boom'); + expect(toast.error).toHaveBeenCalledWith('Failed to load slow rules: slow boom'); + }); + + it('returns empty array for stallRules after load failure', () => { + const api = createApi(CONFIG, STALL_RULES, SLOW_RULES); + api.getStallRules.mockReturnValue(throwError(() => new ApiError('stall boom'))); + const { component } = setup(CONFIG, STALL_RULES, SLOW_RULES, api); + + expect(component.stallRules()).toEqual([]); + }); + + it('returns empty array for slowRules after load failure', () => { + const api = createApi(CONFIG, STALL_RULES, SLOW_RULES); + api.getSlowRules.mockReturnValue(throwError(() => new ApiError('slow boom'))); + const { component } = setup(CONFIG, STALL_RULES, SLOW_RULES, api); + + expect(component.slowRules()).toEqual([]); + }); }); diff --git a/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.ts b/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.ts index bcf09455..12a22735 100644 --- a/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.ts +++ b/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.ts @@ -10,6 +10,7 @@ import { } from '@ui'; import { NgIcon } from '@ng-icons/core'; import { QueueCleanerApi } from '@core/api/queue-cleaner.api'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { ToastService } from '@core/services/toast.service'; import { ConfirmService } from '@core/services/confirm.service'; import { QueueCleanerConfig, ScheduleOptions } from '@shared/models/queue-cleaner-config.model'; @@ -18,8 +19,8 @@ import { SlowRuleModalComponent } from './slow-rule-modal.component'; import { StallRuleModalComponent } from './stall-rule-modal.component'; import { ScheduleUnit, PatternMode } from '@shared/models/enums'; import { HasPendingChanges } from '@core/guards/pending-changes.guard'; -import { createSettingsResource } from '@shared/utils/settings-resource.util'; -import { createDirtyTracker, SAVED_FLASH_MS } from '@shared/utils/dirty-tracker.util'; +import { createSettingsResource, saveSettings } from '@shared/utils/settings-resource.util'; +import { createDirtyTracker } from '@shared/utils/dirty-tracker.util'; import { generateCronExpression, resolveSchedule } from '@shared/utils/schedule.util'; import { analyzeCoverage } from './coverage-analysis.util'; @@ -177,14 +178,14 @@ export class QueueCleanerComponent implements HasPendingChanges { readonly metadataExpanded = signal(false); // Stall rules - readonly stallRules = computed(() => this.stallRulesResource.value()); + readonly stallRules = computed(() => (this.stallRulesResource.hasValue() ? this.stallRulesResource.value() : [])); readonly stallRulesLoading = computed(() => this.stallRulesResource.isLoading()); readonly stallExpanded = signal(false); readonly stallModalVisible = signal(false); readonly editingStallRule = signal(null); // Slow rules - readonly slowRules = computed(() => this.slowRulesResource.value()); + readonly slowRules = computed(() => (this.slowRulesResource.hasValue() ? this.slowRulesResource.value() : [])); readonly slowRulesLoading = computed(() => this.slowRulesResource.isLoading()); readonly slowExpanded = signal(false); readonly slowModalVisible = signal(false); @@ -254,14 +255,16 @@ export class QueueCleanerComponent implements HasPendingChanges { }); effect(() => { - if (this.stallRulesResource.error()) { - this.toast.error('Failed to load stall rules'); + const err = this.stallRulesResource.error(); + if (err) { + this.toast.error(`Failed to load stall rules: ${err.message}`); } }); effect(() => { - if (this.slowRulesResource.error()) { - this.toast.error('Failed to load slow rules'); + const err = this.slowRulesResource.error(); + if (err) { + this.toast.error(`Failed to load slow rules: ${err.message}`); } }); } @@ -315,7 +318,7 @@ export class QueueCleanerComponent implements HasPendingChanges { this.toast.success('Stall rule deleted'); this.stallRulesResource.reload(); }, - error: () => this.toast.error('Failed to delete stall rule'), + error: (err: ApiError) => this.toast.error(err.message), }); } @@ -342,7 +345,7 @@ export class QueueCleanerComponent implements HasPendingChanges { this.toast.success('Slow rule deleted'); this.slowRulesResource.reload(); }, - error: () => this.toast.error('Failed to delete slow rule'), + error: (err: ApiError) => this.toast.error(err.message), }); } @@ -375,19 +378,13 @@ export class QueueCleanerComponent implements HasPendingChanges { downloadingMetadataMaxStrikes: m.metadataMaxStrikes ?? 3, }; - this.saving.set(true); - this.api.updateConfig(config).subscribe({ - next: () => { - this.toast.success('Queue cleaner settings saved'); - this.saving.set(false); - this.saved.set(true); - setTimeout(() => this.saved.set(false), SAVED_FLASH_MS); - this.dirtyTracker.markSaved(m); - }, - error: () => { - this.toast.error('Failed to save queue cleaner settings'); - this.saving.set(false); - }, + saveSettings({ + request: this.api.updateConfig(config), + toast: this.toast, + saving: this.saving, + saved: this.saved, + successMessage: 'Queue cleaner settings saved', + onSaved: () => this.dirtyTracker.markSaved(m), }); } diff --git a/code/frontend/src/app/features/settings/queue-cleaner/slow-rule-modal.component.ts b/code/frontend/src/app/features/settings/queue-cleaner/slow-rule-modal.component.ts index 75441066..8163ef31 100644 --- a/code/frontend/src/app/features/settings/queue-cleaner/slow-rule-modal.component.ts +++ b/code/frontend/src/app/features/settings/queue-cleaner/slow-rule-modal.component.ts @@ -6,6 +6,7 @@ import { type SelectOption, type SizeUnit, } from '@ui'; import { QueueCleanerApi } from '@core/api/queue-cleaner.api'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { ToastService } from '@core/services/toast.service'; import { SlowRule, CreateSlowRuleDto } from '@shared/models/queue-rule.model'; import { TorrentPrivacyType } from '@shared/models/enums'; @@ -170,8 +171,8 @@ export class SlowRuleModalComponent { this.visible.set(false); this.saved.emit(); }, - error: (e: Error) => { - this.toast.error(e.message); + error: (err: ApiError) => { + this.toast.error(err.message); this.saving.set(false); }, }); diff --git a/code/frontend/src/app/features/settings/queue-cleaner/stall-rule-modal.component.ts b/code/frontend/src/app/features/settings/queue-cleaner/stall-rule-modal.component.ts index 910d91d8..774b7f27 100644 --- a/code/frontend/src/app/features/settings/queue-cleaner/stall-rule-modal.component.ts +++ b/code/frontend/src/app/features/settings/queue-cleaner/stall-rule-modal.component.ts @@ -6,6 +6,7 @@ import { type SelectOption, type SizeUnit, } from '@ui'; import { QueueCleanerApi } from '@core/api/queue-cleaner.api'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { ToastService } from '@core/services/toast.service'; import { StallRule, CreateStallRuleDto } from '@shared/models/queue-rule.model'; import { TorrentPrivacyType } from '@shared/models/enums'; @@ -155,8 +156,8 @@ export class StallRuleModalComponent { this.visible.set(false); this.saved.emit(); }, - error: (e: Error) => { - this.toast.error(e.message); + error: (err: ApiError) => { + this.toast.error(err.message); this.saving.set(false); }, }); diff --git a/code/frontend/src/app/features/settings/seeker/seeker.component.spec.ts b/code/frontend/src/app/features/settings/seeker/seeker.component.spec.ts index c9a059e0..7d204a38 100644 --- a/code/frontend/src/app/features/settings/seeker/seeker.component.spec.ts +++ b/code/frontend/src/app/features/settings/seeker/seeker.component.spec.ts @@ -188,7 +188,7 @@ describe('SeekerComponent', () => { expect(component.loadError()).toBe(true); expect(text(fixture)).toContain('Could not connect to server'); - expect(toasts).toContain('error:Failed to load seeker settings'); + expect(toasts).toContain('error:Failed to load seeker settings: boom'); expect(fixture.nativeElement.querySelector('.settings-form')).toBeNull(); (fixture.nativeElement.querySelector('app-empty-state button') as HTMLButtonElement).click(); @@ -556,7 +556,7 @@ describe('SeekerComponent', () => { component.patchInstance(0, { ignoreStruckDownloads: true }); component.save(); - expect(toasts).toContain('error:Failed to save seeker settings'); + expect(toasts).toContain('error:Internal Server Error'); }); it('maps the instance type to an icon and a badge severity', () => { diff --git a/code/frontend/src/app/features/settings/seeker/seeker.component.ts b/code/frontend/src/app/features/settings/seeker/seeker.component.ts index 0f8a3b9d..6d5ece5d 100644 --- a/code/frontend/src/app/features/settings/seeker/seeker.component.ts +++ b/code/frontend/src/app/features/settings/seeker/seeker.component.ts @@ -185,7 +185,7 @@ export class SeekerComponent implements HasPendingChanges { this.settings.retry(); } - readonly confirmRoundRobin = async (newValue: boolean): Promise => { + readonly confirmRoundRobin = (newValue: boolean): Promise => { if (!newValue) { return this.confirm.confirm({ title: 'Disable Round Robin', @@ -194,7 +194,7 @@ export class SeekerComponent implements HasPendingChanges { destructive: true, }); } - return true; + return Promise.resolve(true); }; patchInstance(index: number, patch: Partial): void { @@ -256,9 +256,7 @@ export class SeekerComponent implements HasPendingChanges { this.dirtyTracker.markSaved(snapshot); }, error: (err: ApiError) => { - this.toast.error(err.statusCode === 400 - ? err.message - : 'Failed to save seeker settings'); + this.toast.error(err.message); this.saving.set(false); }, }); diff --git a/code/frontend/src/app/features/strikes/strikes.component.spec.ts b/code/frontend/src/app/features/strikes/strikes.component.spec.ts index fb13100f..c580bb5e 100644 --- a/code/frontend/src/app/features/strikes/strikes.component.spec.ts +++ b/code/frontend/src/app/features/strikes/strikes.component.spec.ts @@ -1,10 +1,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { StrikesApi } from '@core/api/strikes.api'; +import { ToastService } from '@core/services/toast.service'; import { ConfirmService } from '@core/services/confirm.service'; import { DownloadItemStrikes, StrikeFilter } from '@core/models/strike.models'; import { PaginatedResult } from '@core/models/pagination.model'; import { PAGE_SIZE_STORAGE_KEYS } from '@core/services/pagination.service'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { StrikesComponent } from './strikes.component'; import { IntersectionObserverStub } from '../../../testing/intersection-observer.stub'; @@ -63,6 +65,7 @@ interface Harness { fixture: ComponentFixture; filters: StrikeFilter[]; deleted: string[]; + toastError: ReturnType; } describe('StrikesComponent', () => { @@ -72,7 +75,7 @@ describe('StrikesComponent', () => { localStorage.clear(); }); - function setup(options: { confirmed?: boolean; storedPageSize?: string; totalCount?: number } = {}): Harness { + function setup(options: { confirmed?: boolean; storedPageSize?: string; totalCount?: number; strikesLoadFails?: boolean; deleteStrikesFails?: boolean } = {}): Harness { localStorage.clear(); if (options.storedPageSize !== undefined) { localStorage.setItem(PAGE_SIZE_KEY, options.storedPageSize); @@ -81,6 +84,7 @@ describe('StrikesComponent', () => { const filters: StrikeFilter[] = []; const deleted: string[] = []; + const toastErrorSpy = vi.fn(); TestBed.configureTestingModule({ providers: [ @@ -89,6 +93,9 @@ describe('StrikesComponent', () => { useValue: { getStrikes: (filter: StrikeFilter) => { filters.push(filter); + if (options.strikesLoadFails) { + return throwError(() => new ApiError('failed to fetch strikes')); + } return of({ items: ITEMS, page: filter.page ?? 1, @@ -100,10 +107,17 @@ describe('StrikesComponent', () => { getStrikeTypes: () => of(['FailedImport', 'SlowSpeed', 'Stalled']), deleteStrikesForItem: (id: string) => { deleted.push(id); + if (options.deleteStrikesFails) { + return throwError(() => new ApiError('delete failed')); + } return of(undefined); }, }, }, + { + provide: ToastService, + useValue: { error: toastErrorSpy, success: vi.fn() }, + }, { provide: ConfirmService, useValue: { confirm: () => Promise.resolve(options.confirmed ?? true) }, @@ -113,7 +127,7 @@ describe('StrikesComponent', () => { const fixture = TestBed.createComponent(StrikesComponent); fixture.detectChanges(); - return { fixture, filters, deleted }; + return { fixture, filters, deleted, toastError: toastErrorSpy }; } function lastFilter(filters: StrikeFilter[]): StrikeFilter { @@ -257,4 +271,21 @@ describe('StrikesComponent', () => { expect(filters.length).toBe(afterInit + 1); }); + + it('toasts the backend message when deleting strikes fails', async () => { + const { fixture, toastError } = setup({ deleteStrikesFails: true }); + const component = fixture.componentInstance; + + await component.deleteItemStrikes(ITEMS[0]); + fixture.detectChanges(); + + expect(toastError).toHaveBeenCalledWith('delete failed'); + }); + + it('toasts the error message when loading strikes fails', () => { + const { fixture, toastError } = setup({ strikesLoadFails: true }); + + expect(toastError).toHaveBeenCalledWith('Failed to load strikes: failed to fetch strikes'); + expect(fixture.nativeElement).toBeDefined(); + }); }); diff --git a/code/frontend/src/app/features/strikes/strikes.component.ts b/code/frontend/src/app/features/strikes/strikes.component.ts index 1bbaf101..ad6338ab 100644 --- a/code/frontend/src/app/features/strikes/strikes.component.ts +++ b/code/frontend/src/app/features/strikes/strikes.component.ts @@ -9,6 +9,7 @@ import { } from '@ui'; import { AnimatedCounterComponent } from '@ui/animated-counter/animated-counter.component'; import { StrikesApi } from '@core/api/strikes.api'; +import { ApiError } from '@core/interceptors/error.interceptor'; import { ToastService } from '@core/services/toast.service'; import { ConfirmService } from '@core/services/confirm.service'; import { PaginationService, PAGE_SIZE_STORAGE_KEYS } from '@core/services/pagination.service'; @@ -84,17 +85,18 @@ export class StrikesComponent implements OnInit, OnDestroy { defaultValue: [] as string[], }); - readonly items = computed(() => this.strikesResource.value().items); - readonly totalRecords = computed(() => this.strikesResource.value().totalCount); + readonly items = computed(() => this.strikesResource.hasValue() ? this.strikesResource.value().items : []); + readonly totalRecords = computed(() => this.strikesResource.hasValue() ? this.strikesResource.value().totalCount : 0); readonly typeOptions = computed(() => [ { label: 'All Types', value: '' }, - ...this.strikeTypesResource.value().map((t) => ({ label: this.formatStrikeType(t), value: t })), + ...(this.strikeTypesResource.hasValue() ? this.strikeTypesResource.value() : []).map((t) => ({ label: this.formatStrikeType(t), value: t })), ]); constructor() { effect(() => { - if (this.strikesResource.error()) { - this.toast.error('Failed to load strikes'); + const err = this.strikesResource.error(); + if (err) { + this.toast.error(`Failed to load strikes: ${err.message}`); } }); } @@ -142,7 +144,7 @@ export class StrikesComponent implements OnInit, OnDestroy { this.toast.success(`Strikes deleted for "${item.title}"`); this.strikesResource.reload(); }, - error: () => this.toast.error('Failed to delete strikes'), + error: (err: ApiError) => this.toast.error(err.message), }); } diff --git a/code/frontend/src/app/shared/utils/clipboard.util.spec.ts b/code/frontend/src/app/shared/utils/clipboard.util.spec.ts new file mode 100644 index 00000000..a1ba2389 --- /dev/null +++ b/code/frontend/src/app/shared/utils/clipboard.util.spec.ts @@ -0,0 +1,20 @@ +import { copyToClipboard } from './clipboard.util'; + +describe('copyToClipboard', () => { + afterEach(() => { + delete (navigator as { clipboard?: Clipboard }).clipboard; + }); + + it('writes the text through the Clipboard API', async () => { + const writeText = vi.fn(() => Promise.resolve()); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + + await copyToClipboard('hello'); + + expect(writeText).toHaveBeenCalledWith('hello'); + }); + + it('rejects instead of throwing when the Clipboard API is missing', async () => { + await expect(copyToClipboard('hello')).rejects.toThrow('Clipboard API unavailable'); + }); +}); diff --git a/code/frontend/src/app/shared/utils/clipboard.util.ts b/code/frontend/src/app/shared/utils/clipboard.util.ts new file mode 100644 index 00000000..c5f19d53 --- /dev/null +++ b/code/frontend/src/app/shared/utils/clipboard.util.ts @@ -0,0 +1,10 @@ +/** + * Writes text to the clipboard, rejecting when the browser exposes no Clipboard API (plain-HTTP origins). + */ +export function copyToClipboard(text: string): Promise { + const clipboard: Clipboard | undefined = navigator.clipboard; + if (!clipboard) { + return Promise.reject(new Error('Clipboard API unavailable')); + } + return clipboard.writeText(text); +} diff --git a/code/frontend/src/app/shared/utils/plex-pin-poller.ts b/code/frontend/src/app/shared/utils/plex-pin-poller.ts index bf422233..b7e7ae24 100644 --- a/code/frontend/src/app/shared/utils/plex-pin-poller.ts +++ b/code/frontend/src/app/shared/utils/plex-pin-poller.ts @@ -1,11 +1,12 @@ import { DestroyRef } from '@angular/core'; import { Observable, Subscription } from 'rxjs'; +import { ApiError } from '@core/interceptors/error.interceptor'; export interface PlexPinPollOptions { /** Called on each attempt to check whether the Plex PIN has been authorized. */ verify: () => Observable; onCompleted: (result: T) => void; - onError: (error: unknown) => void; + onError: (error: ApiError) => void; onTimeout: () => void; destroyRef: DestroyRef; intervalMs?: number; diff --git a/code/frontend/src/app/shared/utils/settings-resource.util.spec.ts b/code/frontend/src/app/shared/utils/settings-resource.util.spec.ts index 2762acf3..3aa78c88 100644 --- a/code/frontend/src/app/shared/utils/settings-resource.util.spec.ts +++ b/code/frontend/src/app/shared/utils/settings-resource.util.spec.ts @@ -1,7 +1,10 @@ +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { NEVER, Observable, Subject, throwError } from 'rxjs'; +import { NEVER, Observable, of, Subject, throwError } from 'rxjs'; import { ToastService } from '@core/services/toast.service'; -import { createSettingsResource, SettingsResource } from './settings-resource.util'; +import { ApiError } from '@core/interceptors/error.interceptor'; +import { SAVED_FLASH_MS } from './dirty-tracker.util'; +import { createSettingsResource, saveSettings, SettingsResource } from './settings-resource.util'; function createToast() { return { success: vi.fn(), error: vi.fn() }; @@ -50,7 +53,7 @@ describe('createSettingsResource', () => { expect(settingsResource.loader.loading()).toBe(false); expect(settingsResource.loadError()).toBe(true); - expect(toast.error).toHaveBeenCalledWith('Failed to load'); + expect(toast.error).toHaveBeenCalledWith('Failed to load: boom'); }); it('evaluates a function error message lazily at error time', () => { @@ -66,6 +69,56 @@ describe('createSettingsResource', () => { TestBed.tick(); expect(errorMessage).toHaveBeenCalled(); - expect(toast.error).toHaveBeenCalledWith('Dynamic failure'); + expect(toast.error).toHaveBeenCalledWith('Dynamic failure: boom'); + }); +}); + +describe('saveSettings', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + function run(request: Observable) { + const toast = createToast(); + const saving = signal(false); + const saved = signal(false); + const onSaved = vi.fn(); + saveSettings({ + request, + toast: toast as unknown as ToastService, + saving, + saved, + successMessage: 'Settings saved', + onSaved, + }); + return { toast, saving, saved, onSaved }; + } + + it('toasts success, flashes saved and runs onSaved when the request succeeds', () => { + vi.useFakeTimers(); + const { toast, saving, saved, onSaved } = run(of(undefined)); + + expect(toast.success).toHaveBeenCalledWith('Settings saved'); + expect(saving()).toBe(false); + expect(saved()).toBe(true); + expect(onSaved).toHaveBeenCalled(); + + vi.advanceTimersByTime(SAVED_FLASH_MS); + expect(saved()).toBe(false); + }); + + it('toasts the backend message and clears saving when the request fails', () => { + const { toast, saving, saved, onSaved } = run(throwError(() => new ApiError('boom'))); + + expect(toast.error).toHaveBeenCalledWith('boom'); + expect(saving()).toBe(false); + expect(saved()).toBe(false); + expect(onSaved).not.toHaveBeenCalled(); + }); + + it('flags saving while the request is pending', () => { + const { saving } = run(NEVER); + + expect(saving()).toBe(true); }); }); diff --git a/code/frontend/src/app/shared/utils/settings-resource.util.ts b/code/frontend/src/app/shared/utils/settings-resource.util.ts index 417495fd..031c7919 100644 --- a/code/frontend/src/app/shared/utils/settings-resource.util.ts +++ b/code/frontend/src/app/shared/utils/settings-resource.util.ts @@ -1,14 +1,16 @@ -import { computed, effect, inject, ResourceRef, Signal, untracked } from '@angular/core'; +import { computed, effect, inject, ResourceRef, Signal, untracked, WritableSignal } from '@angular/core'; import { rxResource } from '@angular/core/rxjs-interop'; import { Observable } from 'rxjs'; import { ToastService } from '@core/services/toast.service'; +import { ApiError } from '@core/interceptors/error.interceptor'; +import { SAVED_FLASH_MS } from '@shared/utils/dirty-tracker.util'; import { DeferredLoader } from '@shared/utils/loading.util'; export interface SettingsResourceOptions { /** Reactive params for the load call; re-runs the load whenever they change. */ params?: () => P; load: (params: P) => Observable; - /** Toasted when the load fails. Evaluated lazily, at error time. */ + /** Prefixes the backend message when the load fails. Evaluated lazily, at error time. */ errorMessage: string | (() => string); } @@ -39,9 +41,10 @@ export function createSettingsResource( const loadError = computed(() => !!resource.error()); effect(() => { - if (resource.error()) { + const err = resource.error(); + if (err) { const message = options.errorMessage; - toast.error(typeof message === 'function' ? untracked(message) : message); + toast.error(`${typeof message === 'function' ? untracked(message) : message}: ${err.message}`); } }); @@ -60,3 +63,33 @@ export function createSettingsResource( retry: () => resource.reload(), }; } + +export interface SettingsSaveOptions { + request: Observable; + toast: ToastService; + saving: WritableSignal; + saved: WritableSignal; + successMessage: string; + onSaved: () => void; +} + +/** + * Runs a settings save: flags `saving`, flashes `saved` and toasts the outcome. + */ +export function saveSettings(options: SettingsSaveOptions): void { + const { request, toast, saving, saved } = options; + saving.set(true); + request.subscribe({ + next: () => { + toast.success(options.successMessage); + saving.set(false); + saved.set(true); + setTimeout(() => saved.set(false), SAVED_FLASH_MS); + options.onSaved(); + }, + error: (err: ApiError) => { + toast.error(err.message); + saving.set(false); + }, + }); +}