import { describe, expect, it, vi } from 'vitest'; import { extractNimbusApiKey, fetchUbisoftNewsThumbnail, fetchUbisoftNews, UbisoftNewsSourceError, } from './ubisoft-news-client'; const externalIdOne = '64f1234567890abcdef12345'; const externalIdTwo = '64f1234567890abcdef67890'; function landing() { return new Response( ``, { status: 200, headers: { 'content-type': 'text/html' } }, ); } function page(total: number, items: unknown[]) { return new Response(JSON.stringify({ total, items }), { status: 200, headers: { 'content-type': 'application/json' }, }); } function item(id: string, overrides: Record = {}) { return { id, title: 'Division Resurgence Update', abstract: 'Read the latest official update.', date: '2024-03-11T15:20:00.000Z', button: { buttonUrl: `/${id}/division-resurgence-update` }, thumbnail: { url: `https://staticctf.ubisoft.com/news/${id}/cover.jpg`, description: 'Division Resurgence key art', }, ...overrides, }; } describe('Ubisoft News client', () => { it('extracts the Nimbus key from the browser configuration', () => { expect( extractNimbusApiKey( ``, ), ).toBe('public-nimbus-key'); }); it('paginates, deduplicates overlapping items, and normalizes source dates', async () => { const fetchImpl = vi .fn() .mockResolvedValueOnce(landing()) .mockResolvedValueOnce(page(2, [item(externalIdTwo)])) .mockResolvedValueOnce( page(2, [ item(externalIdTwo), item(externalIdOne, { title: 'Earlier Update', date: '2024-01-05T08:00:00Z', button: { buttonUrl: `/${externalIdOne}/earlier-update` }, }), ]), ); const result = await fetchUbisoftNews(fetchImpl); expect(result.map((entry) => entry.externalId)).toEqual([ externalIdOne, externalIdTwo, ]); expect(result[0].publishedAt).toBe('2024-01-05T08:00:00.000Z'); expect(result[0].canonicalUrl).toBe( `https://www.ubisoft.com/en-us/game/the-division/resurgence/news-updates/${externalIdOne}/earlier-update`, ); expect(result[0].thumbnailUrl).toBe( `https://staticctf.ubisoft.com/news/${externalIdOne}/cover.jpg`, ); expect(result[0].thumbnailAlt).toBe('Division Resurgence key art'); expect(fetchImpl).toHaveBeenCalledTimes(3); const secondRequest = fetchImpl.mock.calls[1]; expect(String(secondRequest[0])).toContain('locale=en-us'); expect(secondRequest[1]?.headers).toMatchObject({ authorization: 'public-nimbus-key', }); expect(secondRequest[1]?.redirect).toBe('error'); }); it('rejects article links outside the approved Ubisoft path', async () => { const fetchImpl = vi .fn() .mockResolvedValueOnce(landing()) .mockResolvedValueOnce( page(1, [ item(externalIdOne, { button: { buttonUrl: `/${externalIdOne}-lookalike/not-the-same-item`, }, }), ]), ); await expect(fetchUbisoftNews(fetchImpl)).rejects.toBeInstanceOf( UbisoftNewsSourceError, ); }); it('rejects malformed source dates', async () => { const fetchImpl = vi .fn() .mockResolvedValueOnce(landing()) .mockResolvedValueOnce( page(1, [item(externalIdOne, { date: 'unknown' })]), ); await expect(fetchUbisoftNews(fetchImpl)).rejects.toThrow( 'Ubisoft returned an invalid news item.', ); }); it('rejects thumbnails outside Ubisoft image hosting', async () => { const fetchImpl = vi .fn() .mockResolvedValueOnce(landing()) .mockResolvedValueOnce( page(1, [ item(externalIdOne, { thumbnail: { url: 'https://example.com/cover.jpg', description: 'Untrusted image', }, }), ]), ); await expect(fetchUbisoftNews(fetchImpl)).rejects.toThrow( 'Ubisoft news thumbnail was outside the approved image host.', ); }); it('does not follow provider redirects', async () => { const fetchImpl = vi.fn().mockResolvedValueOnce( new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/internal' }, }), ); await expect(fetchUbisoftNews(fetchImpl)).rejects.toThrow( 'Ubisoft news request returned HTTP 302.', ); expect(fetchImpl.mock.calls[0][1]?.redirect).toBe('error'); }); it('fetches bounded raster thumbnails without following redirects', async () => { const bytes = new Uint8Array([1, 2, 3]); const fetchImpl = vi.fn().mockResolvedValueOnce( new Response(bytes, { headers: { 'content-type': 'image/jpeg; charset=binary' }, }), ); const image = await fetchUbisoftNewsThumbnail( 'https://staticctf.ubisoft.com/news/cover.jpg', fetchImpl, ); expect(image.contentType).toBe('image/jpeg'); expect(image.body).toEqual(bytes); expect(fetchImpl.mock.calls[0][1]?.redirect).toBe('error'); }); it('rejects thumbnail responses that are not raster images', async () => { const fetchImpl = vi.fn().mockResolvedValueOnce( new Response('', { headers: { 'content-type': 'image/svg+xml' }, }), ); await expect( fetchUbisoftNewsThumbnail( 'https://staticctf.ubisoft.com/news/cover.svg', fetchImpl, ), ).rejects.toThrow('not a supported image'); }); it('rejects thumbnails larger than the response bound', async () => { const fetchImpl = vi.fn().mockResolvedValueOnce( new Response(new Uint8Array([1]), { headers: { 'content-type': 'image/jpeg', 'content-length': String(5 * 1024 * 1024 + 1), }, }), ); await expect( fetchUbisoftNewsThumbnail( 'https://staticctf.ubisoft.com/news/large-cover.jpg', fetchImpl, ), ).rejects.toThrow('exceeded the size limit'); }); });