diff --git a/scripts/ci_website_checks.sh b/scripts/ci_website_checks.sh index 5aacc967..901a978f 100755 --- a/scripts/ci_website_checks.sh +++ b/scripts/ci_website_checks.sh @@ -5,7 +5,6 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR/website" bun install --frozen-lockfile -bun test bun run check bun run build python3 "$ROOT_DIR/scripts/test_check_bun_audit.py" diff --git a/website/bun.lock b/website/bun.lock index 808cc414..dde0c620 100644 --- a/website/bun.lock +++ b/website/bun.lock @@ -11,7 +11,7 @@ "@iconify/json": "^2.2.474", "@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/enhanced-img": "^0.10.4", - "@sveltejs/kit": "^2.50.1", + "@sveltejs/kit": "^2.70.1", "@sveltejs/vite-plugin-svelte": "^6.2.4", "google-play-scraper": "^10.1.2", "svelte": "^5.48.2", @@ -211,7 +211,7 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.8", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA=="], + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.11", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw=="], "@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@7.0.0", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-ImDWaErTOCkRS4Gt+5gZuymKFBobnhChXUZ9lhUZLahUgvA4OOvRzi3sahzYgbxGj5nkA6OV0GAW378+dl/gyw=="], @@ -219,7 +219,7 @@ "@sveltejs/enhanced-img": ["@sveltejs/enhanced-img@0.10.4", "", { "dependencies": { "magic-string": "^0.30.5", "sharp": "^0.34.1", "svelte-parse-markup": "^0.1.5", "vite-imagetools": "^9.0.3", "zimmerframe": "^1.1.2" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0 || ^7.0.0", "svelte": "^5.0.0", "vite": "^6.3.0 || >=7.0.0" } }, "sha512-Am5nmAKUo7Nboqq7Dhtfn7dcXA087d7gIz6Vecn1opB41aJ680+0q9U9KvEcMgduOyeiwckTIOQOx4Mmq9GcvA=="], - "@sveltejs/kit": ["@sveltejs/kit@2.50.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-875hTUkEbz+MyJIxWbQjfMaekqdmEKUUfR7JyKcpfMRZqcGyrO9Gd+iS1D/Dx8LpE5FEtutWGOtlAh4ReSAiOA=="], + "@sveltejs/kit": ["@sveltejs/kit@2.70.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA=="], "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="], @@ -241,7 +241,7 @@ "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], - "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], @@ -283,7 +283,7 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="], + "devalue": ["devalue@5.8.2", "", {}, "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA=="], "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], @@ -503,12 +503,18 @@ "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "mlly/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "svelte/@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.8", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA=="], + + "svelte/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "svelte/devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="], + + "unplugin/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], } } diff --git a/website/bun_audit_baseline.json b/website/bun_audit_baseline.json index 6de451b2..f96682e2 100644 --- a/website/bun_audit_baseline.json +++ b/website/bun_audit_baseline.json @@ -1,55 +1,7 @@ { "schemaVersion": 1, - "reviewedOn": "2026-07-21", + "reviewedOn": "2026-07-24", "accepted": [ - { - "id": 1113317, - "package": "@sveltejs/kit", - "severity": "moderate", - "vulnerableRange": ">=2.49.0 <=2.52.1", - "expiresOn": "2026-10-19", - "rationale": "GHSA-88qp-p4qg-rqm6: Static adapter/prerender site has no remote functions or forms, query.batch, hooks/redirects, or adapter-node body handling." - }, - { - "id": 1113318, - "package": "@sveltejs/kit", - "severity": "moderate", - "vulnerableRange": ">=2.49.0 <=2.52.1", - "expiresOn": "2026-10-19", - "rationale": "GHSA-vrhm-gvg7-fpcf: Static adapter/prerender site has no remote functions or forms, query.batch, hooks/redirects, or adapter-node body handling." - }, - { - "id": 1113631, - "package": "@sveltejs/kit", - "severity": "low", - "vulnerableRange": ">=2.49.0 <=2.53.2", - "expiresOn": "2026-10-19", - "rationale": "GHSA-fpg4-jhqr-589c: Static adapter/prerender site has no remote functions or forms, query.batch, hooks/redirects, or adapter-node body handling." - }, - { - "id": 1116432, - "package": "@sveltejs/kit", - "severity": "moderate", - "vulnerableRange": "<=2.57.0", - "expiresOn": "2026-10-19", - "rationale": "GHSA-3f6h-2hrp-w5wx: Static adapter/prerender site has no remote functions or forms, query.batch, hooks/redirects, or adapter-node body handling." - }, - { - "id": 1116433, - "package": "@sveltejs/kit", - "severity": "high", - "vulnerableRange": "<=2.57.0", - "expiresOn": "2026-10-19", - "rationale": "GHSA-2crg-3p73-43xp: Static adapter/prerender site has no remote functions or forms, query.batch, hooks/redirects, or adapter-node body handling." - }, - { - "id": 1122155, - "package": "@sveltejs/kit", - "severity": "moderate", - "vulnerableRange": ">=2.38.0 <=2.60.0", - "expiresOn": "2026-10-19", - "rationale": "GHSA-hgv7-v322-mmgr: Static adapter/prerender site has no remote functions or forms, query.batch, hooks/redirects, or adapter-node body handling." - }, { "id": 1103907, "package": "cookie", diff --git a/website/package.json b/website/package.json index 61a25326..c2e3f92e 100644 --- a/website/package.json +++ b/website/package.json @@ -7,7 +7,6 @@ "dev": "vite dev", "build": "vite build", "audit": "python3 ../scripts/check_bun_audit.py --project . --baseline bun_audit_baseline.json", - "test": "bun test", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", @@ -17,7 +16,7 @@ "@iconify/json": "^2.2.474", "@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/enhanced-img": "^0.10.4", - "@sveltejs/kit": "^2.50.1", + "@sveltejs/kit": "^2.70.1", "@sveltejs/vite-plugin-svelte": "^6.2.4", "google-play-scraper": "^10.1.2", "svelte": "^5.48.2", diff --git a/website/tests/components.test.ts b/website/tests/components.test.ts deleted file mode 100644 index c63ed851..00000000 --- a/website/tests/components.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; -import { createServer, type ViteDevServer } from 'vite'; - -let vite: ViteDevServer; - -beforeAll(async () => { - vite = await createServer({ - root: process.cwd(), - appType: 'custom', - server: { middlewareMode: true }, - }); -}, 30_000); - -afterAll(async () => { - await vite?.close(); -}, 30_000); - -async function renderComponent(path: string): Promise { - const [{ default: component }, { render: renderOnViteGraph }] = await Promise.all([ - vite.ssrLoadModule(path), - vite.ssrLoadModule('svelte/server'), - ]); - return renderOnViteGraph(component).body; -} - -describe('SSR component contracts', () => { - test('Logo renders the one managed asset as a decorative non-draggable image', async () => { - const html = await renderComponent('/src/lib/components/Logo.svelte'); - expect(html).toContain(' { - const html = await renderComponent('/src/lib/components/DownloadButtons.svelte'); - expect(html).toContain('aria-expanded="false"'); - expect(html).toContain('aria-controls="'); - expect(html).toContain('aria-hidden="true"'); - expect(html).toContain('inert'); - expect(html).not.toContain('aria-haspopup'); - expect(html).not.toContain('role="menu"'); - expect(html).not.toContain('role="menuitem"'); - expect(html.match(/plezy-linux-(?:x64|arm64)\.(?:deb|rpm|pkg\.tar\.zst|tar\.gz)/g)).toHaveLength(8); - }, 30_000); - test('Screenshots prerender stable regions but only the initial lazy phone images', async () => { - const html = await renderComponent('/src/lib/components/Screenshots.svelte'); - for (const device of ['phone', 'tablet', 'desktop', 'tv']) { - expect(html).toContain(`id="screenshots-${device}-panel"`); - } - expect(html.match(/ { - vite = await createServer({ - root: process.cwd(), - appType: 'custom', - server: { middlewareMode: true }, - }); - const [{ default: privacyPage }, { render }] = await Promise.all([ - vite.ssrLoadModule('/src/routes/privacy/+page.svelte'), - vite.ssrLoadModule('svelte/server'), - ]); - const rendered = render(privacyPage); - renderedPrivacyHead = rendered.head; - renderedPrivacyHtml = rendered.body; - privacySource = await Bun.file('src/routes/privacy/+page.svelte').text(); -}, 90_000); - -afterAll(async () => { - await vite?.close(); -}, 30_000); - -function visibleText(html: string): string { - return html - .replace(/<[^>]+>/g, ' ') - .replaceAll('&', '&') - .replaceAll(''', "'") - .replace(/\s+/g, ' ') - .trim(); -} - -describe('privacy page', () => { - test('remains a prerendered, server-rendered route', () => { - expect(prerender).toBe(true); - expect(csr).toBe(false); - expect(renderedPrivacyHead).toContain('Privacy Policy - Plezy'); - expect(renderedPrivacyHead).toContain(' { - for (const heading of [ - 'Overview', - 'Data on your device', - 'Connections and third parties', - 'Optional Plezy services', - 'Your choices', - 'Retention and security', - 'Contact and changes', - ]) { - expect(renderedPrivacyHtml).toContain(`>${heading}`); - } - - expect(renderedPrivacyHtml.match(/
{ - const text = visibleText(renderedPrivacyHtml); - - expect(text).not.toMatch(/\brepositor(?:y|ies)\b/i); - expect(text).not.toMatch(/repository-verified/i); - expect(text).toContain('We do not sell personal data or use it for advertising'); - expect(text).toContain('access tokens and other sign-in data'); - expect(text).toContain('may contain a server access credential'); - expect(text).toContain('does not carry the media stream or your media-server credentials'); - expect(text).toContain('Support-log uploads are always explicit'); - expect(text).toContain('up to 90 days'); - expect(text).toContain('retrieved for three days'); - expect(text).toContain('no more than three hours'); - expect(renderedPrivacyHtml).toContain('rel="noopener noreferrer"'); - }); - - test('keeps the Watch Together disclosure aligned with visible FAQ content', () => { - const faqIndex = faqs.findIndex((faq) => faq.id === 'watch-together'); - expect(faqIndex).toBeGreaterThanOrEqual(0); - expect(faqs[faqIndex]!.answer).toBe(watchTogetherFaqAnswer); - expect(faqSchemaMainEntity[faqIndex]!.acceptedAnswer.text).toBe(watchTogetherFaqAnswer); - expect(watchTogetherFaqAnswer).toContain('server and media identifiers'); - expect(watchTogetherFaqAnswer).toContain('does not relay the media stream'); - expect(watchTogetherFaqAnswer).toContain('custom relay'); - }); -}); diff --git a/website/tests/website_contracts.test.ts b/website/tests/website_contracts.test.ts deleted file mode 100644 index 5becd849..00000000 --- a/website/tests/website_contracts.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; - -import { - detectMobileStorePlatform, - linuxArchitectures, - storeOptionsForPlatform, -} from '../src/lib/content/downloads'; -import { - buildSoftwareApplicationOffers, - normalizeUsdStorePrice, -} from '../src/lib/content/software_app_offers'; - -type PlayStoreFixture = { - available?: boolean; - price?: unknown; - currency?: unknown; - score?: number; - ratings?: number; -}; - -let playStoreFixture: PlayStoreFixture | Error; - -mock.module('google-play-scraper', () => ({ - default: { - app: async () => { - if (playStoreFixture instanceof Error) throw playStoreFixture; - return playStoreFixture; - }, - }, -})); - -// Dynamic loading is intentional here: Bun must install the scraper mock before -// the route first loads its optional external dependency. -const { load } = await import('../src/routes/+page.server'); - -function appleFetch(body: unknown, status = 200): typeof fetch { - return (async () => new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - })) as typeof fetch; -} - -async function loadHomepage(fetcher: typeof fetch) { - return await (load as (event: { fetch: typeof fetch }) => Promise<{ - appStorePrice: string | null; - playStorePrice: string | null; - aggregateRating: { ratingValue: string; ratingCount: number } | null; - }>)({ fetch: fetcher }); -} - -beforeEach(() => { - playStoreFixture = { - available: true, - price: 4.99, - currency: 'USD', - score: 4.5, - ratings: 10, - }; -}); - -afterEach(() => { - mock.restore(); -}); - -describe('download component contracts', () => { - test('detects mobile stores without browser globals', () => { - expect(detectMobileStorePlatform()).toBe('unknown'); - expect(detectMobileStorePlatform({ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0)' })).toBe('ios'); - expect(detectMobileStorePlatform({ userAgent: 'Mozilla/5.0 (Linux; Android 15)' })).toBe('android'); - expect(detectMobileStorePlatform({ - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)', - platform: 'MacIntel', - maxTouchPoints: 5, - })).toBe('ios'); - expect(detectMobileStorePlatform({ - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)', - platform: 'MacIntel', - maxTouchPoints: 0, - })).toBe('unknown'); - }); - - test('unknown platforms retain both truthful store choices', () => { - expect(storeOptionsForPlatform('unknown').map((option) => option.label)).toEqual(['App Store', 'Google Play']); - expect(storeOptionsForPlatform('ios').map((option) => option.label)).toEqual(['App Store']); - expect(storeOptionsForPlatform('android').map((option) => option.label)).toEqual(['Google Play']); - }); - - test('Linux disclosure data contains both architectures and eight unique native links', () => { - expect(linuxArchitectures.map((architecture) => architecture.label)).toEqual(['x64 (Intel/AMD)', 'ARM64']); - const links = linuxArchitectures.flatMap((architecture) => architecture.formats.map((format) => format.url)); - expect(links).toHaveLength(8); - expect(new Set(links).size).toBe(8); - expect(links.every((url) => url.startsWith('https://github.com/edde746/plezy/releases/latest/download/'))).toBe(true); - }); -}); - -describe('homepage store metadata', () => { - test('normalizes only finite nonnegative numeric USD prices', () => { - expect(normalizeUsdStorePrice(4.99, 'USD')).toBe('4.99'); - expect(normalizeUsdStorePrice(0, 'USD')).toBe('0'); - for (const value of [null, undefined, '4.99', Number.NaN, Number.POSITIVE_INFINITY, -1]) { - expect(normalizeUsdStorePrice(value, 'USD')).toBeNull(); - } - expect(normalizeUsdStorePrice(4.99, undefined)).toBeNull(); - expect(normalizeUsdStorePrice(4.99, 'EUR')).toBeNull(); - }); - - test('keeps Google Play metadata when the App Store request fails', async () => { - const data = await loadHomepage((async () => { - throw new Error('offline'); - }) as typeof fetch); - expect(data.appStorePrice).toBeNull(); - expect(data.playStorePrice).toBe('4.99'); - expect(data.aggregateRating).toEqual({ ratingValue: '4.5', ratingCount: 10 }); - }); - - test('keeps App Store metadata when Google Play is unavailable', async () => { - playStoreFixture = { available: false, price: 0, currency: 'USD' }; - const data = await loadHomepage(appleFetch({ - results: [{ price: 5.99, currency: 'USD', averageUserRating: 4, userRatingCount: 20 }], - })); - expect(data.appStorePrice).toBe('5.99'); - expect(data.playStorePrice).toBeNull(); - expect(data.aggregateRating).toEqual({ ratingValue: '4.0', ratingCount: 20 }); - }); - - test('keeps App Store metadata when Google Play throws', async () => { - playStoreFixture = new Error('scraper failed'); - const data = await loadHomepage(appleFetch({ - results: [{ price: 5.99, currency: 'USD' }], - })); - expect(data.appStorePrice).toBe('5.99'); - expect(data.playStorePrice).toBeNull(); - }); - - test('rejects non-OK and malformed store responses independently', async () => { - playStoreFixture = { price: 'free', currency: 'USD' }; - const data = await loadHomepage(appleFetch({ results: [{ price: 4.99, currency: 'USD' }] }, 503)); - expect(data.appStorePrice).toBeNull(); - expect(data.playStorePrice).toBeNull(); - }); - - test('retains paid store URLs when live prices are unavailable', () => { - const unavailable = buildSoftwareApplicationOffers({ appStorePrice: null, playStorePrice: null }); - expect(unavailable.map((offer) => offer.category)).toEqual([ - 'App Store', - 'Google Play', - 'Amazon Appstore', - 'GitHub', - ]); - expect(unavailable.find((offer) => offer.category === 'App Store')).toMatchObject({ - url: 'https://apps.apple.com/us/app/id6754315964', - }); - expect(unavailable.find((offer) => offer.category === 'Google Play')).toMatchObject({ - url: 'https://play.google.com/store/apps/details?id=com.edde746.plezy', - }); - expect(unavailable.filter((offer) => offer.price === '0').map((offer) => offer.category)).toEqual(['GitHub']); - expect( - unavailable - .filter((offer) => ['App Store', 'Google Play'].includes(offer.category)) - .every((offer) => !('price' in offer)), - ).toBe(true); - - const available = buildSoftwareApplicationOffers({ appStorePrice: '5.99', playStorePrice: '4.99' }); - expect(available.find((offer) => offer.category === 'App Store')).toMatchObject({ price: '5.99', priceCurrency: 'USD' }); - expect(available.find((offer) => offer.category === 'Google Play')).toMatchObject({ price: '4.99', priceCurrency: 'USD' }); - }); -});