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);
+ },
+ });
+}