diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 36f50c2..32bb592 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -83,6 +83,10 @@ jobs: bdd: runs-on: ubuntu-latest timeout-minutes: 30 + # Serve the committed sprites so offline sync doesn't download every Living Dex sprite from + # GitHub on each run. + env: + PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER: 'true' steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 diff --git a/package-lock.json b/package-lock.json index ff78751..7d733c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "@supabase/ssr": "^0.1.0", "@supabase/supabase-js": "^2.42.0", - "nanoid": "^5.0.4" + "nanoid": "^5.0.4", + "sharp": "^0.33.4" }, "devDependencies": { "@playwright/test": "1.55.1", @@ -36,7 +37,6 @@ "postcss": "^8.4.38", "prettier": "^3.1.1", "prettier-plugin-svelte": "^3.1.2", - "sharp": "^0.33.4", "supabase": "2.72.7", "svelte": "^4.2.8", "svelte-check": "^3.6.2", @@ -46,7 +46,7 @@ "vitest": "^1.0.4" }, "engines": { - "node": ">=18.13.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -2118,7 +2118,6 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2698,7 +2697,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2721,7 +2719,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2744,7 +2741,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2761,7 +2757,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2778,7 +2773,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2795,7 +2789,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2812,7 +2805,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2829,7 +2821,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2846,7 +2837,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2863,7 +2853,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2880,7 +2869,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2903,7 +2891,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2926,7 +2913,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2949,7 +2935,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2972,7 +2957,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2995,7 +2979,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3018,7 +3001,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -3038,7 +3020,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3058,7 +3039,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -5315,7 +5295,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "dev": true, "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" @@ -5328,7 +5307,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -5339,14 +5317,12 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/color-string": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "dev": true, "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" @@ -5742,7 +5718,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -7140,8 +7115,7 @@ "node_modules/is-arrayish": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "dev": true + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" }, "node_modules/is-bigint": { "version": "1.0.4", @@ -9484,7 +9458,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9547,7 +9520,6 @@ "version": "0.33.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", - "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -9703,7 +9675,6 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "dev": true, "dependencies": { "is-arrayish": "^0.3.1" } diff --git a/package.json b/package.json index de48747..850b3d9 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,6 @@ "postcss": "^8.4.38", "prettier": "^3.1.1", "prettier-plugin-svelte": "^3.1.2", - "sharp": "^0.33.4", "supabase": "2.72.7", "svelte": "^4.2.8", "svelte-check": "^3.6.2", @@ -81,9 +80,10 @@ "dependencies": { "@supabase/ssr": "^0.1.0", "@supabase/supabase-js": "^2.42.0", - "nanoid": "^5.0.4" + "nanoid": "^5.0.4", + "sharp": "^0.33.4" }, "engines": { - "node": ">=18.13.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" } } diff --git a/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte b/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte index ffc43d1..a991184 100644 --- a/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte +++ b/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte @@ -1,5 +1,6 @@ + + + {shared.name} - Living Dex Tracker + + + + + + + + + + + + + + + + + + +
+
+
+
+
+

{shared.name}

