Files
Cleanuparr/code/frontend/src/app/features/logs-component/logs.component.spec.ts
T

268 lines
8.8 KiB
TypeScript

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';
function entry(timestamp: string, partial: Partial<LogEntry> = {}): LogEntry {
return {
level: 'Information',
message: 'message',
...partial,
timestamp: new Date(timestamp),
};
}
const LOGS: LogEntry[] = [
entry('2026-07-30T10:00:00Z', {
level: 'Information',
message: 'Queue scan finished',
category: 'QueueCleaner',
jobName: 'QueueCleaner',
jobRunId: 'run-a',
}),
entry('2026-07-30T12:00:00Z', {
level: 'ERROR',
message: 'Connection refused',
category: 'Sonarr',
exception: 'SocketException: refused',
instanceName: 'Sonarr Main',
jobName: 'MalwareBlocker',
jobRunId: 'run-b',
}),
entry('2026-07-30T11:00:00Z', {
level: 'Error',
message: 'Torrent stalled',
category: 'QueueCleaner',
downloadClientType: 'qBittorrent',
downloadClientName: 'Seedbox',
jobRunId: 'run-a',
}),
];
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[]>; 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({
providers: [
{
provide: AppHubService,
useValue: {
logs: logsSignal,
isConnected: signal(true),
clearLogs: () => undefined,
requestRecentLogs: () => undefined,
},
},
{
provide: ActivatedRoute,
useValue: { snapshot: { queryParamMap: convertToParamMap(queryParams) } },
},
{
provide: ToastService,
useValue: { success: toastSuccessSpy, error: toastErrorSpy },
},
],
});
const fixture = TestBed.createComponent(LogsComponent);
fixture.detectChanges();
return { fixture, logsSignal, toastSuccess: toastSuccessSpy, toastError: toastErrorSpy };
}
function messages(fixture: ComponentFixture<LogsComponent>): string[] {
return fixture.componentInstance.filteredLogs().map((log) => log.message);
}
it('returns every log sorted newest first when no filter is active', () => {
const { fixture } = setup();
expect(messages(fixture)).toEqual(['Connection refused', 'Torrent stalled', 'Queue scan finished']);
});
it('matches the level filter regardless of the casing stored on the log', () => {
const { fixture } = setup();
fixture.componentInstance.selectedLevel.set('error');
fixture.detectChanges();
expect(messages(fixture)).toEqual(['Connection refused', 'Torrent stalled']);
});
it('searches the message and the other searched fields case-insensitively without failing on absent fields', () => {
const { fixture } = setup();
const component = fixture.componentInstance;
component.searchQuery.set('STALLED');
fixture.detectChanges();
expect(messages(fixture)).toEqual(['Torrent stalled']);
component.searchQuery.set('socketexception');
fixture.detectChanges();
expect(messages(fixture)).toEqual(['Connection refused']);
component.searchQuery.set('sonarr main');
fixture.detectChanges();
expect(messages(fixture)).toEqual(['Connection refused']);
component.searchQuery.set('SEEDBOX');
fixture.detectChanges();
expect(messages(fixture)).toEqual(['Torrent stalled']);
component.searchQuery.set('qbittorrent');
fixture.detectChanges();
expect(messages(fixture)).toEqual(['Torrent stalled']);
component.searchQuery.set('run-a');
fixture.detectChanges();
expect(messages(fixture)).toEqual(['Torrent stalled', 'Queue scan finished']);
});
it('searches the raw job name and the display name shown on the row', () => {
const { fixture } = setup();
const component = fixture.componentInstance;
component.searchQuery.set('malwareblocker');
fixture.detectChanges();
expect(messages(fixture)).toEqual(['Connection refused']);
component.searchQuery.set('malware blocker');
fixture.detectChanges();
expect(messages(fixture)).toEqual(['Connection refused']);
component.searchQuery.set('queue cleaner');
fixture.detectChanges();
expect(messages(fixture)).toEqual(['Queue scan finished']);
});
it('narrows the result when several filters are active at once', () => {
const { fixture } = setup();
const component = fixture.componentInstance;
component.selectedCategory.set('QueueCleaner');
fixture.detectChanges();
expect(messages(fixture)).toEqual(['Torrent stalled', 'Queue scan finished']);
component.selectedLevel.set('error');
fixture.detectChanges();
expect(messages(fixture)).toEqual(['Torrent stalled']);
component.searchQuery.set('connection');
fixture.detectChanges();
expect(messages(fixture)).toEqual([]);
});
it('pre-filters on the job run id taken from the route query parameters', () => {
const { fixture } = setup(LOGS, { jobRunId: 'run-b' });
expect(fixture.componentInstance.selectedJobRunId()).toBe('run-b');
expect(messages(fixture)).toEqual(['Connection refused']);
fixture.componentInstance.clearJobRunFilter();
fixture.detectChanges();
expect(messages(fixture)).toHaveLength(3);
});
it('lists deduped sorted categories behind an all-categories entry and skips empty ones', () => {
const { fixture, logsSignal } = setup();
logsSignal.set([
...LOGS,
entry('2026-07-30T13:00:00Z', { category: 'Arr' }),
entry('2026-07-30T14:00:00Z', { category: '' }),
entry('2026-07-30T15:00:00Z'),
]);
fixture.detectChanges();
expect(fixture.componentInstance.categoryOptions()).toEqual([
{ label: 'All Categories', value: '' },
{ label: 'Arr', value: 'Arr' },
{ label: 'QueueCleaner', value: 'QueueCleaner' },
{ 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');
});
});