Standardize error toast messages (#820)

This commit is contained in:
Flaminel authored and GitHub committed 2026-09-30 02:12:29 +03:00
1 parent 044e7ae7d2
commit 87194cdb6b
64 files changed
+1281 -347

No files matched your search

@@ -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; }
}
@@ -0,0 +1,17 @@
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
/// <summary>
/// Result of polling a Plex PIN for authorization.
/// </summary>
public sealed record PlexVerifyResponse
{
/// <summary>
/// Whether the user has authorized the PIN on Plex.
/// </summary>
public required bool Completed { get; init; }
/// <summary>
/// Session tokens, issued only when a Plex login completes.
/// </summary>
public TokenResponse? Tokens { get; init; }
}
@@ -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>): 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');
});
});
@@ -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);
@@ -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<typeof vi.fn>;
}
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<DashboardComponent>): 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([]);
});
});
@@ -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<DashboardRowId[]>(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),
});
}
@@ -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<StatsCardComponent> {
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();
});
});
@@ -86,7 +86,7 @@ export class StatsCardComponent {
readonly hasError = computed(() => !!this.statsResource.error());
readonly tiles = computed<StatTile[]>(() => {
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()]);
@@ -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<EventsStatsCardComponent> {
function setup(options: { timeline?: EventTypeTimelineResponse; timelineError?: boolean } = {}): ComponentFixture<EventsStatsCardComponent> {
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([]);
});
});
@@ -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(() => {
@@ -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<EventsComponent>;
filters: EventFilter[];
toastError: ReturnType<typeof vi.fn>;
toastSuccess: ReturnType<typeof vi.fn>;
}
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>): 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<AppEvent>);
},
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');
});
});
@@ -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<SelectOption[]>(() => [
{ 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<SelectOption[]>(() => [
{ 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 {
@@ -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>): void {
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
});
}
function setup(
logs: LogEntry[] = LOGS,
queryParams: Record<string, string> = {},
): { fixture: ComponentFixture<LogsComponent>; logsSignal: WritableSignal<LogEntry[]> } {
): { fixture: ComponentFixture<LogsComponent>; logsSignal: WritableSignal<LogEntry[]>; toastSuccess: ReturnType<typeof vi.fn>; toastError: ReturnType<typeof vi.fn> } {
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<LogsComponent>): 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<void>((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');
});
});
@@ -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 {
@@ -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<typeof vi.fn>;
}
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();
});
});
@@ -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<SelectOption[]>(() => [
{ 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}`);
}
});
}
@@ -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<typeof vi.fn>;
}
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);
});
});
@@ -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<SelectOption[]>(() => {
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<SearchEventsQuery>(() => {
private readonly eventsParams = computed<SearchEventsQuery | undefined>(() => {
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<SearchEvent>,
});
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}`);
}
});
}
@@ -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<typeof vi.fn>;
}
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();
});
});
@@ -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<SelectOption[]>(() => [
{ 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}`);
}
});
}
@@ -84,11 +84,11 @@ describe('AccountSettingsComponent', () => {
getOidcConfig: () => of(options.oidc ?? OIDC),
updateOidcConfig: (config: Partial<OidcConfig>) => {
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);
});
});
@@ -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);
},
});
@@ -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']);
});
});
@@ -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);
},
});
@@ -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);
});
@@ -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);
},
});
@@ -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);
@@ -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);
},
});
@@ -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();
@@ -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);
},
});
@@ -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.']);
});
});
@@ -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);
},
});
@@ -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');
});
});
@@ -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),
});
}
@@ -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();
@@ -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),
});
}
@@ -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<DownloadCleanerComponent>;
component: DownloadCleanerComponent;
api: ReturnType<typeof createApi>;
toast: { success: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
confirm: ConfirmService;
}
@@ -112,6 +115,10 @@ function dropEvent(previousIndex: number, currentIndex: number): CdkDragDrop<See
return { previousIndex, currentIndex } as unknown as CdkDragDrop<SeedingRule[]>;
}
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<Setup> {
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);
});
});
@@ -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),
});
}
@@ -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', () => {
@@ -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);
},
});
@@ -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]);
});
});
@@ -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),
});
}
@@ -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();
@@ -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);
},
});
@@ -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();
@@ -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),
});
}
@@ -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);
@@ -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);
}
}
@@ -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();
@@ -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),
});
}
@@ -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<QueueCleanerComponent>;
component: QueueCleanerComponent;
api: ReturnType<typeof createApi>;
toast: { success: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
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([]);
});
});
@@ -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<StallRule | null>(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),
});
}
@@ -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);
},
});
@@ -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);
},
});
@@ -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', () => {
@@ -185,7 +185,7 @@ export class SeekerComponent implements HasPendingChanges {
this.settings.retry();
}
readonly confirmRoundRobin = async (newValue: boolean): Promise<boolean> => {
readonly confirmRoundRobin = (newValue: boolean): Promise<boolean> => {
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<InstanceState>): 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);
},
});
@@ -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<StrikesComponent>;
filters: StrikeFilter[];
deleted: string[];
toastError: ReturnType<typeof vi.fn>;
}
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();
});
});
@@ -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<SelectOption[]>(() => [
{ 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),
});
}
@@ -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');
});
});
@@ -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<void> {
const clipboard: Clipboard | undefined = navigator.clipboard;
if (!clipboard) {
return Promise.reject(new Error('Clipboard API unavailable'));
}
return clipboard.writeText(text);
}
@@ -1,11 +1,12 @@
import { DestroyRef } from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import { ApiError } from '@core/interceptors/error.interceptor';
export interface PlexPinPollOptions<T extends { completed: boolean }> {
/** Called on each attempt to check whether the Plex PIN has been authorized. */
verify: () => Observable<T>;
onCompleted: (result: T) => void;
onError: (error: unknown) => void;
onError: (error: ApiError) => void;
onTimeout: () => void;
destroyRef: DestroyRef;
intervalMs?: number;
@@ -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<unknown>) {
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);
});
});
@@ -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<T, P> {
/** Reactive params for the load call; re-runs the load whenever they change. */
params?: () => P;
load: (params: P) => Observable<T>;
/** 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<T, P = void>(
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<T, P = void>(
retry: () => resource.reload(),
};
}
export interface SettingsSaveOptions {
request: Observable<unknown>;
toast: ToastService;
saving: WritableSignal<boolean>;
saved: WritableSignal<boolean>;
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);
},
});
}