+ Read-only shared Pokédex +
+
+ {#each typeBadges as badge} + {badge} + {/each} + {shared.gameScope || 'All Games'} +
+ {#if shared.description}

{shared.description}

{/if} +

+ {shared.caught} of {shared.total} Pokémon caught · {shared.completionPercentage}% complete +

+
+
+
+ + +
+ +{#if showModal && selectedPokemon} + + + +{/if} diff --git a/src/routes/shared/[token]/preview.png/+server.ts b/src/routes/shared/[token]/preview.png/+server.ts new file mode 100644 index 0000000..d3d17d9 --- /dev/null +++ b/src/routes/shared/[token]/preview.png/+server.ts @@ -0,0 +1,18 @@ +import { error } from '@sveltejs/kit'; +import { loadSharedPokedex } from '$lib/services/SharedPokedexService'; +import { renderSharePreview } from '$lib/services/SharePreviewService'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ locals, params }) => { + const shared = await loadSharedPokedex(locals.supabase, params.token); + if (!shared) throw error(404, 'Shared Pokédex not found'); + + const image = await renderSharePreview(shared); + return new Response(image, { + headers: { + 'Content-Type': 'image/png', + 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600', + 'Content-Length': String(image.byteLength) + } + }); +}; diff --git a/static/offline-worker.js b/static/offline-worker.js index 90c1941..6619a3c 100644 --- a/static/offline-worker.js +++ b/static/offline-worker.js @@ -1,6 +1,7 @@ const OFFLINE_CACHE_PREFIX = 'livingdex-offline-'; const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`; const OFFLINE_META_URL = '/__offline/current'; +const ARTWORK_FETCH_TIMEOUT_MS = 15_000; let offlineEpoch = 0; let offlineOperation = Promise.resolve(); let claimedUserId = null; @@ -15,8 +16,15 @@ function dataCacheName(userId, generation) { return `${OFFLINE_CACHE_PREFIX}data-v1-${userId}-${generation}`; } -function artworkCacheName(userId, generation) { - return `${OFFLINE_CACHE_PREFIX}art-v1-${userId}-${generation}`; +// Sprites never change at a given URL, so each user keeps one artwork cache that is topped up +// incrementally instead of being rebuilt on every sync. +function artworkCacheName(userId) { + return `${OFFLINE_CACHE_PREFIX}art-v2-${userId}`; +} + +// Covers both the local `/sprites-small/...` folder and the GitHub-hosted copy of it. +function isSpriteUrl(url) { + return /\/sprites(-small)?\//.test(url.pathname) && url.pathname.endsWith('.webp'); } async function currentOfflineMeta() { @@ -38,22 +46,39 @@ async function notifyOfflineDataCleared() { for (const client of windows) client.postMessage({ type: 'OFFLINE_DATA_CLEARED' }); } -async function cacheArtwork(cache, urls) { +async function cacheArtwork(cache, urls, isCurrent) { + const wanted = new Set(urls.map((url) => new URL(url, self.location.origin).href)); + const cachedRequests = await cache.keys(); + const cached = new Set(cachedRequests.map((request) => request.url)); + await Promise.all( + cachedRequests + .filter((request) => !wanted.has(request.url)) + .map((request) => cache.delete(request)) + ); + const missing = [...wanted].filter((url) => !cached.has(url)); + let next = 0; let failed = 0; - const workers = Array.from({ length: Math.min(6, urls.length) }, async () => { + const workers = Array.from({ length: Math.min(6, missing.length) }, async () => { for (;;) { const index = next++; - if (index >= urls.length) return; - const url = urls[index]; + if (index >= missing.length || !isCurrent()) return; + const url = missing[index]; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), ARTWORK_FETCH_TIMEOUT_MS); try { + // CORS rather than no-cors: opaque responses are padded to several MB each for storage + // quota, which a full Living Dex of artwork would exhaust. const response = await fetch(url, { - mode: url.startsWith(self.location.origin) ? 'same-origin' : 'no-cors' + mode: url.startsWith(self.location.origin) ? 'same-origin' : 'cors', + signal: controller.signal }); - if (!response.ok && response.type !== 'opaque') throw new Error(`HTTP ${response.status}`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); await cache.put(url, response); } catch { failed++; + } finally { + clearTimeout(timeout); } } }); @@ -98,11 +123,12 @@ self.addEventListener('message', (event) => { if (event.data?.type !== 'SYNC_OFFLINE_SNAPSHOT') return; const syncEpoch = offlineEpoch; const syncUserId = claimedUserId; + const isCurrent = () => syncEpoch === offlineEpoch; event.waitUntil( queueOfflineOperation(async () => { let nextData; - let nextArtwork; + let committed = false; try { const snapshot = event.data.snapshot; if (!snapshot || snapshot.version !== 1 || typeof snapshot.userId !== 'string') { @@ -114,6 +140,13 @@ self.addEventListener('message', (event) => { const previousMeta = await currentOfflineMeta(); if (previousMeta?.userId && previousMeta.userId !== snapshot.userId) await clearOfflineData(); + // Per-sync artwork caches of opaque responses could fill the whole storage quota, so drop + // them before writing anything new. + await Promise.all( + (await caches.keys()) + .filter((name) => name.startsWith(`${OFFLINE_CACHE_PREFIX}art-v1-`)) + .map((name) => caches.delete(name)) + ); const timestamp = String(snapshot.generatedAt).replace(/[^0-9]/g, ''); const generation = `${timestamp}-${crypto.randomUUID()}`; @@ -125,18 +158,12 @@ self.addEventListener('message', (event) => { headers: { 'Content-Type': 'application/json' } }) ); - - nextArtwork = artworkCacheName(snapshot.userId, generation); - const artworkCache = await caches.open(nextArtwork); - const failedArtwork = await cacheArtwork( - artworkCache, - Array.from(new Set(event.data.artworkUrls ?? [])) - ); - if (syncEpoch !== offlineEpoch) { - await Promise.all([caches.delete(nextData), caches.delete(nextArtwork)]); + if (!isCurrent()) throw new Error('Offline synchronization was superseded by an account change'); - } + // Commit the collection before any artwork so a slow or failing sprite download can never + // prevent the offline copy from being saved. + const artworkCache = artworkCacheName(snapshot.userId); const metaCache = await caches.open(OFFLINE_META_CACHE); await metaCache.put( OFFLINE_META_URL, @@ -145,40 +172,41 @@ self.addEventListener('message', (event) => { userId: snapshot.userId, generatedAt: snapshot.generatedAt, dataCache: nextData, - artworkCache: nextArtwork + artworkCache }), { headers: { 'Content-Type': 'application/json' } } ) ); - if (syncEpoch !== offlineEpoch) { + if (!isCurrent()) { const current = await currentOfflineMeta(); if (current?.dataCache === nextData) await metaCache.delete(OFFLINE_META_URL); - await Promise.all([caches.delete(nextData), caches.delete(nextArtwork)]); throw new Error('Offline synchronization was superseded by an account change'); } - if (previousMeta?.artworkCache && previousMeta.artworkCache !== nextArtwork) { - await caches.delete(previousMeta.artworkCache); - } - if (previousMeta?.dataCache && previousMeta.dataCache !== nextData) { - await caches.delete(previousMeta.dataCache); - } + committed = true; + const currentCaches = await caches.keys(); await Promise.all( currentCaches .filter( (name) => name.startsWith(OFFLINE_CACHE_PREFIX) && - ![OFFLINE_META_CACHE, nextData, nextArtwork].includes(name) + ![OFFLINE_META_CACHE, nextData, artworkCache].includes(name) ) .map((name) => caches.delete(name)) ); + + const failedArtwork = await cacheArtwork( + await caches.open(artworkCache), + Array.from(new Set(event.data.artworkUrls ?? [])), + isCurrent + ); + if (!isCurrent()) + throw new Error('Offline synchronization was superseded by an account change'); reply({ ok: true, failedArtwork }); } catch (error) { - await Promise.allSettled( - [nextData, nextArtwork].filter(Boolean).map((name) => caches.delete(name)) - ); + if (!committed && nextData) await caches.delete(nextData).catch(() => undefined); reply({ ok: false, error: error instanceof Error ? error.message : String(error) }); } }) @@ -203,15 +231,22 @@ self.addEventListener('fetch', (event) => { event.respondWith( (async () => { const meta = await currentOfflineMeta(); - if (meta?.artworkCache) { - const cached = await ( - await caches.open(meta.artworkCache) - ).match(event.request, { - ignoreSearch: true + if (!meta?.artworkCache) return fetch(event.request); + const cache = await caches.open(meta.artworkCache); + const cached = await cache.match(event.request, { ignoreSearch: true }); + if (cached) return cached; + if (!isSpriteUrl(url)) return fetch(event.request); + // Cache-first with fill-on-miss: a sprite shown online is stored once and served from the + // cache from then on, so the bulk sync never has to download it again. + try { + const response = await fetch(url.href, { + mode: url.origin === self.location.origin ? 'same-origin' : 'cors' }); - if (cached) return cached; + if (response.ok) event.waitUntil(cache.put(url.href, response.clone())); + return response; + } catch { + return fetch(event.request); } - return fetch(event.request); })() ); }); diff --git a/supabase/migrations/20260914000000_add_pokedex_sharing.sql b/supabase/migrations/20260914000000_add_pokedex_sharing.sql new file mode 100644 index 0000000..f850752 --- /dev/null +++ b/supabase/migrations/20260914000000_add_pokedex_sharing.sql @@ -0,0 +1,73 @@ +-- Stable capability links for read-only Pokédex sharing. +ALTER TABLE pokedexes + ADD COLUMN "shareToken" UUID NOT NULL DEFAULT gen_random_uuid(); + +ALTER TABLE pokedexes + ADD CONSTRAINT pokedexes_share_token_key UNIQUE ("shareToken"); + +CREATE OR REPLACE FUNCTION prevent_pokedex_share_token_update() +RETURNS TRIGGER +LANGUAGE plpgsql +SET search_path = public, pg_temp +AS $$ +BEGIN + IF NEW."shareToken" IS DISTINCT FROM OLD."shareToken" THEN + RAISE EXCEPTION 'shareToken is immutable'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER prevent_pokedex_share_token_update + BEFORE UPDATE OF "shareToken" ON pokedexes + FOR EACH ROW EXECUTE FUNCTION prevent_pokedex_share_token_update(); + +-- This is the only anonymous path into user-owned Pokédex data. Keep the returned +-- shape deliberately narrow: no owner identity, row IDs, timestamps, or personal notes. +CREATE OR REPLACE FUNCTION get_shared_pokedex(p_share_token UUID) +RETURNS JSONB +SECURITY DEFINER +STABLE +SET search_path = public, pg_temp +LANGUAGE sql +AS $$ + SELECT jsonb_build_object( + 'name', p.name, + 'description', COALESCE(p.description, ''), + 'isLivingDex', COALESCE(p."isLivingDex", false), + 'isShinyDex', COALESCE(p."isShinyDex", false), + 'isOriginDex', COALESCE(p."isOriginDex", false), + 'isFormDex', COALESCE(p."isFormDex", false), + 'gameScope', p."gameScope", + 'dexScopes', COALESCE( + ( + SELECT jsonb_agg(pds."dexId" ORDER BY pds."dexId") + FROM pokedex_dex_scopes pds + WHERE pds."pokedexId" = p.id + ), + '[]'::jsonb + ), + 'catchStatuses', COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'pokemonId', cr."pokemonId"::text, + 'caught', COALESCE(cr.caught, false), + 'haveToEvolve', COALESCE(cr."haveToEvolve", false), + 'inHome', COALESCE(cr."inHome", false), + 'hasGigantamaxed', COALESCE(cr."hasGigantamaxed", false) + ) + ORDER BY cr."pokemonId" + ) + FROM catch_records cr + WHERE cr."pokedexId" = p.id + ), + '[]'::jsonb + ) + ) + FROM pokedexes p + WHERE p."shareToken" = p_share_token; +$$; + +REVOKE ALL ON FUNCTION get_shared_pokedex(UUID) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION get_shared_pokedex(UUID) TO anon, authenticated; diff --git a/tests/bdd/features/pokedex-sharing.feature b/tests/bdd/features/pokedex-sharing.feature new file mode 100644 index 0000000..9b650bf --- /dev/null +++ b/tests/bdd/features/pokedex-sharing.feature @@ -0,0 +1,19 @@ +Feature: Share a Pokédex + As a trainer + I want to share my progress without granting edit access + So that friends can follow my collection safely + + Background: + Given I am signed in + + Scenario: Share a live read-only Pokédex + Given I have a Living Dex named "Public Journey" + When I mark the first Pokémon as caught + And I add the note "share-secret-note" to the first Pokémon + When I open the Pokédex share dialog + Then I receive an unguessable read-only link + When I visit the shared link while signed out + Then I can browse the shared Pokédex without editing it + And the shared page does not expose the private note + And the shared page advertises a social progress image + And the social progress image is a PNG diff --git a/tests/bdd/fixtures.ts b/tests/bdd/fixtures.ts index 0c23723..dc647e7 100644 --- a/tests/bdd/fixtures.ts +++ b/tests/bdd/fixtures.ts @@ -12,6 +12,7 @@ export type ScenarioState = { lastMessage: string | null; caughtEntryLabel: string | null; legacyServiceWorkerRequested: boolean; + shareUrl: string | null; }; type Fixtures = { state: ScenarioState; providerMock: void }; @@ -53,7 +54,8 @@ export const test = base.extend({ lastResponseStatus: null, lastMessage: null, caughtEntryLabel: null, - legacyServiceWorkerRequested: false + legacyServiceWorkerRequested: false, + shareUrl: null }); } }); diff --git a/tests/bdd/steps/pwa.steps.ts b/tests/bdd/steps/pwa.steps.ts index e38cafe..4ec42f6 100644 --- a/tests/bdd/steps/pwa.steps.ts +++ b/tests/bdd/steps/pwa.steps.ts @@ -67,16 +67,19 @@ When('I go offline and reload the sign-in page', async ({ page }) => { Given('my offline copy is synchronized', async ({ page, state }) => { await waitForServiceWorker(page); + // A full Living Dex snapshot plus its artwork can take longer than the default poll window on CI. await expect - .poll(() => - page.evaluate(async (userId) => { - const meta = await ( - await caches.open('livingdex-offline-meta-v1') - ).match('/__offline/current'); - if (!meta) return false; - const value = await meta.json(); - return value.userId === userId; - }, state.userId) + .poll( + () => + page.evaluate(async (userId) => { + const meta = await ( + await caches.open('livingdex-offline-meta-v1') + ).match('/__offline/current'); + if (!meta) return false; + const value = await meta.json(); + return value.userId === userId; + }, state.userId), + { timeout: 30_000 } ) .toBe(true); const serializedSnapshot = await page.evaluate(async () => { diff --git a/tests/bdd/steps/sharing.steps.ts b/tests/bdd/steps/sharing.steps.ts new file mode 100644 index 0000000..82ea817 --- /dev/null +++ b/tests/bdd/steps/sharing.steps.ts @@ -0,0 +1,65 @@ +import { createBdd } from 'playwright-bdd'; +import { test, expect } from '../fixtures'; + +const { When, Then } = createBdd(test); + +When('I open the Pokédex share dialog', async ({ page, state }) => { + const detailsDialog = page.getByRole('dialog').filter({ hasText: 'Notes:' }); + if (await detailsDialog.count()) { + await detailsDialog.getByRole('button', { name: 'Close', exact: true }).click(); + } + await page.getByRole('button', { name: 'Share', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: /Share Public Journey/ }); + await expect(dialog).toBeVisible(); + state.shareUrl = await dialog.getByLabel('Read-only link').inputValue(); +}); + +Then('I receive an unguessable read-only link', async ({ state }) => { + expect(state.shareUrl).toMatch( + /^https?:\/\/[^/]+\/shared\/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); +}); + +When('I visit the shared link while signed out', async ({ page, context, state }) => { + if (!state.shareUrl) throw new Error('A share URL is required'); + await context.clearCookies(); + await page.goto(state.shareUrl); +}); + +Then('I can browse the shared Pokédex without editing it', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Public Journey' })).toBeVisible(); + await expect(page.getByText('Read-only shared Pokédex')).toBeVisible(); + await expect(page.getByLabel('Choose box view layout density')).toBeVisible(); + await expect(page.getByText('Filters:', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Open bulk actions menu' })).toHaveCount(0); + await expect(page.getByRole('button', { name: /Create Pokédex data/ })).toHaveCount(0); + await expect(page.getByText('Personal notes')).toHaveCount(0); +}); + +Then('the shared page does not expose the private note', async ({ page }) => { + await expect(page.getByText('share-secret-note')).toHaveCount(0); +}); + +Then('the shared page advertises a social progress image', async ({ page }) => { + const canonical = await page.locator('link[rel="canonical"]').getAttribute('href'); + const image = await page.locator('meta[property="og:image"]').getAttribute('content'); + expect(canonical).toBe(page.url()); + expect(image).toBe(`${page.url()}/preview.png`); + await expect(page.locator('meta[name="twitter:card"]')).toHaveAttribute( + 'content', + 'summary_large_image' + ); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex, nofollow'); + await expect(page.locator('meta[name="referrer"]')).toHaveAttribute('content', 'no-referrer'); +}); + +Then('the social progress image is a PNG', async ({ page }) => { + const image = await page.locator('meta[property="og:image"]').getAttribute('content'); + if (!image) throw new Error('Open Graph image URL is required'); + const response = await page.request.get(image); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toBe('image/png'); + expect(response.headers()['cache-control']).toContain('max-age=300'); + const body = await response.body(); + expect([...body.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); +}); diff --git a/tests/integration/sharing.integration.test.ts b/tests/integration/sharing.integration.test.ts new file mode 100644 index 0000000..5afd914 --- /dev/null +++ b/tests/integration/sharing.integration.test.ts @@ -0,0 +1,138 @@ +import { createClient } from '@supabase/supabase-js'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { requireLoopbackUrl } from '../support/loopback'; + +const url = requireLoopbackUrl( + process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321', + 'TEST_SUPABASE_URL' +); +const anonKey = process.env.TEST_SUPABASE_ANON_KEY; +const serviceKey = process.env.E2E_SERVICE_ROLE_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY; + +describe('read-only Pokédex sharing', () => { + const createdUserIds: string[] = []; + + beforeAll(() => { + if (!anonKey || !serviceKey) { + throw new Error('Integration tests require TEST_SUPABASE_ANON_KEY and E2E_SERVICE_ROLE_KEY'); + } + }); + + afterAll(async () => { + if (!serviceKey) return; + const admin = createClient(url, serviceKey); + await Promise.all(createdUserIds.map((id) => admin.auth.admin.deleteUser(id))); + }); + + it('generates a stable token and exposes only sanitized data through the RPC', async () => { + const admin = createClient(url, serviceKey!); + const email = `integration-sharing-${Date.now()}@example.test`; + const password = 'Integration123!'; + const created = await admin.auth.admin.createUser({ email, password, email_confirm: true }); + expect(created.error).toBeNull(); + const userId = created.data.user!.id; + createdUserIds.push(userId); + + const { data: dex, error: dexError } = await admin + .from('pokedexes') + .insert({ + userId, + name: 'Shared & Safe', + description: 'Public description', + isLivingDex: true + }) + .select('id, shareToken') + .single(); + expect(dexError).toBeNull(); + expect(dex!.shareToken).toMatch(/^[0-9a-f-]{36}$/i); + + const { data: pokemon } = await admin + .from('pokemon') + .select('id') + .order('id') + .limit(1) + .single(); + expect( + ( + await admin.from('catch_records').insert({ + userId, + pokedexId: dex!.id, + pokemonId: pokemon!.id, + caught: true, + inHome: true, + personalNotes: 'This must remain private' + }) + ).error + ).toBeNull(); + + const anonymous = createClient(url, anonKey!); + const direct = await anonymous.from('pokedexes').select('*').eq('id', dex!.id); + expect(direct.error).toBeNull(); + expect(direct.data).toEqual([]); + const directCatchRecords = await anonymous + .from('catch_records') + .select('*') + .eq('pokedexId', dex!.id); + expect(directCatchRecords.error).toBeNull(); + expect(directCatchRecords.data).toEqual([]); + + const result = await anonymous.rpc('get_shared_pokedex', { + p_share_token: dex!.shareToken + }); + expect(result.error).toBeNull(); + expect(result.data).toMatchObject({ + name: 'Shared & Safe', + description: 'Public description', + catchStatuses: [ + { + pokemonId: String(pokemon!.id), + caught: true, + inHome: true + } + ] + }); + const serialized = JSON.stringify(result.data); + expect(serialized).not.toContain(userId); + expect(serialized).not.toContain(dex!.shareToken); + expect(serialized).not.toContain('This must remain private'); + expect(serialized).not.toContain('personalNotes'); + + const attemptedWrite = await anonymous + .from('catch_records') + .update({ caught: false }) + .eq('pokedexId', dex!.id) + .eq('pokemonId', pokemon!.id) + .select(); + expect(attemptedWrite.error).toBeNull(); + expect(attemptedWrite.data).toEqual([]); + const unchanged = await admin + .from('catch_records') + .select('caught') + .eq('pokedexId', dex!.id) + .eq('pokemonId', pokemon!.id) + .single(); + expect(unchanged.data?.caught).toBe(true); + + const owner = createClient(url, anonKey!); + expect((await owner.auth.signInWithPassword({ email, password })).error).toBeNull(); + const ownerDex = await owner.from('pokedexes').select('shareToken').eq('id', dex!.id).single(); + expect(ownerDex.data?.shareToken).toBe(dex!.shareToken); + expect( + (await owner.from('pokedexes').update({ shareToken: crypto.randomUUID() }).eq('id', dex!.id)) + .error + ).not.toBeNull(); + + const missing = await anonymous.rpc('get_shared_pokedex', { + p_share_token: crypto.randomUUID() + }); + expect(missing.error).toBeNull(); + expect(missing.data).toBeNull(); + + expect((await admin.from('pokedexes').delete().eq('id', dex!.id)).error).toBeNull(); + const deleted = await anonymous.rpc('get_shared_pokedex', { + p_share_token: dex!.shareToken + }); + expect(deleted.error).toBeNull(); + expect(deleted.data).toBeNull(); + }); +}); diff --git a/tests/unit/sharePreviewService.test.ts b/tests/unit/sharePreviewService.test.ts new file mode 100644 index 0000000..001beb8 --- /dev/null +++ b/tests/unit/sharePreviewService.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import sharp from 'sharp'; +import { + buildSharePreviewSvg, + escapeXml, + renderSharePreview, + SHARE_PREVIEW_HEIGHT, + SHARE_PREVIEW_WIDTH, + truncatePreviewText +} from '$lib/services/SharePreviewService'; +import type { SharedPokedexData } from '$lib/models/SharedPokedex'; + +const shared: SharedPokedexData = { + name: 'Johto & ', + description: 'A shared collection', + isLivingDex: true, + isShinyDex: false, + isOriginDex: false, + isFormDex: false, + gameScope: null, + dexScopes: [], + combinedData: [], + total: 100, + caught: 42, + completionPercentage: 42 +}; + +describe('share preview rendering', () => { + it('escapes XML and truncates normalized user text', () => { + expect(escapeXml(`Tom & Jerry's`)).toBe( + '<tag attr="x">Tom & Jerry's</tag>' + ); + expect(truncatePreviewText(' lots of\nspace ', 20)).toBe('lots of space'); + expect(truncatePreviewText('abcdefghij', 6)).toBe('abcde…'); + }); + + it('builds a branded progress card without raw user markup', () => { + const svg = buildSharePreviewSvg(shared); + expect(svg).toContain('Johto & <Friends>'); + expect(svg).not.toContain('Johto & '); + expect(svg).toContain('42 of 100 Pokémon caught'); + expect(svg).toContain('42%'); + }); + + it('lists every enabled dex badge and omits an empty description', () => { + const svg = buildSharePreviewSvg({ + ...shared, + description: '', + isShinyDex: true, + isOriginDex: true, + isFormDex: true, + gameScope: 'Scarlet' + }); + for (const badge of ['Living', 'Shiny', 'Origin', 'Form', 'Scarlet']) { + expect(svg).toContain(`class="badge">${badge}`); + } + expect(svg).not.toContain('All Games'); + expect(svg).not.toContain('class="description"'); + }); + + it('renders a valid 1200 by 630 PNG', async () => { + const png = await renderSharePreview(shared); + const metadata = await sharp(png).metadata(); + expect(metadata.format).toBe('png'); + expect(metadata.width).toBe(SHARE_PREVIEW_WIDTH); + expect(metadata.height).toBe(SHARE_PREVIEW_HEIGHT); + }); +}); diff --git a/tests/unit/sharedPokedexService.test.ts b/tests/unit/sharedPokedexService.test.ts new file mode 100644 index 0000000..5b5cc49 --- /dev/null +++ b/tests/unit/sharedPokedexService.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { PokedexEntry } from '$lib/models/PokedexEntry'; +import type { SharedPokedexRpcData } from '$lib/models/SharedPokedex'; + +const { findAllCombinedData } = vi.hoisted(() => ({ findAllCombinedData: vi.fn() })); +vi.mock('$lib/repositories/CombinedDataRepository', () => ({ + default: vi.fn().mockImplementation(() => ({ findAllCombinedData })) +})); + +import { + calculateSharedProgress, + isShareToken, + loadSharedPokedex +} from '$lib/services/SharedPokedexService'; + +const TOKEN = '123e4567-e89b-42d3-a456-426614174000'; + +function supabaseWithRpc(result: { data: unknown; error: unknown }) { + const rpc = vi.fn().mockResolvedValue(result); + return { supabase: { rpc } as unknown as SupabaseClient, rpc }; +} + +describe('shared Pokédex helpers', () => { + it('accepts UUID capability tokens and rejects malformed route values', () => { + expect(isShareToken('123e4567-e89b-42d3-a456-426614174000')).toBe(true); + expect(isShareToken('not-a-token')).toBe(false); + expect(isShareToken('123e4567-e89b-12d3-a456-426614174000/extra')).toBe(false); + }); + + it('counts caught and needs-to-evolve entries as progress', () => { + const progress = calculateSharedProgress( + [ + { + pokemonId: '1', + caught: true, + haveToEvolve: false, + inHome: false, + hasGigantamaxed: false + }, + { + pokemonId: '2', + caught: false, + haveToEvolve: true, + inHome: false, + hasGigantamaxed: false + } + ], + 4 + ); + expect(progress).toEqual({ caught: 2, completionPercentage: 50 }); + expect(calculateSharedProgress([], 0)).toEqual({ caught: 0, completionPercentage: 0 }); + }); +}); + +describe('loadSharedPokedex', () => { + beforeEach(() => { + findAllCombinedData.mockReset(); + }); + + it('rejects malformed tokens without querying', async () => { + const { supabase, rpc } = supabaseWithRpc({ data: null, error: null }); + expect(await loadSharedPokedex(supabase, 'not-a-token')).toBeNull(); + expect(rpc).not.toHaveBeenCalled(); + }); + + it('returns null when the token matches nothing or the RPC fails', async () => { + expect( + await loadSharedPokedex(supabaseWithRpc({ data: null, error: null }).supabase, TOKEN) + ).toBeNull(); + expect( + await loadSharedPokedex( + supabaseWithRpc({ data: null, error: { message: 'boom' } }).supabase, + TOKEN + ) + ).toBeNull(); + expect(findAllCombinedData).not.toHaveBeenCalled(); + }); + + it('maps shared catch statuses onto the scoped dex entries', async () => { + const rpcData: SharedPokedexRpcData = { + name: 'Kanto', + description: 'Gen 1', + isLivingDex: true, + isShinyDex: false, + isOriginDex: false, + isFormDex: true, + gameScope: null, + dexScopes: ['kanto'], + catchStatuses: [ + { pokemonId: '1', caught: true, haveToEvolve: false, inHome: true, hasGigantamaxed: false }, + { + pokemonId: '99', + caught: true, + haveToEvolve: false, + inHome: false, + hasGigantamaxed: false + } + ] + }; + const entries = ['1', '2', '3', '4'].map((id) => ({ + pokedexEntry: { _id: id } as PokedexEntry, + catchRecord: null + })); + findAllCombinedData.mockResolvedValue(entries); + const { supabase, rpc } = supabaseWithRpc({ data: rpcData, error: null }); + + const shared = await loadSharedPokedex(supabase, TOKEN); + + expect(rpc).toHaveBeenCalledWith('get_shared_pokedex', { p_share_token: TOKEN }); + expect(findAllCombinedData).toHaveBeenCalledWith('', true, '', '', ['kanto']); + expect(shared?.combinedData.map((entry) => entry.catchRecord?.pokemonId ?? null)).toEqual([ + '1', + null, + null, + null + ]); + // Statuses for Pokémon outside the dex scope must not inflate progress. + expect(shared).toMatchObject({ + name: 'Kanto', + total: 4, + caught: 1, + completionPercentage: 25 + }); + }); +});