diff --git a/scripts/mock-provider-server.mjs b/scripts/mock-provider-server.mjs index c7e406c..eb00b8e 100644 --- a/scripts/mock-provider-server.mjs +++ b/scripts/mock-provider-server.mjs @@ -3,7 +3,7 @@ import { createServer } from 'node:http'; const port = Number(process.env.MOCK_PROVIDER_PORT ?? 4199); -const state = { requests: [], failUploads: false, refreshes: 0 }; +const state = { requests: [], failUploads: false, revokeRefresh: false, refreshes: 0 }; function send(response, status, body, headers = {}) { response.writeHead(status, { 'Content-Type': 'application/json', ...headers }); @@ -25,6 +25,7 @@ const server = createServer(async (request, response) => { if (url.pathname === '/__mock/reset') { state.requests = []; state.failUploads = false; + state.revokeRefresh = false; state.refreshes = 0; return send(response, 200, { ok: true }); } @@ -32,6 +33,10 @@ const server = createServer(async (request, response) => { state.failUploads = true; return send(response, 200, { ok: true }); } + if (url.pathname === '/__mock/revoke-refresh') { + state.revokeRefresh = true; + return send(response, 200, { ok: true }); + } if (url.pathname.endsWith('/authorize')) { const redirectUri = url.searchParams.get('redirect_uri'); @@ -45,7 +50,13 @@ const server = createServer(async (request, response) => { } if (url.pathname.endsWith('/token')) { - if (body.includes('grant_type=refresh_token')) state.refreshes++; + if (body.includes('grant_type=refresh_token')) { + state.refreshes++; + // Mirrors Google and Dropbox answering a revoked or expired refresh token. + if (state.revokeRefresh) { + return send(response, 400, { error: 'invalid_grant', error_description: 'Bad Request' }); + } + } return send(response, 200, { access_token: 'mock-access-token', refresh_token: 'mock-refresh-token', diff --git a/src/lib/repositories/PokedexExportIntegrationRepository.ts b/src/lib/repositories/PokedexExportIntegrationRepository.ts index 8fb4770..be12526 100644 --- a/src/lib/repositories/PokedexExportIntegrationRepository.ts +++ b/src/lib/repositories/PokedexExportIntegrationRepository.ts @@ -139,6 +139,7 @@ class PokedexExportIntegrationRepository { async updateExportStatus( id: string, patch: { + enabled?: boolean; lastExportedAt?: string | null; lastError?: string | null; metadata?: Record | null; diff --git a/src/lib/services/PokedexExportFormatting.ts b/src/lib/services/PokedexExportFormatting.ts index ec02015..a2941bf 100644 --- a/src/lib/services/PokedexExportFormatting.ts +++ b/src/lib/services/PokedexExportFormatting.ts @@ -54,6 +54,17 @@ export function buildCsv(combinedData: CombinedData[]): string { return lines.join('\r\n'); } +/** OAuth providers answer a revoked or expired refresh token with `invalid_grant`. */ +export function isRevokedGrant(status: number, body: string): boolean { + if (status !== 400 && status !== 401) return false; + try { + const parsed = JSON.parse(body) as { error?: unknown } | null; + return parsed?.error === 'invalid_grant'; + } catch { + return false; + } +} + export function shouldRefreshToken(expiresAt: string | null): boolean { if (!expiresAt) return false; const expiry = new Date(expiresAt).getTime(); diff --git a/src/lib/services/PokedexExportService.ts b/src/lib/services/PokedexExportService.ts index 064f19a..67deee8 100644 --- a/src/lib/services/PokedexExportService.ts +++ b/src/lib/services/PokedexExportService.ts @@ -13,6 +13,7 @@ import { getEnv } from '$lib/utils/env'; import { getProviderEndpoints } from '$lib/services/providerEndpoints'; import { buildCsv, + isRevokedGrant, sanitizeFileName, shouldRefreshToken } from '$lib/services/PokedexExportFormatting'; @@ -21,6 +22,7 @@ type ExportFailure = { integrationId: string; provider: ExportProvider; error: string; + reconnectRequired: boolean; }; export type PokedexExportResult = { @@ -29,12 +31,21 @@ export type PokedexExportResult = { failed: ExportFailure[]; }; +const RECONNECT_MESSAGES: Record = { + google_drive: + 'Google Drive access has expired or was revoked. Reconnect Google Drive to resume backups.', + dropbox: 'Dropbox access has expired or was revoked. Reconnect Dropbox to resume backups.' +}; + +/** The provider rejected the stored grant, so only a fresh OAuth connection can resume exports. */ +class ReconnectRequiredError extends Error {} + async function refreshGoogleToken( integration: PokedexExportIntegration, repo: PokedexExportIntegrationRepository ): Promise { if (!integration.refreshToken) { - throw new Error('Missing Google refresh token'); + throw new ReconnectRequiredError(RECONNECT_MESSAGES.google_drive); } const env = getEnv(); @@ -59,6 +70,9 @@ async function refreshGoogleToken( if (!response.ok) { const text = await response.text(); + if (isRevokedGrant(response.status, text)) { + throw new ReconnectRequiredError(RECONNECT_MESSAGES.google_drive); + } throw new Error(`Google token refresh failed: ${response.status} ${text}`); } @@ -88,7 +102,7 @@ async function refreshDropboxToken( repo: PokedexExportIntegrationRepository ): Promise { if (!integration.refreshToken) { - throw new Error('Missing Dropbox refresh token'); + throw new ReconnectRequiredError(RECONNECT_MESSAGES.dropbox); } const env = getEnv(); @@ -113,6 +127,9 @@ async function refreshDropboxToken( if (!response.ok) { const text = await response.text(); + if (isRevokedGrant(response.status, text)) { + throw new ReconnectRequiredError(RECONNECT_MESSAGES.dropbox); + } throw new Error(`Dropbox token refresh failed: ${response.status} ${text}`); } @@ -418,13 +435,17 @@ export async function exportPokedexIfConfigured( successes++; } catch (error) { const message = error instanceof Error ? error.message : String(error); + // A revoked grant never succeeds on retry, so pause this integration until the user reconnects. + const reconnectRequired = error instanceof ReconnectRequiredError; failures.push({ integrationId: integration._id, provider: integration.provider, - error: message + error: message, + reconnectRequired }); await scopedRepo.updateExportStatus(integration._id, { - lastError: message + lastError: message, + ...(reconnectRequired ? { enabled: false } : {}) }); console.error('Failed to export pokedex:', integration.provider, message); } diff --git a/src/lib/stores/backupStatus.ts b/src/lib/stores/backupStatus.ts new file mode 100644 index 0000000..a3aa2df --- /dev/null +++ b/src/lib/stores/backupStatus.ts @@ -0,0 +1,38 @@ +import { writable } from 'svelte/store'; +import type { ExportProvider } from '$lib/models/PokedexExportIntegration'; + +export const PROVIDER_LABELS: Record = { + google_drive: 'Google Drive', + dropbox: 'Dropbox' +}; + +/** + * Backup providers whose access has lapsed. Exports switch an integration off when the provider + * revokes its grant, so these stay paused until the user reconnects. + */ +export const backupsNeedingReconnect = writable([]); + +type IntegrationSummary = { provider: ExportProvider; enabled: boolean }; + +export function setBackupStatus(integrations: IntegrationSummary[]): void { + backupsNeedingReconnect.set(integrations.filter((i) => !i.enabled).map((i) => i.provider)); +} + +export async function refreshBackupStatus(): Promise { + if (typeof window === 'undefined' || !navigator.onLine) return; + try { + const response = await fetch('/api/export-integrations', { credentials: 'include' }); + if (!response.ok) return; + setBackupStatus((await response.json()) as IntegrationSummary[]); + } catch (error) { + console.error('Unable to check backup status', error); + } +} + +export function markReconnectNeeded(providers: ExportProvider[]): void { + backupsNeedingReconnect.update((current) => [...new Set([...current, ...providers])]); +} + +export function clearBackupStatus(): void { + backupsNeedingReconnect.set([]); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index fa7668d..12e1f20 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -6,14 +6,14 @@ import SignIn from '$lib/components/SignIn.svelte'; import SignOut from '$lib/components/SignOut.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte'; + import { page } from '$app/stores'; + import { claimOfflineData, requestOfflineSync, startOfflineSync } from '$lib/stores/offlineSync'; import { - artworkDownloadStatus, - claimOfflineData, - downloadAllArtwork, - offlineSyncStatus, - requestOfflineSync, - startOfflineSync - } from '$lib/stores/offlineSync'; + PROVIDER_LABELS, + backupsNeedingReconnect, + clearBackupStatus, + refreshBackupStatus + } from '$lib/stores/backupStatus'; import { pwaInfo } from 'virtual:pwa-info'; import { pwaAssetsHead } from 'virtual:pwa-assets/head'; @@ -57,7 +57,10 @@ updateOnlineState(); void getUser() .then(async () => { - if (localUser) await claimOfflineData(localUser.id); + if (localUser) { + void refreshBackupStatus(); + await claimOfflineData(localUser.id); + } stopOfflineSync = startOfflineSync(() => localUser?.id ?? null); }) .catch((error) => console.error('Unable to claim offline data', error)); @@ -75,12 +78,14 @@ void claimOfflineData(session.user.id) .then(requestOfflineSync) .catch((error) => console.error('Unable to claim offline data', error)); + void refreshBackupStatus(); } } else { // Offline data is only cleared by the Sign Out button (or another account claiming it). // An expired or rejected session must not throw away artwork that would then have to be // downloaded again after signing back in. localUser = null; + clearBackupStatus(); } user.set(localUser); }); @@ -98,10 +103,7 @@ }; }); - function formatMegabytes(bytes: number) { - const megabytes = bytes / 1048576; - return megabytes < 1 ? '<1 MB' : `≈${Math.round(megabytes)} MB`; - } + $: reconnectLabels = $backupsNeedingReconnect.map((provider) => PROVIDER_LABELS[provider]); async function getUser() { const { @@ -192,6 +194,9 @@
  • Backup Settings
  • +
  • + Using Offline +
  • Offline read-only mode: saved data remains available, but changes are disabled. - {:else if localUser && $offlineSyncStatus.state === 'error'} -
    - Offline copy could not be refreshed: {$offlineSyncStatus.message} - -
    - {:else if localUser && $artworkDownloadStatus.state === 'error'} -
    - Offline data is saved, but artwork could not be saved: {$artworkDownloadStatus.message}. - -
    {/if} - {#if isOnline && localUser && $offlineSyncStatus.state === 'syncing'} -

    Updating offline copy…

    - {:else if isOnline && localUser && $offlineSyncStatus.generatedAt} -

    - Offline copy updated {new Date($offlineSyncStatus.generatedAt).toLocaleString()}. - {#if $artworkDownloadStatus.state === 'downloading'} - Saving all artwork for offline… - {:else if $artworkDownloadStatus.state === 'missing'} - - {/if} -

    + {#if localUser && reconnectLabels.length > 0 && $page.url.pathname !== '/backup-settings'} + {/if}
    diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index e4d1c04..ca3f869 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -115,7 +115,7 @@ Open Source - +

    Start Your Pokédex Journey!

    diff --git a/src/routes/api/integrations/dropbox/callback/+server.ts b/src/routes/api/integrations/dropbox/callback/+server.ts index 42e8d45..3ecb2b6 100644 --- a/src/routes/api/integrations/dropbox/callback/+server.ts +++ b/src/routes/api/integrations/dropbox/callback/+server.ts @@ -98,7 +98,9 @@ export const GET = async (event: RequestEvent) => { accessToken: tokenData.access_token, refreshToken: tokenData.refresh_token ?? null, accessTokenExpiresAt: expiresAt, - metadata: tokenData.scope ? { scope: tokenData.scope } : null + metadata: tokenData.scope ? { scope: tokenData.scope } : null, + // A reconnect replaces the tokens, so any error from the old ones no longer applies. + lastError: null }); } catch (saveError) { console.error('Dropbox integration save failed:', saveError); diff --git a/src/routes/api/integrations/google-drive/callback/+server.ts b/src/routes/api/integrations/google-drive/callback/+server.ts index 0c4d426..92208ff 100644 --- a/src/routes/api/integrations/google-drive/callback/+server.ts +++ b/src/routes/api/integrations/google-drive/callback/+server.ts @@ -99,7 +99,9 @@ export const GET = async (event: RequestEvent) => { accessToken: tokenData.access_token, refreshToken: tokenData.refresh_token ?? null, accessTokenExpiresAt: expiresAt, - metadata: tokenData.scope ? { scope: tokenData.scope } : null + metadata: tokenData.scope ? { scope: tokenData.scope } : null, + // A reconnect replaces the tokens, so any error from the old ones no longer applies. + lastError: null }); } catch (saveError) { console.error('Google Drive integration save failed:', saveError); diff --git a/src/routes/backup-settings/+page.svelte b/src/routes/backup-settings/+page.svelte index 3268345..5289797 100644 --- a/src/routes/backup-settings/+page.svelte +++ b/src/routes/backup-settings/+page.svelte @@ -1,6 +1,7 @@ + + + Using Offline - Living Dex Tracker + + +

    +

    Using Living Dex Tracker offline

    + +
    +
    +

    Your offline copy

    + {#if !$user} +

    + Sign in to keep a copy of your pokédexes on + this device. +

    + {:else} +
    + {#if $offlineSyncStatus.state === 'syncing'} +

    Updating your offline copy…

    + {:else if $offlineSyncStatus.state === 'error'} +
    + Your offline copy could not be refreshed: {$offlineSyncStatus.message} + +
    + {:else if $offlineSyncStatus.generatedAt} +

    + Offline copy updated {new Date($offlineSyncStatus.generatedAt).toLocaleString()}. +

    + {:else} +

    + No offline copy is saved on this device yet. It saves automatically while you are + online. +

    + {/if} +
    + +

    Artwork

    + {#if $artworkDownloadStatus.state === 'downloading'} +

    Saving all artwork for offline…

    + {:else if $artworkDownloadStatus.state === 'error'} +
    + Some artwork could not be saved: {$artworkDownloadStatus.message}. + +
    + {:else if $artworkDownloadStatus.state === 'done'} +

    All artwork is saved on this device.

    + {:else} +

    + Artwork is saved as you view it. To browse every Pokémon offline, including every form, + shiny and female variant, save it all now. +

    + {#if $artworkDownloadStatus.state === 'missing'} +
    + +
    + {/if} + {/if} + {/if} +
    +
    + +
    +
    +

    How it works

    +
      +
    • + Install the app. Use your browser's "Install app" or "Add to Home Screen" + option so Living Dex Tracker opens without a connection. +
    • +
    • + Stay signed in. Your offline copy updates automatically whenever you are online, + including after you make changes. +
    • +
    • + Offline is read-only. You can browse your pokédexes, but catches and edits + are disabled until you are back online. +
    • +
    • + Artwork. Sprites are saved as you view them. Use "Save all artwork for offline" + above to download the rest in one go. +
    • +
    • + Signing out removes the offline copy from this device. +
    • +
    +
    +
    +
    diff --git a/src/routes/pokedex/[id]/+page.svelte b/src/routes/pokedex/[id]/+page.svelte index 1bed083..9eea078 100644 --- a/src/routes/pokedex/[id]/+page.svelte +++ b/src/routes/pokedex/[id]/+page.svelte @@ -17,6 +17,14 @@ import type { Pokedex } from '$lib/models/Pokedex'; import type { PageData } from './$types'; import { requestOfflineSync } from '$lib/stores/offlineSync'; + import { get } from 'svelte/store'; + import { + PROVIDER_LABELS, + backupsNeedingReconnect, + markReconnectNeeded, + refreshBackupStatus + } from '$lib/stores/backupStatus'; + import type { ExportProvider } from '$lib/models/PokedexExportIntegration'; import type { SharedCombinedData } from '$lib/models/SharedPokedex'; export let data: PageData; @@ -66,6 +74,8 @@ lastSuccessfulFlushAt: null }; let lastOfflineSyncFlush: number | null = null; + // Backup providers that just refused this page's export because their access was revoked. + let reconnectToastLabels: string[] = []; let exportAfterFlush = false; let exportInFlight = false; let exportTimer: ReturnType | null = null; @@ -110,6 +120,24 @@ console.error('Auto-export failed:', response.status, body); return; } + const result = (await response.json().catch(() => null)) as { + failed?: Array<{ provider: ExportProvider; reconnectRequired?: boolean }>; + } | null; + const revoked = (result?.failed ?? []).filter((failure) => failure.reconnectRequired); + const alreadyPaused = new Set(get(backupsNeedingReconnect)); + if (revoked.length > 0) { + markReconnectNeeded(revoked.map((failure) => failure.provider)); + } else { + // Saving a catch record also exports on the server, and that export may already have + // paused a provider, leaving this export nothing to report. Re-read the status to catch it. + await refreshBackupStatus(); + } + const newlyPaused = get(backupsNeedingReconnect).filter( + (provider) => !alreadyPaused.has(provider) + ); + if (newlyPaused.length > 0) { + reconnectToastLabels = newlyPaused.map((provider) => PROVIDER_LABELS[provider]); + } if (exportGeneration === exportInFlightGeneration) { exportAfterFlush = false; } @@ -514,6 +542,24 @@ }); +{#if reconnectToastLabels.length > 0} + +
    + +
    +{/if} + {pokedex ? `${pokedex.name} - Living Dex Tracker` : 'Pokédex - Living Dex Tracker'} diff --git a/src/routes/welcome/+page.svelte b/src/routes/welcome/+page.svelte index ed09076..41d3888 100644 --- a/src/routes/welcome/+page.svelte +++ b/src/routes/welcome/+page.svelte @@ -306,6 +306,7 @@

    Works Offline

    Track your catches even without an internet connection

    + How to use offline diff --git a/tests/bdd/features/backup-export.feature b/tests/bdd/features/backup-export.feature index 55789a0..2abe453 100644 --- a/tests/bdd/features/backup-export.feature +++ b/tests/bdd/features/backup-export.feature @@ -40,3 +40,29 @@ Feature: Backup and export When I update collection progress Then the catch remains marked caught And the provider failure is shown in backup settings + And "Google Drive" is not flagged for reconnection + + Scenario Outline: Warn when a provider's access is revoked + Given "" is connected with a revoked refresh token + When I update collection progress + Then the Pokédex page tells me to reconnect "" + And I can dismiss the reconnect alert + And backup settings asks me to reconnect "" + And other pages warn that my "" backup has stopped + And later exports do not retry the revoked token + + Examples: + | provider | + | Google Drive | + | Dropbox | + + Scenario Outline: Reconnecting clears a previous backup error + Given "" previously lost access + When I connect the mocked "" provider + Then "" is shown as connected + And the previous backup error is cleared + + Examples: + | provider | + | Google Drive | + | Dropbox | diff --git a/tests/bdd/features/pwa-offline.feature b/tests/bdd/features/pwa-offline.feature index 8747995..58d5e40 100644 --- a/tests/bdd/features/pwa-offline.feature +++ b/tests/bdd/features/pwa-offline.feature @@ -26,6 +26,20 @@ Feature: Offline-friendly application When I go offline and reload the current Pokédex Then the offline copy contains "Offline Collection" + Scenario: Explain offline use on the offline guide + Given I am signed in + When I open the offline guide + Then the offline guide shows my offline copy status + + Scenario: Keep offline sync status off everyday pages + Given I am signed in + And I have a Living Dex named "Quiet Offline" + And my offline copy is synchronized + When I open the offline guide from the user menu + Then the offline guide shows when my offline copy was updated + When I return to my Pokédexes from the user menu + Then no offline sync status is shown + Scenario: Restore network access Given I have opened the built application online When I go offline and then return online diff --git a/tests/bdd/steps/backup.steps.ts b/tests/bdd/steps/backup.steps.ts index 7344501..e463603 100644 --- a/tests/bdd/steps/backup.steps.ts +++ b/tests/bdd/steps/backup.steps.ts @@ -12,6 +12,17 @@ const SUPABASE_URL = requireLoopbackUrl( type Provider = 'google_drive' | 'dropbox'; +// Outside CI Playwright reuses an already-running mock, which may predate a new control route. +// Fail loudly then, rather than letting the scenario run against the wrong mock behaviour. +async function mockControl(route: string) { + const response = await fetch(`${MOCK_URL}/__mock/${route}`); + if (!response.ok) { + throw new Error( + `Mock provider rejected /__mock/${route} (${response.status}). Stop any stale mock on port 4199 and rerun.` + ); + } +} + async function seedIntegration( state: import('../fixtures').ScenarioState, provider: Provider, @@ -67,10 +78,36 @@ Given('Dropbox is connected with an expired token', async ({ page, state }) => { Given('Google Drive is connected to a failing mocked provider', async ({ page, state }) => { await seedIntegration(state, 'google_drive'); - await fetch(`${MOCK_URL}/__mock/fail-uploads`); + await mockControl('fail-uploads'); await ensureExportDex(page, state); }); +const PROVIDERS: Record = { 'Google Drive': 'google_drive', Dropbox: 'dropbox' }; + +function providerFor(label: string): Provider { + const provider = PROVIDERS[label]; + if (!provider) throw new Error(`Unknown backup provider "${label}"`); + return provider; +} + +Given( + '{string} is connected with a revoked refresh token', + async ({ page, state }, label: string) => { + await seedIntegration(state, providerFor(label), { + accessTokenExpiresAt: new Date(Date.now() - 60_000).toISOString() + }); + await mockControl('revoke-refresh'); + await ensureExportDex(page, state); + } +); + +Given('{string} previously lost access', async ({ state }, label: string) => { + await seedIntegration(state, providerFor(label), { + enabled: false, + lastError: `${label} access has expired or was revoked. Reconnect ${label} to resume backups.` + }); +}); + When('I visit backup settings', async ({ page }) => { await page.goto('/backup-settings'); }); @@ -182,3 +219,65 @@ Then('the provider failure is shown in backup settings', async ({ page }) => { await page.goto('/backup-settings'); await expect(page.getByText(/mock upload failure/)).toBeVisible(); }); + +Then('the Pokédex page tells me to reconnect {string}', async ({ page }, label: string) => { + const toast = page.getByTestId('backup-reconnect-toast'); + await expect(toast).toBeVisible({ timeout: 15_000 }); + await expect(toast).toContainText(label); + await expect(toast.getByRole('link', { name: 'Reconnect' })).toHaveAttribute( + 'href', + '/backup-settings' + ); +}); + +Then('I can dismiss the reconnect alert', async ({ page }) => { + await page.getByTestId('backup-reconnect-toast').getByRole('button', { name: 'Dismiss' }).click(); + await expect(page.getByTestId('backup-reconnect-toast')).toHaveCount(0); + // Dismissing the one-off alert must not hide the standing sitewide warning. + await expect(page.getByTestId('backup-reconnect-banner')).toBeVisible(); +}); + +Then('backup settings asks me to reconnect {string}', async ({ page }, label: string) => { + await page.goto('/backup-settings'); + const card = page.locator('.border').filter({ hasText: label }); + await expect(card.getByText('Reconnect needed', { exact: true })).toBeVisible(); + await expect(card.getByText(/access has expired or was revoked/)).toBeVisible(); + // The settings page already explains the problem, so the sitewide banner stays out of the way. + await expect(page.getByTestId('backup-reconnect-banner')).toHaveCount(0); +}); + +Then('other pages warn that my {string} backup has stopped', async ({ page }, label: string) => { + await page.goto('/my-pokedexes'); + const banner = page.getByTestId('backup-reconnect-banner'); + await expect(banner).toBeVisible(); + await expect(banner).toContainText(label); + await expect(banner.getByRole('link', { name: 'Reconnect' })).toHaveAttribute( + 'href', + '/backup-settings' + ); +}); + +Then('{string} is not flagged for reconnection', async ({ page, state }, label: string) => { + await page.goto('/backup-settings'); + const card = page.locator('.border').filter({ hasText: label }); + await expect(card.getByText('Connected', { exact: true })).toBeVisible(); + await page.goto('/my-pokedexes'); + await expect(page.getByTestId('backup-reconnect-banner')).toHaveCount(0); + // A transient upload failure leaves the integration enabled, so the next export still tries it. + const response = await page.request.post(`/api/pokedexes/${state.pokedexId}/export`); + expect(await response.json()).toMatchObject({ attempted: 1 }); +}); + +Then('later exports do not retry the revoked token', async ({ page, state }) => { + const before = (await mockState()).refreshes; + const response = await page.request.post(`/api/pokedexes/${state.pokedexId}/export`); + expect(response.status()).toBe(200); + expect(await response.json()).toMatchObject({ attempted: 0 }); + expect((await mockState()).refreshes).toBe(before); +}); + +Then('the previous backup error is cleared', async ({ page }) => { + await expect(page.getByText(/access has expired or was revoked/)).toHaveCount(0); + await page.goto('/my-pokedexes'); + await expect(page.getByTestId('backup-reconnect-banner')).toHaveCount(0); +}); diff --git a/tests/bdd/steps/pwa.steps.ts b/tests/bdd/steps/pwa.steps.ts index 4ec42f6..2b40d01 100644 --- a/tests/bdd/steps/pwa.steps.ts +++ b/tests/bdd/steps/pwa.steps.ts @@ -104,6 +104,23 @@ When('I go offline and reload the current Pokédex', async ({ page, state }) => }); }); +When('I open the offline guide', async ({ page }) => { + await page.goto('/offline-guide'); +}); + +When('I open the offline guide from the user menu', async ({ page }) => { + await page.getByRole('button', { name: 'usericon' }).click(); + await page.getByRole('link', { name: 'Using Offline' }).click(); + await page.waitForURL(/\/offline-guide$/); +}); + +// Client-side navigation keeps the sync status in memory, so the old layout would show it at once. +When('I return to my Pokédexes from the user menu', async ({ page }) => { + await page.getByRole('button', { name: 'usericon' }).click(); + await page.getByRole('link', { name: 'My Pokédexes' }).click(); + await page.waitForURL(/\/my-pokedexes$/); +}); + When('I go offline and then return online', async ({ page }) => { await page.context().setOffline(true); await page.reload({ waitUntil: 'domcontentloaded' }); @@ -167,6 +184,26 @@ Then('the application remains available', async ({ page }) => { await expect(page.getByRole('heading', { name: /Start Your Pokédex Journey/ })).toBeVisible(); }); +Then('the offline guide shows my offline copy status', async ({ page }) => { + await expect( + page.getByRole('heading', { name: 'Using Living Dex Tracker offline' }) + ).toBeVisible(); + await expect(page.getByTestId('offline-copy-status')).toBeVisible(); +}); + +Then('the offline guide shows when my offline copy was updated', async ({ page }) => { + await expect(page.getByTestId('offline-copy-status')).toContainText(/Offline copy updated/, { + timeout: 30_000 + }); +}); + +Then('no offline sync status is shown', async ({ page }) => { + await expect(page.getByRole('heading', { name: /My Pok/ }).first()).toBeVisible(); + await expect( + page.getByText(/Offline copy updated|Updating offline copy|Save all artwork for offline/) + ).toHaveCount(0); +}); + Then('the read-only offline viewer is available', async ({ page }) => { await expect(page.getByText(/offline.*read-only/i)).toBeVisible(); }); diff --git a/tests/unit/backupStatus.test.ts b/tests/unit/backupStatus.test.ts new file mode 100644 index 0000000..1bdd3ce --- /dev/null +++ b/tests/unit/backupStatus.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { get } from 'svelte/store'; +import { + backupsNeedingReconnect, + clearBackupStatus, + markReconnectNeeded, + refreshBackupStatus, + setBackupStatus +} from '$lib/stores/backupStatus'; + +describe('backup reconnect status', () => { + beforeEach(() => clearBackupStatus()); + + it('lists only the providers that exports switched off', () => { + setBackupStatus([ + { provider: 'google_drive', enabled: false }, + { provider: 'dropbox', enabled: true } + ]); + expect(get(backupsNeedingReconnect)).toEqual(['google_drive']); + }); + + it('adds newly revoked providers without duplicating known ones', () => { + markReconnectNeeded(['google_drive']); + markReconnectNeeded(['google_drive', 'dropbox']); + expect(get(backupsNeedingReconnect)).toEqual(['google_drive', 'dropbox']); + }); + + it('clears everything, e.g. on sign-out', () => { + markReconnectNeeded(['dropbox']); + clearBackupStatus(); + expect(get(backupsNeedingReconnect)).toEqual([]); + }); +}); + +describe('refreshBackupStatus', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + clearBackupStatus(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('window', {}); + vi.stubGlobal('navigator', { onLine: true }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('loads the paused providers from the integrations API', async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify([ + { provider: 'google_drive', enabled: true }, + { provider: 'dropbox', enabled: false } + ]) + ) + ); + + await refreshBackupStatus(); + + expect(fetchMock).toHaveBeenCalledWith('/api/export-integrations', { + credentials: 'include' + }); + expect(get(backupsNeedingReconnect)).toEqual(['dropbox']); + }); + + it('clears a stale warning once the provider has been reconnected', async () => { + markReconnectNeeded(['google_drive']); + fetchMock.mockResolvedValue( + new Response(JSON.stringify([{ provider: 'google_drive', enabled: true }])) + ); + + await refreshBackupStatus(); + + expect(get(backupsNeedingReconnect)).toEqual([]); + }); + + it('does nothing while offline', async () => { + vi.stubGlobal('navigator', { onLine: false }); + markReconnectNeeded(['google_drive']); + + await refreshBackupStatus(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(get(backupsNeedingReconnect)).toEqual(['google_drive']); + }); + + it('does nothing during server rendering', async () => { + vi.stubGlobal('window', undefined); + + await refreshBackupStatus(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('keeps the last known status when the API rejects the request', async () => { + markReconnectNeeded(['google_drive']); + fetchMock.mockResolvedValue(new Response('Unauthorized', { status: 401 })); + + await refreshBackupStatus(); + + expect(get(backupsNeedingReconnect)).toEqual(['google_drive']); + }); + + it('keeps the last known status when the request fails', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + markReconnectNeeded(['dropbox']); + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); + + await refreshBackupStatus(); + + expect(get(backupsNeedingReconnect)).toEqual(['dropbox']); + expect(consoleError).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/pokedexExportIntegrations.test.ts b/tests/unit/pokedexExportIntegrations.test.ts new file mode 100644 index 0000000..739a730 --- /dev/null +++ b/tests/unit/pokedexExportIntegrations.test.ts @@ -0,0 +1,494 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { PokedexExportIntegration } from '$lib/models/PokedexExportIntegration'; + +const mocks = vi.hoisted(() => ({ + env: {} as Record, + pokedex: null as unknown, + integrations: [] as PokedexExportIntegration[], + updateExportStatus: vi.fn(), + updateTokens: vi.fn() +})); + +vi.mock('$lib/utils/env', () => ({ getEnv: () => mocks.env })); +vi.mock('$lib/repositories/PokedexRepository', () => ({ + default: vi.fn().mockImplementation(() => ({ findById: vi.fn(async () => mocks.pokedex) })) +})); +vi.mock('$lib/repositories/CombinedDataRepository', () => ({ + default: vi.fn().mockImplementation(() => ({ + findAllCombinedData: vi.fn().mockResolvedValue([]) + })) +})); +vi.mock('$lib/services/PokedexDexScopeService', () => ({ + resolveDexScopes: vi.fn().mockResolvedValue([]) +})); +vi.mock('$lib/repositories/PokedexExportIntegrationRepository', () => ({ + default: vi.fn().mockImplementation(() => ({ + listEnabledForPokedexOrUser: vi.fn(async () => mocks.integrations), + updateExportStatus: mocks.updateExportStatus, + updateTokens: mocks.updateTokens + })) +})); + +import { exportPokedexIfConfigured } from '$lib/services/PokedexExportService'; + +const supabase = {} as SupabaseClient; +const EXPIRED = () => new Date(Date.now() - 60_000).toISOString(); +const FRESH = () => new Date(Date.now() + 3_600_000).toISOString(); + +function integration(overrides: Partial = {}): PokedexExportIntegration { + return { + _id: 'google-1', + userId: 'user-1', + pokedexId: null, + provider: 'google_drive', + enabled: true, + fileName: null, + // A known folder skips the Drive folder lookup, keeping each test to token + upload calls. + folderId: 'folder-1', + path: null, + accessToken: 'old-access', + refreshToken: 'refresh', + accessTokenExpiresAt: EXPIRED(), + metadata: null, + lastExportedAt: null, + lastError: null, + ...overrides + }; +} + +type Reply = { status: number; body: unknown }; +const fetchMock = vi.fn(); + +/** Answers token requests with `token` and every other provider call with `upload`. */ +function stubProvider(token: Reply, upload: Reply = { status: 200, body: { id: 'file-1' } }) { + fetchMock.mockImplementation(async (input: string) => { + const reply = input.includes('/token') ? token : upload; + const body = typeof reply.body === 'string' ? reply.body : JSON.stringify(reply.body); + return new Response(body, { status: reply.status }); + }); +} + +const REVOKED: Reply = { + status: 400, + body: { error: 'invalid_grant', error_description: 'Bad Request' } +}; +const REFRESHED: Reply = { status: 200, body: { access_token: 'new-access', expires_in: 3600 } }; + +function uploadCalls() { + return fetchMock.mock.calls.filter(([url]) => !String(url).includes('/token')); +} + +/** Routes each provider call to a reply; returning a string or Error makes that fetch reject. */ +function routeFetch(handler: (url: string, init?: RequestInit) => Reply | Error | string) { + fetchMock.mockImplementation(async (input: string, init?: RequestInit) => { + const reply = handler(input, init); + if (typeof reply === 'string' || reply instanceof Error) throw reply; + const body = typeof reply.body === 'string' ? reply.body : JSON.stringify(reply.body); + return new Response(body, { status: reply.status }); + }); +} + +const FULL_ENV = { + GOOGLE_OAUTH_CLIENT_ID: 'google-id', + GOOGLE_OAUTH_CLIENT_SECRET: 'google-secret', + DROPBOX_OAUTH_CLIENT_ID: 'dropbox-id', + DROPBOX_OAUTH_CLIENT_SECRET: 'dropbox-secret' +}; +const DEX = { _id: 'dex-1', name: 'My Dex', isFormDex: false, gameScope: '' }; + +let consoleError: ReturnType; +let consoleWarn: ReturnType; + +beforeEach(() => { + mocks.env = { ...FULL_ENV }; + mocks.pokedex = DEX; + mocks.integrations = []; + mocks.updateExportStatus.mockReset(); + mocks.updateTokens.mockReset(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + // Only undo the spies: vi.restoreAllMocks() would also wipe the repository module mocks above. + consoleError.mockRestore(); + consoleWarn.mockRestore(); +}); + +describe('exportPokedexIfConfigured when a provider revokes access', () => { + it.each([ + ['google_drive', 'google-1', /Reconnect Google Drive/], + ['dropbox', 'dropbox-1', /Reconnect Dropbox/] + ] as const)('pauses %s when its refresh token is revoked', async (provider, id, message) => { + mocks.integrations = [integration({ _id: id, provider })]; + stubProvider(REVOKED); + + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(result).toMatchObject({ attempted: 1, succeeded: 0 }); + expect(result.failed).toEqual([ + { + integrationId: id, + provider, + error: expect.stringMatching(message), + reconnectRequired: true + } + ]); + expect(mocks.updateExportStatus).toHaveBeenCalledWith(id, { + lastError: expect.stringMatching(message), + enabled: false + }); + expect(uploadCalls()).toHaveLength(0); + expect(mocks.updateTokens).not.toHaveBeenCalled(); + }); + + it('pauses an integration that has no refresh token without calling the provider', async () => { + mocks.integrations = [integration({ refreshToken: null })]; + + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(result.failed[0]).toMatchObject({ reconnectRequired: true }); + expect(mocks.updateExportStatus).toHaveBeenCalledWith('google-1', { + lastError: expect.stringMatching(/Reconnect Google Drive/), + enabled: false + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + ['a server error', { status: 500, body: 'upstream down' }], + ['a different OAuth error', { status: 400, body: { error: 'invalid_client' } }] + ])('keeps the integration enabled when the refresh fails with %s', async (_label, token) => { + mocks.integrations = [integration()]; + stubProvider(token); + + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(result.failed[0]).toMatchObject({ + reconnectRequired: false, + error: expect.stringMatching(/^Google token refresh failed: /) + }); + // Exactly this patch: no `enabled` key, so a transient failure is retried on the next export. + expect(mocks.updateExportStatus).toHaveBeenCalledWith('google-1', { + lastError: expect.stringMatching(/^Google token refresh failed: /) + }); + }); + + it('keeps the integration enabled when only the upload fails', async () => { + mocks.integrations = [integration({ accessTokenExpiresAt: FRESH() })]; + stubProvider(REFRESHED, { status: 503, body: { error: 'mock upload failure' } }); + + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(result.failed[0]).toMatchObject({ reconnectRequired: false }); + expect(mocks.updateExportStatus).toHaveBeenCalledWith('google-1', { + lastError: expect.stringMatching(/Google Drive upload failed: 503/) + }); + }); + + it('refreshes an expired token, uploads, and clears the previous error', async () => { + mocks.integrations = [integration({ lastError: 'old failure' })]; + stubProvider(REFRESHED); + + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(result).toEqual({ attempted: 1, succeeded: 1, failed: [] }); + expect(mocks.updateTokens).toHaveBeenCalledWith('google-1', { + accessToken: 'new-access', + accessTokenExpiresAt: expect.any(String) + }); + expect(uploadCalls()[0][1].headers.Authorization).toBe('Bearer new-access'); + expect(mocks.updateExportStatus).toHaveBeenCalledWith('google-1', { + lastExportedAt: expect.any(String), + lastError: null + }); + }); + + it('pauses only the revoked provider when another one still works', async () => { + mocks.integrations = [ + integration(), + integration({ _id: 'dropbox-1', provider: 'dropbox', accessTokenExpiresAt: FRESH() }) + ]; + fetchMock.mockImplementation(async (input: string) => + input.includes('googleapis.com/token') + ? new Response(JSON.stringify(REVOKED.body), { status: 400 }) + : new Response(JSON.stringify({ id: 'file-1' }), { status: 200 }) + ); + + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(result).toMatchObject({ attempted: 2, succeeded: 1 }); + expect(result.failed.map((failure) => failure.provider)).toEqual(['google_drive']); + const pausedIds = mocks.updateExportStatus.mock.calls + .filter(([, patch]) => patch.enabled === false) + .map(([integrationId]) => integrationId); + expect(pausedIds).toEqual(['google-1']); + }); +}); + +const DRIVE_API = 'https://www.googleapis.com/drive/v3'; +const DRIVE_UPLOAD = 'https://www.googleapis.com/upload/drive/v3'; + +function isDriveUpload(url: string) { + return url.startsWith(DRIVE_UPLOAD); +} + +/** The JSON metadata part of a Drive multipart upload body. */ +function driveUploadMetadata(init?: RequestInit) { + return JSON.parse(String(init?.body).split('\r\n')[3]) as Record; +} + +function statusPatches() { + return mocks.updateExportStatus.mock.calls.map(([, patch]) => patch as Record); +} + +describe('exportPokedexIfConfigured provider paths', () => { + it('does nothing when the Pokédex no longer exists', async () => { + mocks.pokedex = null; + mocks.integrations = [integration()]; + + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(result).toEqual({ attempted: 0, succeeded: 0, failed: [] }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(mocks.updateExportStatus).not.toHaveBeenCalled(); + }); + + it('does nothing when no backup is connected', async () => { + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(result).toEqual({ attempted: 0, succeeded: 0, failed: [] }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + ['google_drive', 'GOOGLE_OAUTH_CLIENT_SECRET', 'Missing Google OAuth client credentials'], + ['dropbox', 'DROPBOX_OAUTH_CLIENT_ID', 'Missing Dropbox OAuth client credentials'] + ] as const)( + 'reports missing %s OAuth credentials without pausing the backup', + async (provider, envKey, error) => { + delete mocks.env[envKey]; + mocks.integrations = [integration({ provider })]; + + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(result.failed[0]).toMatchObject({ error, reconnectRequired: false }); + expect(fetchMock).not.toHaveBeenCalled(); + } + ); + + it('pauses Dropbox when it has no refresh token', async () => { + mocks.integrations = [integration({ provider: 'dropbox', refreshToken: null })]; + + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(result.failed[0]).toMatchObject({ + error: expect.stringMatching(/Reconnect Dropbox/), + reconnectRequired: true + }); + }); + + it('keeps Dropbox enabled when its token refresh fails for another reason', async () => { + mocks.integrations = [integration({ provider: 'dropbox' })]; + stubProvider({ status: 500, body: 'upstream down' }); + + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(result.failed[0]).toMatchObject({ + error: 'Dropbox token refresh failed: 500 upstream down', + reconnectRequired: false + }); + }); + + it('stores a refreshed token without an expiry when the provider omits one', async () => { + mocks.integrations = [integration({ _id: 'dropbox-1', provider: 'dropbox' })]; + stubProvider({ status: 200, body: { access_token: 'new-access' } }); + + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(result.succeeded).toBe(1); + expect(mocks.updateTokens).toHaveBeenCalledWith('dropbox-1', { + accessToken: 'new-access', + accessTokenExpiresAt: null + }); + expect(uploadCalls()[0][1].headers.Authorization).toBe('Bearer new-access'); + }); + + it.each([ + [null, '/My Dex.csv'], + ['/Backups/', '/Backups/My Dex.csv'], + ['/Backups', '/Backups/My Dex.csv'], + [' /Backups/custom.CSV ', '/Backups/custom.CSV'] + ])('uploads to Dropbox path %j as %s', async (path, expected) => { + mocks.integrations = [ + integration({ provider: 'dropbox', path, accessTokenExpiresAt: FRESH() }) + ]; + stubProvider(REFRESHED); + + await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + const headers = uploadCalls()[0][1].headers as Record; + expect(JSON.parse(headers['Dropbox-API-Arg'])).toEqual({ + path: expected, + mode: 'overwrite', + mute: true + }); + }); + + it('reports a failed Dropbox upload', async () => { + mocks.integrations = [integration({ provider: 'dropbox', accessTokenExpiresAt: FRESH() })]; + stubProvider(REFRESHED, { status: 500, body: 'boom' }); + + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(result.failed[0]).toMatchObject({ + error: 'Dropbox upload failed: 500 boom', + reconnectRequired: false + }); + }); + + it('finds the existing Living Dex Tracker folder in Drive', async () => { + mocks.integrations = [integration({ folderId: null, accessTokenExpiresAt: FRESH() })]; + routeFetch((url) => { + if (url.startsWith(`${DRIVE_API}/files?`)) + return { status: 200, body: { files: [{ id: 'found' }] } }; + if (isDriveUpload(url)) return { status: 200, body: { id: 'file-1' } }; + return { status: 500, body: `unexpected ${url}` }; + }); + + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(result.succeeded).toBe(1); + const [, init] = uploadCalls().find(([url]) => isDriveUpload(String(url)))!; + expect(driveUploadMetadata(init).parents).toEqual(['found']); + expect(statusPatches()).toContainEqual({ folderId: 'found' }); + expect(fetchMock.mock.calls.some(([url]) => url === `${DRIVE_API}/files`)).toBe(false); + }); + + it('creates the Living Dex Tracker folder when Drive has none', async () => { + mocks.integrations = [integration({ folderId: null, accessTokenExpiresAt: FRESH() })]; + routeFetch((url) => { + if (url.startsWith(`${DRIVE_API}/files?`)) return { status: 200, body: { files: [] } }; + if (url === `${DRIVE_API}/files`) return { status: 200, body: { id: 'created' } }; + return { status: 200, body: { id: 'file-1' } }; + }); + + await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + const [, init] = uploadCalls().find(([url]) => isDriveUpload(String(url)))!; + expect(driveUploadMetadata(init).parents).toEqual(['created']); + }); + + it.each([ + ['rejects the requests', { status: 500, body: 'nope' }], + ['cannot be reached', new TypeError('Failed to fetch')] + ])('uploads to the Drive root when the folder API %s', async (_label, folderReply) => { + mocks.integrations = [integration({ folderId: null, accessTokenExpiresAt: FRESH() })]; + routeFetch((url) => + isDriveUpload(url) ? { status: 200, body: { id: 'file-1' } } : folderReply + ); + + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(result.succeeded).toBe(1); + const [, init] = uploadCalls().find(([url]) => isDriveUpload(String(url)))!; + expect(driveUploadMetadata(init).parents).toBeUndefined(); + expect(statusPatches().some((patch) => 'folderId' in patch)).toBe(false); + expect(statusPatches()).toContainEqual({ metadata: { files: { 'dex-1': 'file-1' } } }); + }); + + it('updates the existing Drive file in place', async () => { + mocks.integrations = [ + integration({ accessTokenExpiresAt: FRESH(), metadata: { files: { 'dex-1': 'file-1' } } }) + ]; + stubProvider(REFRESHED, { status: 200, body: { id: 'file-1' } }); + + await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + const [url, init] = uploadCalls()[0]; + expect(url).toBe(`${DRIVE_UPLOAD}/files/file-1?uploadType=multipart&addParents=folder-1`); + expect(init.method).toBe('PATCH'); + expect(driveUploadMetadata(init).parents).toBeUndefined(); + expect(statusPatches().some((patch) => 'metadata' in patch)).toBe(false); + }); + + it.each([ + [ + 'the only saved file', + { scope: 's', files: { 'dex-1': 'stale' } }, + { scope: 's' }, + { scope: 's', files: { 'dex-1': 'new-file' } } + ], + [ + 'one of several saved files', + { files: { 'dex-1': 'stale', 'dex-2': 'other' } }, + { files: { 'dex-2': 'other' } }, + { files: { 'dex-1': 'new-file', 'dex-2': 'other' } } + ] + ])( + 'recreates a Drive file deleted by the user when it was %s', + async (_label, metadata, cleared, saved) => { + mocks.integrations = [integration({ accessTokenExpiresAt: FRESH(), metadata })]; + routeFetch((_url, init) => + init?.method === 'PATCH' + ? { status: 404, body: 'File not found' } + : { status: 200, body: { id: 'new-file' } } + ); + + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(result.succeeded).toBe(1); + expect(uploadCalls().map(([, init]) => init.method)).toEqual(['PATCH', 'POST']); + expect(driveUploadMetadata(uploadCalls()[1][1]).parents).toEqual(['folder-1']); + const metadataPatches = statusPatches().filter((patch) => 'metadata' in patch); + expect(metadataPatches).toEqual([{ metadata: cleared }, { metadata: saved }]); + } + ); + + it('gives up when the recreated Drive file is also missing', async () => { + mocks.integrations = [ + integration({ accessTokenExpiresAt: FRESH(), metadata: { files: { 'dex-1': 'stale' } } }) + ]; + stubProvider(REFRESHED, { status: 404, body: 'File not found' }); + + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(uploadCalls()).toHaveLength(2); + expect(result.failed[0]).toMatchObject({ + error: 'Google Drive upload failed: 404 File not found', + reconnectRequired: false + }); + }); + + it('records the export even when Drive returns no file id', async () => { + mocks.integrations = [integration({ accessTokenExpiresAt: FRESH() })]; + stubProvider(REFRESHED, { status: 200, body: {} }); + + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(result.succeeded).toBe(1); + expect(statusPatches()).toEqual([{ lastExportedAt: expect.any(String), lastError: null }]); + }); + + it('reports a failure that is not an Error as text', async () => { + mocks.integrations = [integration({ accessTokenExpiresAt: FRESH() })]; + routeFetch(() => 'network down'); + + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(result.failed[0]).toMatchObject({ error: 'network down', reconnectRequired: false }); + }); + + it('does nothing for a provider it does not support', async () => { + mocks.integrations = [integration({ provider: 'onedrive' as never })]; + + const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1'); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toMatchObject({ attempted: 1, failed: [] }); + }); +}); diff --git a/tests/unit/pokedexExportService.test.ts b/tests/unit/pokedexExportService.test.ts index ef24d67..93798ed 100644 --- a/tests/unit/pokedexExportService.test.ts +++ b/tests/unit/pokedexExportService.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { buildCsv, csvEscape, + isRevokedGrant, sanitizeFileName, shouldRefreshToken } from '$lib/services/PokedexExportFormatting'; @@ -59,6 +60,17 @@ describe('Pokédex export formatting', () => { ]); }); + it('recognises a revoked or expired refresh token', () => { + expect(isRevokedGrant(400, '{"error":"invalid_grant","error_description":"Bad Request"}')).toBe( + true + ); + expect(isRevokedGrant(401, '{"error":"invalid_grant"}')).toBe(true); + expect(isRevokedGrant(400, '{"error":"invalid_client"}')).toBe(false); + expect(isRevokedGrant(500, '{"error":"invalid_grant"}')).toBe(false); + expect(isRevokedGrant(400, 'Bad Request')).toBe(false); + expect(isRevokedGrant(400, 'null')).toBe(false); + }); + it('refreshes only finite expiries within the next minute', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-09-13T12:00:00Z')); diff --git a/vitest.config.mts b/vitest.config.mts index 04523a5..ccc7732 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -22,10 +22,10 @@ export default defineConfig({ exclude: ['src/lib/models/**', 'src/lib/stores/**', 'src/lib/actions/**'], // Set to the measured baseline. Ratchet these up as coverage grows; never down. thresholds: { - statements: 34.58, - functions: 73.68, - lines: 34.58, - branches: 79.79 + statements: 53.84, + functions: 82.89, + lines: 53.84, + branches: 86.93 } } }