mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 02:38:55 -04:00
Standardize error toast messages (#820)
This commit is contained in:
1 parent
044e7ae7d2
commit
87194cdb6b
64 files changed
+1281
-347
No files matched your search
-6
@@ -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()]);
|
||||
|
||||
|
||||
+13
-5
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
+55
-3
@@ -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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+73
-3
@@ -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);
|
||||
});
|
||||
});
|
||||
+18
-10
@@ -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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+34
-3
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
+2
-2
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
+2
-2
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
+18
-4
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+54
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
+35
-55
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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', () => {
|
||||
|
||||
+2
-2
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
+38
-5
@@ -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]);
|
||||
});
|
||||
});
|
||||
+8
-7
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
+9
-18
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+5
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+41
-1
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user