diff --git a/web/src/screens/ChannelEditScreen.test.tsx b/web/src/screens/ChannelEditScreen.test.tsx index 74a6e3b75..9d64a671a 100644 --- a/web/src/screens/ChannelEditScreen.test.tsx +++ b/web/src/screens/ChannelEditScreen.test.tsx @@ -552,7 +552,7 @@ describe('ChannelEditScreen', () => { expect(preview.style.top).toBe(''); }); - it('does not render the preview when an external logo URL is set', async () => { + it('does not offer the stale "external URL cannot drive the bug" copy', async () => { mockApi({ channelOverrides: { logo: { path: 'iptv/logos/cartoons.png', contentType: 'image/png' }, @@ -565,15 +565,116 @@ describe('ChannelEditScreen', () => { await screen.findByDisplayValue('Cartoons'); fireEvent.click(screen.getByRole('button', { name: /^Branding/ })); - // Preview renders from the uploaded logo first. - await screen.findByAltText('On-screen bug preview'); + const urlInput = screen.getByPlaceholderText('https://example.com/logo.png'); + fireEvent.change(urlInput, { target: { value: 'https://example.com/new-logo.png' } }); + + // #525: a saved external URL is downloaded server-side and comes back as a normal cached + // logo, so the bug preview works fine for it — the old blanket "cannot be used" claim is gone. + expect(screen.queryByText(/cannot be used as the on-screen bug/i)).toBeNull(); + }); + + it('renders the bug preview for a logo that was saved via an external URL', async () => { + const puts: unknown[] = []; + mockApi({ + channelOverrides: { + logo: { path: '', contentType: '' }, + watermarkId: 9 + }, + watermarks: [{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }], + onPut: (body) => puts.push(body), + // The PUT response models what the backend now does (#525): the external URL was + // downloaded and cached server-side, so the saved channel comes back with a normal, + // non-external cached logo rather than the URL itself. + putResponseOverrides: { + logo: { path: 'iptv/logos/downloaded.png', contentType: 'image/png', isExternalUrl: false }, + watermarkId: 9 + } + }); + render(); + + await screen.findByDisplayValue('Cartoons'); + fireEvent.click(screen.getByRole('button', { name: /^Branding/ })); const urlInput = screen.getByPlaceholderText('https://example.com/logo.png'); fireEvent.change(urlInput, { target: { value: 'https://example.com/new-logo.png' } }); - // An external URL can never resolve to a real bug (see the comment on the WatermarkSelector - // File.Exists behavior), so the preview must disappear once one is set. - await waitFor(() => expect(screen.queryByAltText('On-screen bug preview')).toBeNull()); + const saveButton = await screen.findByRole('button', { name: 'Save changes' }); + fireEvent.click(saveButton); + + await waitFor(() => expect(puts).toHaveLength(1)); + expect(await screen.findByText('Channel saved')).toBeInTheDocument(); + + const preview = await screen.findByAltText('On-screen bug preview'); + expect(preview).toHaveAttribute('src', 'iptv/logos/downloaded.png'); + + // The URL field is cleared post-save since the channel's logo is no longer external. + expect(screen.getByPlaceholderText('https://example.com/logo.png')).toHaveValue(''); + }); + + it('shows a 400 save error inline near the URL field and does not navigate away', async () => { + mockApi({ + channelOverrides: { logo: { path: '', contentType: '' } } + }); + vi.spyOn(window, 'fetch').mockImplementation((input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url; + const method = (init?.method ?? 'GET').toUpperCase(); + + if (url === '/api/v1/channels/5' && method === 'PUT') { + return Promise.resolve( + json({ status: 400, title: 'Bad Request', detail: 'Could not download logo: host timed out' }, 400) + ); + } + + if (url === '/api/v1/channels/5') { + return Promise.resolve(json({ ...channel, logo: { path: '', contentType: '' } })); + } + + if (url === '/api/v1/ffmpeg/profiles') { + return Promise.resolve(json([{ id: 1, name: 'Default profile' }])); + } + + if (url === '/api/v1/watermarks') { + return Promise.resolve(json([{ id: 2, name: 'Corner bug', imageSource: 'Custom' }])); + } + + if (url === '/api/v1/filler-presets') { + return Promise.resolve(json([{ id: 3, name: 'Bumpers' }])); + } + + if (url === '/api/v1/channels') { + return Promise.resolve(json([{ id: 5, number: '5', name: 'Cartoons', group: 'ChicoryTV' }])); + } + + if (url === '/api/v1/languages') { + return Promise.resolve(json([{ code: 'eng', englishName: 'English' }])); + } + + if (url === '/api/v1/channels/music-video-credits-templates') { + return Promise.resolve(json(['default'])); + } + + if (url === '/api/v1/channels/stream-selectors') { + return Promise.resolve(json(['selector.py'])); + } + + return Promise.resolve(json({ status: 404, title: 'Not Found' }, 404)); + }); + render(); + + await screen.findByDisplayValue('Cartoons'); + fireEvent.click(screen.getByRole('button', { name: /^Branding/ })); + + const urlInput = screen.getByPlaceholderText('https://example.com/logo.png'); + fireEvent.change(urlInput, { target: { value: 'https://example.com/bad-logo.png' } }); + + const saveButton = await screen.findByRole('button', { name: 'Save changes' }); + fireEvent.click(saveButton); + + expect(await screen.findByText('Could not download logo: host timed out')).toBeInTheDocument(); + + // Still on the edit screen, with the failed URL still in the field. + expect(screen.getByPlaceholderText('https://example.com/logo.png')).toHaveValue('https://example.com/bad-logo.png'); + expect(screen.queryByText('Channel saved')).toBeNull(); }); }); }); diff --git a/web/src/screens/ChannelEditScreen.tsx b/web/src/screens/ChannelEditScreen.tsx index f4e9831c9..b9fb1f343 100644 --- a/web/src/screens/ChannelEditScreen.tsx +++ b/web/src/screens/ChannelEditScreen.tsx @@ -679,9 +679,6 @@ function BrandingPane({ const referenced = data.watermarks.find((watermark) => watermark.id === draft.watermarkId) ?? null; const logoBugEnabled = referenced?.imageSource === 'ChannelLogo'; const logoBugTarget = findLogoBugWatermark(data.watermarks); - // An external-URL logo is resolved by WatermarkSelector to the URL itself and then File.Exists-ed, - // which is never true, so no bug renders (#502). Don't promise one in the preview. - const externalUrlLogo = trimmedUrl.length > 0; // Keyed by the watermark id it was fetched for, so a stale response (or a disabled toggle) is // filtered out by comparing against the CURRENT draft.watermarkId at render time — no reset @@ -797,9 +794,7 @@ function BrandingPane({ help={ logoBugTarget == null ? 'No logo-driven watermark preset exists yet.' - : externalUrlLogo - ? 'An external logo URL cannot be used as the on-screen bug — upload an image instead.' - : 'Overlays this channel’s own logo on the stream, using the shared preset’s position and size.' + : 'Overlays this channel’s own logo on the stream, using the shared preset’s position and size.' } label="Use logo as on-screen bug" > @@ -814,7 +809,7 @@ function BrandingPane({ } size="sm" /> - {logoBugEnabled && bugGeometry && previewSrc && !externalUrlLogo && ( + {logoBugEnabled && bugGeometry && previewSrc && ( // bugGeometry is cached keyed by `id` (see fetchedGeometry above); pass the // geometry fields explicitly rather than spreading so the cache key never leaks // into BugPreview's props.