diff --git a/Cargo.lock b/Cargo.lock index 200f5a35..a6707ae2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -102,7 +102,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -113,7 +113,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -681,7 +681,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -735,7 +735,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1753,7 +1753,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1811,7 +1811,7 @@ dependencies = [ [[package]] name = "nzb-dispatch" -version = "0.2.8" +version = "0.2.9" dependencies = [ "anyhow", "arc-swap", @@ -2460,7 +2460,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2765,7 +2765,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3130,7 +3130,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3256,7 +3256,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4005,7 +4005,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9df3672d..48b02936 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,7 +93,7 @@ nzb-nntp = { version = "0.2.24", path = "crates/nzb-nntp" } nzb-core = { version = "0.2.19", path = "crates/nzb-core", features = ["groups-db"] } nzb-decode = { version = "0.1.5", path = "crates/nzb-decode" } nzb-news = { version = "0.1.14", path = "crates/nzb-news" } -nzb-dispatch = { version = "0.2.8", path = "crates/nzb-dispatch" } +nzb-dispatch = { version = "0.2.9", path = "crates/nzb-dispatch" } nzb-postproc = { version = "0.2.8", path = "crates/nzb-postproc" } mock-nntp-server = { path = "crates/mock-nntp-server" } rust-par2 = { version = "0.1.3" } diff --git a/apps/rustnzb/Cargo.toml b/apps/rustnzb/Cargo.toml index cc9412a9..07e9a827 100644 --- a/apps/rustnzb/Cargo.toml +++ b/apps/rustnzb/Cargo.toml @@ -78,8 +78,8 @@ assets = [ reqwest = { workspace = true, features = ["multipart", "json"] } serde_json = { workspace = true } axum = { workspace = true } -tempfile = "3" -arc-swap.workspace = true + tempfile = "3" + arc-swap.workspace = true nzb-nntp = { workspace = true, features = ["test-support"] } crc32fast = { workspace = true } mock-nntp-server = { workspace = true } diff --git a/apps/rustnzb/config.example.toml b/apps/rustnzb/config.example.toml index ad665b50..47d0f5fb 100644 --- a/apps/rustnzb/config.example.toml +++ b/apps/rustnzb/config.example.toml @@ -48,6 +48,9 @@ script_max_output_bytes = 1048576 # fetch_allow_private = false # fetch_allowed_hosts = ["prowlarr", "192.168.1.0/24", "172.16.0.0/12"] +# Most headers kept per group when fetching (oldest pruned first). 0 keeps all. +# group_max_headers = 100000 + # NNTP servers — add as many as needed, ordered by priority # Use the web UI "Servers" tab to add/edit servers, or uncomment below. # Only `host` is required. Omitted fields default to: port 563, ssl = true, diff --git a/apps/rustnzb/frontend/angular.json b/apps/rustnzb/frontend/angular.json index 22ea53df..bb60c099 100644 --- a/apps/rustnzb/frontend/angular.json +++ b/apps/rustnzb/frontend/angular.json @@ -57,6 +57,14 @@ }, "configurations": { "production": { + "optimization": { + "scripts": true, + "styles": { + "minify": true, + "inlineCritical": false + }, + "fonts": true + }, "budgets": [ { "type": "initial", diff --git a/apps/rustnzb/frontend/src/app/app.spec.ts b/apps/rustnzb/frontend/src/app/app.spec.ts index 591230f0..56c7550d 100644 --- a/apps/rustnzb/frontend/src/app/app.spec.ts +++ b/apps/rustnzb/frontend/src/app/app.spec.ts @@ -87,4 +87,10 @@ describe('bare route detection', () => { expect(isBareRoute('/settings')).toBe(false); expect(isBareRoute('/welcomes')).toBe(false); }); + + it('formats sizes and speeds beyond TB without "undefined" (BUG-120)', () => { + const { app } = makeApp(); + expect(app.formatBytes(1.1 * 1024 ** 5)).toBe('1.1 PB'); + expect(app.formatSpeed(2 * 1024 ** 4)).toBe('2.0 TB/s'); + }); }); diff --git a/apps/rustnzb/frontend/src/app/app.ts b/apps/rustnzb/frontend/src/app/app.ts index b4558cba..cee981fb 100644 --- a/apps/rustnzb/frontend/src/app/app.ts +++ b/apps/rustnzb/frontend/src/app/app.ts @@ -16,6 +16,7 @@ import { filter } from 'rxjs'; import { ApiService } from './core/services/api.service'; import { AuthService } from './core/services/auth.service'; import { StatusResponse } from './core/models/queue.model'; +import { formatBytes, formatSpeed } from './core/format'; import { AddNzbService } from './core/services/add-nzb.service'; import { WidthModeService } from './core/services/width-mode.service'; import { PauseStateService } from './core/services/pause-state.service'; @@ -547,18 +548,10 @@ export class App implements OnInit, OnDestroy { } formatSpeed(bps: number): string { - if (bps === 0) return '0 B/s'; - const k = 1024; - const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']; - const i = Math.floor(Math.log(bps) / Math.log(k)); - return parseFloat((bps / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + return formatSpeed(bps); } formatBytes(bytes: number): string { - if (!bytes) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + return formatBytes(bytes); } } diff --git a/apps/rustnzb/frontend/src/app/core/format.spec.ts b/apps/rustnzb/frontend/src/app/core/format.spec.ts new file mode 100644 index 00000000..bc7b8369 --- /dev/null +++ b/apps/rustnzb/frontend/src/app/core/format.spec.ts @@ -0,0 +1,46 @@ +import { formatBytes, formatBytesParts, formatSpeed } from './format'; + +describe('formatBytes', () => { + it('walks the unit ladder up to EB (BUG-120)', () => { + expect(formatBytes(0)).toBe('0 B'); + expect(formatBytes(1)).toBe('1 B'); + expect(formatBytes(1023)).toBe('1023 B'); + expect(formatBytes(1024)).toBe('1.0 KB'); + expect(formatBytes(1024 ** 2)).toBe('1.0 MB'); + expect(formatBytes(1024 ** 3)).toBe('1.0 GB'); + expect(formatBytes(1024 ** 4)).toBe('1.0 TB'); + expect(formatBytes(1024 ** 5)).toBe('1.0 PB'); + expect(formatBytes(1024 ** 6)).toBe('1.0 EB'); + }); + + it('clamps at EB instead of returning undefined (BUG-120)', () => { + expect(formatBytes(4096 * 1024 ** 6)).toBe('4096.0 EB'); + }); + + it('promotes when a value rounds up to the next unit', () => { + expect(formatBytes(1024 ** 2 - 1)).toBe('1024.0 KB'); + expect(formatBytes(1024 ** 3 - 1)).toBe('1024.0 MB'); + }); + + it('treats non-finite and non-positive sizes as zero', () => { + expect(formatBytes(Number.NaN)).toBe('0 B'); + expect(formatBytes(Number.POSITIVE_INFINITY)).toBe('0 B'); + expect(formatBytes(Number.NEGATIVE_INFINITY)).toBe('0 B'); + expect(formatBytes(-5)).toBe('0 B'); + expect(formatBytes(null)).toBe('0 B'); + expect(formatBytes(undefined)).toBe('0 B'); + }); + + it('splits value and unit for template interpolation', () => { + expect(formatBytesParts(1536)).toEqual({ value: '1.5', unit: 'KB' }); + }); +}); + +describe('formatSpeed', () => { + it('appends /s to the formatted size', () => { + expect(formatSpeed(0)).toBe('0 B/s'); + expect(formatSpeed(1024)).toBe('1.0 KB/s'); + expect(formatSpeed(5 * 1024 ** 4)).toBe('5.0 TB/s'); + expect(formatSpeed(Number.NaN)).toBe('0 B/s'); + }); +}); diff --git a/apps/rustnzb/frontend/src/app/core/format.ts b/apps/rustnzb/frontend/src/app/core/format.ts new file mode 100644 index 00000000..f5e89a54 --- /dev/null +++ b/apps/rustnzb/frontend/src/app/core/format.ts @@ -0,0 +1,36 @@ +/** + * Shared byte-size and speed formatting. + * + * Angular templates cannot see plain functions, so components historically + * grew private copies of this logic. Centralising it here keeps the ladder + * consistent across the app and fixes sizes beyond TB rendering as + * "undefined" (BUG-120). + */ + +const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'] as const; + +export interface FormattedSize { + value: string; + unit: string; +} + +export function formatBytesParts(bytes: number | null | undefined): FormattedSize { + const n = Number(bytes); + if (!Number.isFinite(n) || n <= 0) { + return { value: '0', unit: 'B' }; + } + if (n < 1024) { + return { value: String(Math.round(n)), unit: 'B' }; + } + let i = Math.min(UNITS.length - 1, Math.floor(Math.log(n) / Math.log(1024))); + return { value: (n / 1024 ** i).toFixed(1), unit: UNITS[i] }; +} + +export function formatBytes(bytes: number | null | undefined): string { + const parts = formatBytesParts(bytes); + return `${parts.value} ${parts.unit}`; +} + +export function formatSpeed(bytesPerSecond: number | null | undefined): string { + return `${formatBytes(bytesPerSecond)}/s`; +} diff --git a/apps/rustnzb/frontend/src/app/core/interceptors/auth.interceptor.spec.ts b/apps/rustnzb/frontend/src/app/core/interceptors/auth.interceptor.spec.ts new file mode 100644 index 00000000..e1aa7b97 --- /dev/null +++ b/apps/rustnzb/frontend/src/app/core/interceptors/auth.interceptor.spec.ts @@ -0,0 +1,79 @@ +import '@angular/compiler'; + +import { HttpErrorResponse, HttpRequest, HttpResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { firstValueFrom, of, throwError } from 'rxjs'; + +import { AuthService } from '../services/auth.service'; +import { authInterceptor } from './auth.interceptor'; + +function setup(token = 'access-1') { + const auth = { + getAccessToken: vi.fn(() => token), + refresh: vi.fn(() => of({ access_token: 'access-2' })), + clearTokens: vi.fn(), + }; + const router = { navigate: vi.fn() }; + TestBed.configureTestingModule({ + providers: [ + { provide: AuthService, useValue: auth }, + { provide: Router, useValue: router }, + ], + }); + const run = (req: HttpRequest, next: (r: HttpRequest) => unknown) => + firstValueFrom( + TestBed.runInInjectionContext(() => authInterceptor(req, next as never)), + ); + return { auth, router, run }; +} + +const unauthorized = (url: string) => + throwError(() => new HttpErrorResponse({ status: 401, url })); + +describe('authInterceptor', () => { + afterEach(() => TestBed.resetTestingModule()); + + it('refreshes and retries API requests that get a 401', async () => { + const { auth, run } = setup(); + const next = vi + .fn() + .mockReturnValueOnce(unauthorized('/api/queue')) + .mockReturnValueOnce(of(new HttpResponse({ status: 200 }))); + + await run(new HttpRequest('GET', '/api/queue'), next); + + expect(auth.refresh).toHaveBeenCalledTimes(1); + expect(next.mock.calls[1][0].headers.get('Authorization')).toBe('Bearer access-2'); + }); + + it.each([ + `${window.location.origin}/dav/content`, + '/dav/content/Release/', + '/dav', + ])('never spends a refresh token on a 401 from WebDAV (%s)', async (url) => { + const { auth, router, run } = setup(); + const next = vi.fn((_req: HttpRequest) => unauthorized(url)); + + await expect(run(new HttpRequest('GET', url), next)).rejects.toMatchObject({ status: 401 }); + + expect(next).toHaveBeenCalledTimes(1); + expect(next.mock.calls[0]![0].headers.get('Authorization')).toBe('Bearer access-1'); + expect(auth.refresh).not.toHaveBeenCalled(); + expect(auth.clearTokens).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('treats /api/dav/... as a normal API request', async () => { + const { auth, run } = setup(); + const next = vi + .fn() + .mockReturnValueOnce(unauthorized('/api/dav/status')) + .mockReturnValueOnce(of(new HttpResponse({ status: 200 }))); + + await run(new HttpRequest('GET', '/api/dav/status'), next); + + expect(auth.refresh).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/rustnzb/frontend/src/app/core/interceptors/auth.interceptor.ts b/apps/rustnzb/frontend/src/app/core/interceptors/auth.interceptor.ts index f29f72de..0e5a8423 100644 --- a/apps/rustnzb/frontend/src/app/core/interceptors/auth.interceptor.ts +++ b/apps/rustnzb/frontend/src/app/core/interceptors/auth.interceptor.ts @@ -1,13 +1,28 @@ import { HttpInterceptorFn, HttpErrorResponse, HttpRequest } from '@angular/common/http'; import { inject } from '@angular/core'; import { Router } from '@angular/router'; -import { catchError, switchMap, throwError } from 'rxjs'; -import { AuthService } from '../services/auth.service'; +import { catchError, of, switchMap, throwError } from 'rxjs'; +import { AuthService, TokenResponse } from '../services/auth.service'; function withToken(req: HttpRequest, token: string): HttpRequest { return req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); } +/** + * The WebDAV mount (`/dav`, not `/api/dav/...`). It accepts the session token + * but sits outside the API's refresh flow, so a 401 there must never rotate + * the single-use refresh token or bounce the user to the login page. + */ +function isWebDavRequest(url: string): boolean { + let path: string; + try { + path = new URL(url, window.location.origin).pathname; + } catch { + return false; + } + return path === '/dav' || path.startsWith('/dav/'); +} + export const authInterceptor: HttpInterceptorFn = (req, next) => { // Don't intercept auth endpoints if (req.url.includes('/api/auth/')) { @@ -24,7 +39,7 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => { return next(req).pipe( catchError((error: HttpErrorResponse) => { - if (error.status !== 401) { + if (error.status !== 401 || isWebDavRequest(req.url)) { return throwError(() => error); } @@ -39,6 +54,14 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => { // parallel page loads all recover instead of only the first request. return authService.refresh().pipe( catchError((refreshError) => { + // Another tab may have rotated the tokens after ours failed; only a + // session nobody has replaced is discarded. + if (!authService.discardFailedSession()) { + const current = authService.getAccessToken(); + if (current) { + return of({ access_token: current } as TokenResponse); + } + } authService.clearTokens(); router.navigate(['/login']); return throwError(() => refreshError); diff --git a/apps/rustnzb/frontend/src/app/core/security.spec.ts b/apps/rustnzb/frontend/src/app/core/security.spec.ts index 58ad5582..ea556862 100644 --- a/apps/rustnzb/frontend/src/app/core/security.spec.ts +++ b/apps/rustnzb/frontend/src/app/core/security.spec.ts @@ -52,6 +52,7 @@ describe('authInterceptor', () => { getAccessToken: vi.fn(() => token), refresh: vi.fn(() => refreshResult), clearTokens: vi.fn(), + discardFailedSession: vi.fn(() => true), setToken: (t: string | null) => (token = t), }; const router = { navigate: vi.fn(() => Promise.resolve(true)) }; @@ -154,7 +155,22 @@ describe('authInterceptor', () => { await expect( firstValueFrom(intercept(new HttpRequest('GET', '/api/queue'), next as HttpHandlerFn)), ).rejects.toMatchObject({ status: 403 }); - expect(auth.clearTokens).toHaveBeenCalledTimes(1); + expect(auth.discardFailedSession).toHaveBeenCalledTimes(1); expect(router.navigate).toHaveBeenCalledWith(['/login']); }); + + it('retries with tokens another tab stored when its own refresh failed (BUG-123)', async () => { + const { auth, router } = configure( + throwError(() => new HttpErrorResponse({ status: 403 })), + ); + auth.discardFailedSession.mockReturnValue(false); + auth.setToken('tab-b-access'); + const next = vi.fn().mockReturnValueOnce(unauthorized()).mockReturnValueOnce(ok()); + + await firstValueFrom(intercept(new HttpRequest('GET', '/api/queue'), next as HttpHandlerFn)); + + expect(authHeader(next, 1)).toBe('Bearer tab-b-access'); + expect(auth.clearTokens).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + }); }); diff --git a/apps/rustnzb/frontend/src/app/core/services/auth.service.ts b/apps/rustnzb/frontend/src/app/core/services/auth.service.ts index 6b320677..d4fdab69 100644 --- a/apps/rustnzb/frontend/src/app/core/services/auth.service.ts +++ b/apps/rustnzb/frontend/src/app/core/services/auth.service.ts @@ -1,6 +1,6 @@ import { Injectable, computed, signal } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; -import { Observable, catchError, finalize, map, of, shareReplay, tap, throwError } from 'rxjs'; +import { Observable, catchError, filter, finalize, fromEvent, map, of, shareReplay, take, tap, throwError, timeout } from 'rxjs'; export interface AuthStatus { auth_enabled: boolean; @@ -19,6 +19,9 @@ const REFRESH_KEY = 'refresh_token'; const EXPIRES_KEY = 'access_token_expires_at'; // Refresh slightly early so a token never expires between check and use. const EXPIRY_SKEW_MS = 30_000; +// How long to wait for another tab to store tokens rotated with our spent +// refresh token. +const CROSS_TAB_REFRESH_GRACE_MS = 5_000; @Injectable({ providedIn: 'root' }) export class AuthService { @@ -32,6 +35,7 @@ export class AuthService { readonly authenticated = computed(() => !!this.accessToken() && this.verified()); private refresh$: Observable | null = null; + private lastSpentRefresh: string | null = null; private verify$: Observable | null = null; constructor(private http: HttpClient) {} @@ -59,10 +63,12 @@ export class AuthService { refresh(): Observable { if (!this.refresh$) { const refreshToken = localStorage.getItem(REFRESH_KEY); + this.lastSpentRefresh = refreshToken; this.refresh$ = this.http .post(`${this.baseUrl}/refresh`, { refresh_token: refreshToken }) .pipe( tap((res) => this.storeTokens(res)), + catchError((err) => this.adoptTokensRotatedElsewhere(refreshToken ?? '', err)), finalize(() => (this.refresh$ = null)), shareReplay({ bufferSize: 1, refCount: false }), ); @@ -70,6 +76,20 @@ export class AuthService { return this.refresh$; } + /** + * Another tab may have rotated the tokens after our refresh failed; only + * discard a session nobody has replaced. + */ + discardFailedSession(): boolean { + const stored = localStorage.getItem(REFRESH_KEY); + if (stored && stored !== this.lastSpentRefresh && this.getAccessToken()) { + this.accessToken.set(stored); + return false; + } + this.clearTokens(); + return true; + } + logout(): Observable { const refreshToken = localStorage.getItem(REFRESH_KEY); this.clearTokens(); @@ -91,10 +111,12 @@ export class AuthService { this.verify$ = probe$.pipe( map(() => true), catchError((err) => { + const probed = this.getAccessToken(); const rejected = err instanceof HttpErrorResponse && (err.status === 401 || err.status === 403); - if (rejected) this.clearTokens(); - return of(!rejected); + const replaced = this.getAccessToken() !== probed; + if (rejected && !replaced) this.clearTokens(); + return of(!rejected || replaced); }), map((ok) => ok && this.isLoggedIn()), tap((ok) => this.verified.set(ok)), @@ -118,6 +140,33 @@ export class AuthService { return !!expiresAt && Date.now() >= expiresAt - EXPIRY_SKEW_MS; } + private adoptTokensRotatedElsewhere(spent: string, err: unknown): Observable { + if (!(err instanceof HttpErrorResponse && (err.status === 401 || err.status === 403))) { + return throwError(() => err); + } + const rotated = this.rotatedTokens(spent); + if (rotated) return of(rotated); + return fromEvent(window, 'storage').pipe( + map(() => this.rotatedTokens(spent)), + filter((t) => t !== null), + take(1), + timeout({ + first: CROSS_TAB_REFRESH_GRACE_MS, + with: () => throwError(() => err), + }), + ); + } + + private rotatedTokens(spent: string): TokenResponse | null { + const access = localStorage.getItem(ACCESS_KEY); + const refresh = localStorage.getItem(REFRESH_KEY); + if (!access || !refresh || refresh === spent) return null; + this.accessToken.set(access); + this.verified.set(true); + const expires_in = (Number(localStorage.getItem(EXPIRES_KEY)) - Date.now()) / 1000; + return { access_token: access, refresh_token: refresh, token_type: 'Bearer', expires_in }; + } + private storeTokens(res: TokenResponse): void { localStorage.setItem(ACCESS_KEY, res.access_token); localStorage.setItem(REFRESH_KEY, res.refresh_token); diff --git a/apps/rustnzb/frontend/src/app/features/groups/groups-view.component.spec.ts b/apps/rustnzb/frontend/src/app/features/groups/groups-view.component.spec.ts index c622bd75..1e8a5ac1 100644 --- a/apps/rustnzb/frontend/src/app/features/groups/groups-view.component.spec.ts +++ b/apps/rustnzb/frontend/src/app/features/groups/groups-view.component.spec.ts @@ -1,7 +1,7 @@ import '@angular/compiler'; -import { describe, expect, it, vi } from 'vitest'; -import { of, throwError } from 'rxjs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Subject, of, throwError } from 'rxjs'; import { GroupService } from '../../core/services/group.service'; import { GroupRow, HeaderRow } from '../../core/models/group.model'; @@ -68,4 +68,86 @@ describe('GroupsViewComponent', () => { expect(service.downloadSelected).toHaveBeenCalledWith(7, ['']); expect(snack.open).toHaveBeenCalledWith('Download failed', 'Close', { duration: 5000 }); }); + + it('recomputes the subscribed-group list when the name filter changes (BUG-116)', () => { + const other: GroupRow = { ...group, id: 8, name: 'alt.binaries.movies' }; + const { component } = makeComponent({ + list: vi.fn(() => of({ groups: [group, other], total: 2, limit: 500, offset: 0 })), + }); + component.ngOnInit(); + expect(component.filteredGroups().map((g) => g.id)).toEqual([7, 8]); + component.groupNameFilter.set('MOVIES'); + expect(component.filteredGroups().map((g) => g.id)).toEqual([8]); + component.groupNameFilter.set(''); + expect(component.filteredGroups().map((g) => g.id)).toEqual([7, 8]); + }); + + it('keeps earlier pages when headers are reloaded after "Load more" (BUG-117)', () => { + const page = (start: number, n: number): HeaderRow[] => + Array.from({ length: n }, (_, i) => ({ ...header, id: start + i, message_id: `<${start + i}>` })); + const listHeaders = vi.fn((_id: number, q: { limit: number; offset: number }) => + of({ headers: page(q.offset, Math.min(q.limit, 250 - q.offset)), total: 250, limit: q.limit, offset: q.offset }), + ); + const { component } = makeComponent({ listHeaders }); + component.selectGroup(group); + expect(component.headers().length).toBe(100); + component.loadMore(); + expect(component.headers().length).toBe(200); + // A later refresh (fetch poll, mark-all-read) must not collapse the list + // to just the most recent page. + component.loadHeaders(); + expect(component.headers().length).toBe(200); + expect(component.headers()[0].message_id).toBe('<0>'); + expect(new Set(component.headers().map((h) => h.message_id)).size).toBe(200); + component.markAllRead(); + expect(component.headers().length).toBe(200); + }); + + describe('header fetch polling (BUG-118)', () => { + afterEach(() => vi.useRealTimers()); + + it('stops polling and never shows the give-up toast after the view is destroyed', () => { + vi.useFakeTimers(); + const { component, service, snack } = makeComponent(); + component.selectGroup(group); + component.fetchHeaders(); + snack.open.mockClear(); + component.ngOnDestroy(); + const calls = service.listHeaders.mock.calls.length; + vi.advanceTimersByTime(130_000); + expect(service.listHeaders.mock.calls.length).toBe(calls); + expect(snack.open).not.toHaveBeenCalled(); + }); + + it('stops polling when the fetch request fails', () => { + vi.useFakeTimers(); + const fetch = new Subject(); + const { component, service, snack } = makeComponent({ + fetchHeaders: vi.fn(() => fetch.asObservable()), + }); + component.selectGroup(group); + component.fetchHeaders(); + fetch.error(new Error('no server')); + expect(component.fetching()).toBe(false); + const calls = service.listHeaders.mock.calls.length; + snack.open.mockClear(); + vi.advanceTimersByTime(130_000); + expect(service.listHeaders.mock.calls.length).toBe(calls); + expect(snack.open).not.toHaveBeenCalledWith( + expect.stringContaining('taking longer'), expect.anything(), expect.anything(), + ); + }); + + it('does not stack pollers when fetch is clicked twice', () => { + vi.useFakeTimers(); + const { component, service } = makeComponent(); + component.selectGroup(group); + component.fetchHeaders(); + component.fetchHeaders(); + const before = service.listHeaders.mock.calls.length; + vi.advanceTimersByTime(3000); + expect(service.listHeaders.mock.calls.length - before).toBe(1); + component.ngOnDestroy(); + }); + }); }); diff --git a/apps/rustnzb/frontend/src/app/features/groups/groups-view.component.ts b/apps/rustnzb/frontend/src/app/features/groups/groups-view.component.ts index 0b69b546..974666bf 100644 --- a/apps/rustnzb/frontend/src/app/features/groups/groups-view.component.ts +++ b/apps/rustnzb/frontend/src/app/features/groups/groups-view.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, signal, computed } from '@angular/core'; +import { Component, OnDestroy, OnInit, signal, computed } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; @@ -8,6 +8,7 @@ import { GroupRow, HeaderRow } from '../../core/models/group.model'; import { GroupBrowserDialogComponent } from './group-browser-dialog.component'; import { IconComponent } from '../../shared/icon.component'; import { showHttpError } from '../../core/http/http-error'; +import { formatBytes } from '../../core/format'; @Component({ selector: 'app-groups-view', @@ -44,7 +45,7 @@ import { showHttpError } from '../../core/http/http-error';