mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-16 10:32:10 +00:00
fix(backup): pause revoked backups and tell users to reconnect
Google answers a revoked or expired refresh token with invalid_grant. Every save then retried the dead token, and reconnecting never cleared the old error, so it kept showing on Backup Settings afterwards. - The Google Drive and Dropbox OAuth callbacks clear lastError when a provider is reconnected. - An invalid_grant, or a missing refresh token, now pauses the integration with a readable "reconnect" message instead of retrying it on every catch update. Other failures still retry as before. - A banner on every page and an alert on the Pokédex page point to Backup Settings, which shows a "Reconnect needed" badge. The Pokédex page re-checks backup status after each export, because saving a catch record also exports on the server and may pause a provider first. - Offline sync status and the "Save all artwork" link move from every page to a new /offline-guide page, linked from the user menu and the home and welcome pages. Only the offline read-only banner stays sitewide. - Unit tests cover every export path and the backup status store. BDD covers revocation and reconnecting for both providers, and the offline guide. The mock provider can now reject token refreshes, and mock control calls fail loudly if a stale mock is reused. Coverage thresholds are raised to the new baseline.
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
|
|
||||||
const port = Number(process.env.MOCK_PROVIDER_PORT ?? 4199);
|
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 = {}) {
|
function send(response, status, body, headers = {}) {
|
||||||
response.writeHead(status, { 'Content-Type': 'application/json', ...headers });
|
response.writeHead(status, { 'Content-Type': 'application/json', ...headers });
|
||||||
@@ -25,6 +25,7 @@ const server = createServer(async (request, response) => {
|
|||||||
if (url.pathname === '/__mock/reset') {
|
if (url.pathname === '/__mock/reset') {
|
||||||
state.requests = [];
|
state.requests = [];
|
||||||
state.failUploads = false;
|
state.failUploads = false;
|
||||||
|
state.revokeRefresh = false;
|
||||||
state.refreshes = 0;
|
state.refreshes = 0;
|
||||||
return send(response, 200, { ok: true });
|
return send(response, 200, { ok: true });
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,10 @@ const server = createServer(async (request, response) => {
|
|||||||
state.failUploads = true;
|
state.failUploads = true;
|
||||||
return send(response, 200, { ok: 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')) {
|
if (url.pathname.endsWith('/authorize')) {
|
||||||
const redirectUri = url.searchParams.get('redirect_uri');
|
const redirectUri = url.searchParams.get('redirect_uri');
|
||||||
@@ -45,7 +50,13 @@ const server = createServer(async (request, response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname.endsWith('/token')) {
|
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, {
|
return send(response, 200, {
|
||||||
access_token: 'mock-access-token',
|
access_token: 'mock-access-token',
|
||||||
refresh_token: 'mock-refresh-token',
|
refresh_token: 'mock-refresh-token',
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ class PokedexExportIntegrationRepository {
|
|||||||
async updateExportStatus(
|
async updateExportStatus(
|
||||||
id: string,
|
id: string,
|
||||||
patch: {
|
patch: {
|
||||||
|
enabled?: boolean;
|
||||||
lastExportedAt?: string | null;
|
lastExportedAt?: string | null;
|
||||||
lastError?: string | null;
|
lastError?: string | null;
|
||||||
metadata?: Record<string, unknown> | null;
|
metadata?: Record<string, unknown> | null;
|
||||||
|
|||||||
@@ -54,6 +54,17 @@ export function buildCsv(combinedData: CombinedData[]): string {
|
|||||||
return lines.join('\r\n');
|
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 {
|
export function shouldRefreshToken(expiresAt: string | null): boolean {
|
||||||
if (!expiresAt) return false;
|
if (!expiresAt) return false;
|
||||||
const expiry = new Date(expiresAt).getTime();
|
const expiry = new Date(expiresAt).getTime();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { getEnv } from '$lib/utils/env';
|
|||||||
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
|
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
|
||||||
import {
|
import {
|
||||||
buildCsv,
|
buildCsv,
|
||||||
|
isRevokedGrant,
|
||||||
sanitizeFileName,
|
sanitizeFileName,
|
||||||
shouldRefreshToken
|
shouldRefreshToken
|
||||||
} from '$lib/services/PokedexExportFormatting';
|
} from '$lib/services/PokedexExportFormatting';
|
||||||
@@ -21,6 +22,7 @@ type ExportFailure = {
|
|||||||
integrationId: string;
|
integrationId: string;
|
||||||
provider: ExportProvider;
|
provider: ExportProvider;
|
||||||
error: string;
|
error: string;
|
||||||
|
reconnectRequired: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PokedexExportResult = {
|
export type PokedexExportResult = {
|
||||||
@@ -29,12 +31,21 @@ export type PokedexExportResult = {
|
|||||||
failed: ExportFailure[];
|
failed: ExportFailure[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RECONNECT_MESSAGES: Record<ExportProvider, string> = {
|
||||||
|
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(
|
async function refreshGoogleToken(
|
||||||
integration: PokedexExportIntegration,
|
integration: PokedexExportIntegration,
|
||||||
repo: PokedexExportIntegrationRepository
|
repo: PokedexExportIntegrationRepository
|
||||||
): Promise<PokedexExportIntegration> {
|
): Promise<PokedexExportIntegration> {
|
||||||
if (!integration.refreshToken) {
|
if (!integration.refreshToken) {
|
||||||
throw new Error('Missing Google refresh token');
|
throw new ReconnectRequiredError(RECONNECT_MESSAGES.google_drive);
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
@@ -59,6 +70,9 @@ async function refreshGoogleToken(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
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}`);
|
throw new Error(`Google token refresh failed: ${response.status} ${text}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +102,7 @@ async function refreshDropboxToken(
|
|||||||
repo: PokedexExportIntegrationRepository
|
repo: PokedexExportIntegrationRepository
|
||||||
): Promise<PokedexExportIntegration> {
|
): Promise<PokedexExportIntegration> {
|
||||||
if (!integration.refreshToken) {
|
if (!integration.refreshToken) {
|
||||||
throw new Error('Missing Dropbox refresh token');
|
throw new ReconnectRequiredError(RECONNECT_MESSAGES.dropbox);
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
@@ -113,6 +127,9 @@ async function refreshDropboxToken(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
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}`);
|
throw new Error(`Dropbox token refresh failed: ${response.status} ${text}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,13 +435,17 @@ export async function exportPokedexIfConfigured(
|
|||||||
successes++;
|
successes++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(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({
|
failures.push({
|
||||||
integrationId: integration._id,
|
integrationId: integration._id,
|
||||||
provider: integration.provider,
|
provider: integration.provider,
|
||||||
error: message
|
error: message,
|
||||||
|
reconnectRequired
|
||||||
});
|
});
|
||||||
await scopedRepo.updateExportStatus(integration._id, {
|
await scopedRepo.updateExportStatus(integration._id, {
|
||||||
lastError: message
|
lastError: message,
|
||||||
|
...(reconnectRequired ? { enabled: false } : {})
|
||||||
});
|
});
|
||||||
console.error('Failed to export pokedex:', integration.provider, message);
|
console.error('Failed to export pokedex:', integration.provider, message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import type { ExportProvider } from '$lib/models/PokedexExportIntegration';
|
||||||
|
|
||||||
|
export const PROVIDER_LABELS: Record<ExportProvider, string> = {
|
||||||
|
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<ExportProvider[]>([]);
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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([]);
|
||||||
|
}
|
||||||
+27
-38
@@ -6,14 +6,14 @@
|
|||||||
import SignIn from '$lib/components/SignIn.svelte';
|
import SignIn from '$lib/components/SignIn.svelte';
|
||||||
import SignOut from '$lib/components/SignOut.svelte';
|
import SignOut from '$lib/components/SignOut.svelte';
|
||||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { claimOfflineData, requestOfflineSync, startOfflineSync } from '$lib/stores/offlineSync';
|
||||||
import {
|
import {
|
||||||
artworkDownloadStatus,
|
PROVIDER_LABELS,
|
||||||
claimOfflineData,
|
backupsNeedingReconnect,
|
||||||
downloadAllArtwork,
|
clearBackupStatus,
|
||||||
offlineSyncStatus,
|
refreshBackupStatus
|
||||||
requestOfflineSync,
|
} from '$lib/stores/backupStatus';
|
||||||
startOfflineSync
|
|
||||||
} from '$lib/stores/offlineSync';
|
|
||||||
|
|
||||||
import { pwaInfo } from 'virtual:pwa-info';
|
import { pwaInfo } from 'virtual:pwa-info';
|
||||||
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
|
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
|
||||||
@@ -57,7 +57,10 @@
|
|||||||
updateOnlineState();
|
updateOnlineState();
|
||||||
void getUser()
|
void getUser()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
if (localUser) await claimOfflineData(localUser.id);
|
if (localUser) {
|
||||||
|
void refreshBackupStatus();
|
||||||
|
await claimOfflineData(localUser.id);
|
||||||
|
}
|
||||||
stopOfflineSync = startOfflineSync(() => localUser?.id ?? null);
|
stopOfflineSync = startOfflineSync(() => localUser?.id ?? null);
|
||||||
})
|
})
|
||||||
.catch((error) => console.error('Unable to claim offline data', error));
|
.catch((error) => console.error('Unable to claim offline data', error));
|
||||||
@@ -75,12 +78,14 @@
|
|||||||
void claimOfflineData(session.user.id)
|
void claimOfflineData(session.user.id)
|
||||||
.then(requestOfflineSync)
|
.then(requestOfflineSync)
|
||||||
.catch((error) => console.error('Unable to claim offline data', error));
|
.catch((error) => console.error('Unable to claim offline data', error));
|
||||||
|
void refreshBackupStatus();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Offline data is only cleared by the Sign Out button (or another account claiming it).
|
// 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
|
// An expired or rejected session must not throw away artwork that would then have to be
|
||||||
// downloaded again after signing back in.
|
// downloaded again after signing back in.
|
||||||
localUser = null;
|
localUser = null;
|
||||||
|
clearBackupStatus();
|
||||||
}
|
}
|
||||||
user.set(localUser);
|
user.set(localUser);
|
||||||
});
|
});
|
||||||
@@ -98,10 +103,7 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatMegabytes(bytes: number) {
|
$: reconnectLabels = $backupsNeedingReconnect.map((provider) => PROVIDER_LABELS[provider]);
|
||||||
const megabytes = bytes / 1048576;
|
|
||||||
return megabytes < 1 ? '<1 MB' : `≈${Math.round(megabytes)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getUser() {
|
async function getUser() {
|
||||||
const {
|
const {
|
||||||
@@ -192,6 +194,9 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="/backup-settings"> Backup Settings </a>
|
<a href="/backup-settings"> Backup Settings </a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/offline-guide"> Using Offline </a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<SignOut
|
<SignOut
|
||||||
{supabase}
|
{supabase}
|
||||||
@@ -222,35 +227,19 @@
|
|||||||
<div class="alert rounded-none" role="status">
|
<div class="alert rounded-none" role="status">
|
||||||
<span>Offline read-only mode: saved data remains available, but changes are disabled.</span>
|
<span>Offline read-only mode: saved data remains available, but changes are disabled.</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if localUser && $offlineSyncStatus.state === 'error'}
|
{/if}
|
||||||
<div class="alert alert-warning rounded-none" role="status">
|
{#if localUser && reconnectLabels.length > 0 && $page.url.pathname !== '/backup-settings'}
|
||||||
<span>Offline copy could not be refreshed: {$offlineSyncStatus.message}</span>
|
<div
|
||||||
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
|
class="alert alert-warning rounded-none"
|
||||||
</div>
|
role="alert"
|
||||||
{:else if localUser && $artworkDownloadStatus.state === 'error'}
|
data-testid="backup-reconnect-banner"
|
||||||
<div class="alert alert-warning rounded-none" role="status">
|
|
||||||
<span
|
|
||||||
>Offline data is saved, but artwork could not be saved: {$artworkDownloadStatus.message}.</span
|
|
||||||
>
|
>
|
||||||
<button class="btn btn-sm" on:click={downloadAllArtwork}>Retry</button>
|
<span>
|
||||||
|
Your {reconnectLabels.join(' and ')} backup has stopped because access expired or was revoked.
|
||||||
|
</span>
|
||||||
|
<a class="btn btn-sm" href="/backup-settings">Reconnect</a>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if isOnline && localUser && $offlineSyncStatus.state === 'syncing'}
|
|
||||||
<p class="bg-base-200 px-4 py-1 text-center text-xs" role="status">Updating offline copy…</p>
|
|
||||||
{:else if isOnline && localUser && $offlineSyncStatus.generatedAt}
|
|
||||||
<p class="bg-base-200 px-4 py-1 text-center text-xs" role="status">
|
|
||||||
Offline copy updated {new Date($offlineSyncStatus.generatedAt).toLocaleString()}.
|
|
||||||
{#if $artworkDownloadStatus.state === 'downloading'}
|
|
||||||
Saving all artwork for offline…
|
|
||||||
{:else if $artworkDownloadStatus.state === 'missing'}
|
|
||||||
<button class="link" on:click={downloadAllArtwork}>
|
|
||||||
Save all artwork for offline{#if $artworkDownloadStatus.missingBytes}{' '}({formatMegabytes(
|
|
||||||
$artworkDownloadStatus.missingBytes
|
|
||||||
)}){/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<main class="flex-grow">
|
<main class="flex-grow">
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -115,7 +115,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Open Source
|
Open Source
|
||||||
</div>
|
</div>
|
||||||
<div class="badge badge-accent badge-lg gap-1">
|
<a href="/offline-guide" class="badge badge-accent badge-lg gap-1 hover:opacity-80">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Offline-friendly
|
Offline-friendly
|
||||||
</div>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-5xl font-bold mb-6">Start Your Pokédex Journey!</h1>
|
<h1 class="text-5xl font-bold mb-6">Start Your Pokédex Journey!</h1>
|
||||||
<p class="text-xl mb-6 text-base-content/80">
|
<p class="text-xl mb-6 text-base-content/80">
|
||||||
|
|||||||
@@ -98,7 +98,9 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
accessToken: tokenData.access_token,
|
accessToken: tokenData.access_token,
|
||||||
refreshToken: tokenData.refresh_token ?? null,
|
refreshToken: tokenData.refresh_token ?? null,
|
||||||
accessTokenExpiresAt: expiresAt,
|
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) {
|
} catch (saveError) {
|
||||||
console.error('Dropbox integration save failed:', saveError);
|
console.error('Dropbox integration save failed:', saveError);
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
accessToken: tokenData.access_token,
|
accessToken: tokenData.access_token,
|
||||||
refreshToken: tokenData.refresh_token ?? null,
|
refreshToken: tokenData.refresh_token ?? null,
|
||||||
accessTokenExpiresAt: expiresAt,
|
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) {
|
} catch (saveError) {
|
||||||
console.error('Google Drive integration save failed:', saveError);
|
console.error('Google Drive integration save failed:', saveError);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import { setBackupStatus } from '$lib/stores/backupStatus';
|
||||||
|
|
||||||
type ExportIntegrationSummary = {
|
type ExportIntegrationSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -40,6 +41,8 @@
|
|||||||
exportIntegrations = (await response.json()) as ExportIntegrationSummary[];
|
exportIntegrations = (await response.json()) as ExportIntegrationSummary[];
|
||||||
googleIntegration = exportIntegrations.find((i) => i.provider === 'google_drive');
|
googleIntegration = exportIntegrations.find((i) => i.provider === 'google_drive');
|
||||||
dropboxIntegration = exportIntegrations.find((i) => i.provider === 'dropbox');
|
dropboxIntegration = exportIntegrations.find((i) => i.provider === 'dropbox');
|
||||||
|
// Keeps the sitewide banner in step, e.g. clearing it after the OAuth flow returns here.
|
||||||
|
setBackupStatus(exportIntegrations);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
exportError = message || 'Failed to load export settings';
|
exportError = message || 'Failed to load export settings';
|
||||||
@@ -48,6 +51,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusBadge(integration: ExportIntegrationSummary | undefined) {
|
||||||
|
if (!integration) return { label: 'Not Connected', className: 'badge-ghost' };
|
||||||
|
// Exports switch an integration off when the provider revokes its access.
|
||||||
|
if (!integration.enabled) return { label: 'Reconnect needed', className: 'badge-warning' };
|
||||||
|
return { label: 'Connected', className: 'badge-success' };
|
||||||
|
}
|
||||||
|
|
||||||
|
$: googleBadge = statusBadge(googleIntegration);
|
||||||
|
$: dropboxBadge = statusBadge(dropboxIntegration);
|
||||||
|
|
||||||
function getGoogleFolderUrl(folderId: string): string {
|
function getGoogleFolderUrl(folderId: string): string {
|
||||||
return `https://drive.google.com/drive/folders/${folderId}`;
|
return `https://drive.google.com/drive/folders/${folderId}`;
|
||||||
}
|
}
|
||||||
@@ -106,13 +119,14 @@
|
|||||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="font-semibold">Google Drive</h2>
|
<h2 class="font-semibold">Google Drive</h2>
|
||||||
<span class={`badge ${googleIntegration ? 'badge-success' : 'badge-ghost'}`}>
|
<span class={`badge ${googleBadge.className}`}>{googleBadge.label}</span>
|
||||||
{googleIntegration ? 'Connected' : 'Not Connected'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 space-y-2">
|
<div class="mt-3 space-y-2">
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<button class="btn btn-sm btn-outline" on:click={connectGoogleDrive}>
|
<button
|
||||||
|
class={`btn btn-sm ${googleIntegration?.enabled === false ? 'btn-primary' : 'btn-outline'}`}
|
||||||
|
on:click={connectGoogleDrive}
|
||||||
|
>
|
||||||
{googleIntegration ? 'Reconnect' : 'Connect'}
|
{googleIntegration ? 'Reconnect' : 'Connect'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,13 +156,14 @@
|
|||||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="font-semibold">Dropbox</h2>
|
<h2 class="font-semibold">Dropbox</h2>
|
||||||
<span class={`badge ${dropboxIntegration ? 'badge-success' : 'badge-ghost'}`}>
|
<span class={`badge ${dropboxBadge.className}`}>{dropboxBadge.label}</span>
|
||||||
{dropboxIntegration ? 'Connected' : 'Not Connected'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 space-y-2">
|
<div class="mt-3 space-y-2">
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<button class="btn btn-sm btn-outline" on:click={connectDropbox}>
|
<button
|
||||||
|
class={`btn btn-sm ${dropboxIntegration?.enabled === false ? 'btn-primary' : 'btn-outline'}`}
|
||||||
|
on:click={connectDropbox}
|
||||||
|
>
|
||||||
{dropboxIntegration ? 'Reconnect' : 'Connect'}
|
{dropboxIntegration ? 'Reconnect' : 'Connect'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { user } from '$lib/stores/user.js';
|
||||||
|
import {
|
||||||
|
artworkDownloadStatus,
|
||||||
|
downloadAllArtwork,
|
||||||
|
offlineSyncStatus,
|
||||||
|
requestOfflineSync
|
||||||
|
} from '$lib/stores/offlineSync';
|
||||||
|
|
||||||
|
function formatMegabytes(bytes: number) {
|
||||||
|
const megabytes = bytes / 1048576;
|
||||||
|
return megabytes < 1 ? '<1 MB' : `≈${Math.round(megabytes)} MB`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Using Offline - Living Dex Tracker</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-4 max-w-screen-lg">
|
||||||
|
<h1 class="text-3xl font-bold mb-6">Using Living Dex Tracker offline</h1>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">Your offline copy</h2>
|
||||||
|
{#if !$user}
|
||||||
|
<p>
|
||||||
|
<a href="/signin" class="link link-primary">Sign in</a> to keep a copy of your pokédexes on
|
||||||
|
this device.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div data-testid="offline-copy-status" role="status">
|
||||||
|
{#if $offlineSyncStatus.state === 'syncing'}
|
||||||
|
<p>Updating your offline copy…</p>
|
||||||
|
{:else if $offlineSyncStatus.state === 'error'}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<span>Your offline copy could not be refreshed: {$offlineSyncStatus.message}</span>
|
||||||
|
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if $offlineSyncStatus.generatedAt}
|
||||||
|
<p>
|
||||||
|
Offline copy updated {new Date($offlineSyncStatus.generatedAt).toLocaleString()}.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p>
|
||||||
|
No offline copy is saved on this device yet. It saves automatically while you are
|
||||||
|
online.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="font-semibold mt-4">Artwork</h3>
|
||||||
|
{#if $artworkDownloadStatus.state === 'downloading'}
|
||||||
|
<p role="status">Saving all artwork for offline…</p>
|
||||||
|
{:else if $artworkDownloadStatus.state === 'error'}
|
||||||
|
<div class="alert alert-warning" role="status">
|
||||||
|
<span>Some artwork could not be saved: {$artworkDownloadStatus.message}.</span>
|
||||||
|
<button class="btn btn-sm" on:click={downloadAllArtwork}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if $artworkDownloadStatus.state === 'done'}
|
||||||
|
<p>All artwork is saved on this device.</p>
|
||||||
|
{:else}
|
||||||
|
<p>
|
||||||
|
Artwork is saved as you view it. To browse every Pokémon offline, including every form,
|
||||||
|
shiny and female variant, save it all now.
|
||||||
|
</p>
|
||||||
|
{#if $artworkDownloadStatus.state === 'missing'}
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary btn-sm" on:click={downloadAllArtwork}>
|
||||||
|
Save all artwork for offline{#if $artworkDownloadStatus.missingBytes}{' '}({formatMegabytes(
|
||||||
|
$artworkDownloadStatus.missingBytes
|
||||||
|
)}){/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">How it works</h2>
|
||||||
|
<ul class="list-disc pl-5 space-y-2">
|
||||||
|
<li>
|
||||||
|
<strong>Install the app.</strong> Use your browser's "Install app" or "Add to Home Screen"
|
||||||
|
option so Living Dex Tracker opens without a connection.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Stay signed in.</strong> Your offline copy updates automatically whenever you are online,
|
||||||
|
including after you make changes.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Offline is read-only.</strong> You can browse your pokédexes, but catches and edits
|
||||||
|
are disabled until you are back online.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Artwork.</strong> Sprites are saved as you view them. Use "Save all artwork for offline"
|
||||||
|
above to download the rest in one go.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Signing out</strong> removes the offline copy from this device.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -17,6 +17,14 @@
|
|||||||
import type { Pokedex } from '$lib/models/Pokedex';
|
import type { Pokedex } from '$lib/models/Pokedex';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { requestOfflineSync } from '$lib/stores/offlineSync';
|
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';
|
import type { SharedCombinedData } from '$lib/models/SharedPokedex';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
@@ -66,6 +74,8 @@
|
|||||||
lastSuccessfulFlushAt: null
|
lastSuccessfulFlushAt: null
|
||||||
};
|
};
|
||||||
let lastOfflineSyncFlush: number | null = 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 exportAfterFlush = false;
|
||||||
let exportInFlight = false;
|
let exportInFlight = false;
|
||||||
let exportTimer: ReturnType<typeof setTimeout> | null = null;
|
let exportTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -110,6 +120,24 @@
|
|||||||
console.error('Auto-export failed:', response.status, body);
|
console.error('Auto-export failed:', response.status, body);
|
||||||
return;
|
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) {
|
if (exportGeneration === exportInFlightGeneration) {
|
||||||
exportAfterFlush = false;
|
exportAfterFlush = false;
|
||||||
}
|
}
|
||||||
@@ -514,6 +542,24 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if reconnectToastLabels.length > 0}
|
||||||
|
<!-- Above DaisyUI's modal (z-index 999) so the alert stays usable over an open Pokémon dialog. -->
|
||||||
|
<div class="toast toast-end z-[1000]">
|
||||||
|
<div class="alert alert-warning" role="alert" data-testid="backup-reconnect-toast">
|
||||||
|
<span>
|
||||||
|
Backups to {reconnectToastLabels.join(' and ')} have stopped because access expired or was revoked.
|
||||||
|
</span>
|
||||||
|
<a class="btn btn-sm" href="/backup-settings">Reconnect</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
on:click={() => (reconnectToastLabels = [])}>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{pokedex ? `${pokedex.name} - Living Dex Tracker` : 'Pokédex - Living Dex Tracker'}</title>
|
<title>{pokedex ? `${pokedex.name} - Living Dex Tracker` : 'Pokédex - Living Dex Tracker'}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|||||||
@@ -306,6 +306,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<h4 class="font-semibold">Works Offline</h4>
|
<h4 class="font-semibold">Works Offline</h4>
|
||||||
<p class="text-sm opacity-70">Track your catches even without an internet connection</p>
|
<p class="text-sm opacity-70">Track your catches even without an internet connection</p>
|
||||||
|
<a href="/offline-guide" class="link link-primary text-sm">How to use offline</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,3 +40,29 @@ Feature: Backup and export
|
|||||||
When I update collection progress
|
When I update collection progress
|
||||||
Then the catch remains marked caught
|
Then the catch remains marked caught
|
||||||
And the provider failure is shown in backup settings
|
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 "<provider>" is connected with a revoked refresh token
|
||||||
|
When I update collection progress
|
||||||
|
Then the Pokédex page tells me to reconnect "<provider>"
|
||||||
|
And I can dismiss the reconnect alert
|
||||||
|
And backup settings asks me to reconnect "<provider>"
|
||||||
|
And other pages warn that my "<provider>" 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 "<provider>" previously lost access
|
||||||
|
When I connect the mocked "<provider>" provider
|
||||||
|
Then "<provider>" is shown as connected
|
||||||
|
And the previous backup error is cleared
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| provider |
|
||||||
|
| Google Drive |
|
||||||
|
| Dropbox |
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ Feature: Offline-friendly application
|
|||||||
When I go offline and reload the current Pokédex
|
When I go offline and reload the current Pokédex
|
||||||
Then the offline copy contains "Offline Collection"
|
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
|
Scenario: Restore network access
|
||||||
Given I have opened the built application online
|
Given I have opened the built application online
|
||||||
When I go offline and then return online
|
When I go offline and then return online
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ const SUPABASE_URL = requireLoopbackUrl(
|
|||||||
|
|
||||||
type Provider = 'google_drive' | 'dropbox';
|
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(
|
async function seedIntegration(
|
||||||
state: import('../fixtures').ScenarioState,
|
state: import('../fixtures').ScenarioState,
|
||||||
provider: Provider,
|
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 }) => {
|
Given('Google Drive is connected to a failing mocked provider', async ({ page, state }) => {
|
||||||
await seedIntegration(state, 'google_drive');
|
await seedIntegration(state, 'google_drive');
|
||||||
await fetch(`${MOCK_URL}/__mock/fail-uploads`);
|
await mockControl('fail-uploads');
|
||||||
await ensureExportDex(page, state);
|
await ensureExportDex(page, state);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const PROVIDERS: Record<string, Provider> = { '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 }) => {
|
When('I visit backup settings', async ({ page }) => {
|
||||||
await page.goto('/backup-settings');
|
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 page.goto('/backup-settings');
|
||||||
await expect(page.getByText(/mock upload failure/)).toBeVisible();
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 }) => {
|
When('I go offline and then return online', async ({ page }) => {
|
||||||
await page.context().setOffline(true);
|
await page.context().setOffline(true);
|
||||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
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();
|
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 }) => {
|
Then('the read-only offline viewer is available', async ({ page }) => {
|
||||||
await expect(page.getByText(/offline.*read-only/i)).toBeVisible();
|
await expect(page.getByText(/offline.*read-only/i)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string | undefined>,
|
||||||
|
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> = {}): 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<typeof vi.spyOn>;
|
||||||
|
let consoleWarn: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusPatches() {
|
||||||
|
return mocks.updateExportStatus.mock.calls.map(([, patch]) => patch as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string>;
|
||||||
|
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: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
buildCsv,
|
buildCsv,
|
||||||
csvEscape,
|
csvEscape,
|
||||||
|
isRevokedGrant,
|
||||||
sanitizeFileName,
|
sanitizeFileName,
|
||||||
shouldRefreshToken
|
shouldRefreshToken
|
||||||
} from '$lib/services/PokedexExportFormatting';
|
} 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', () => {
|
it('refreshes only finite expiries within the next minute', () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date('2026-09-13T12:00:00Z'));
|
vi.setSystemTime(new Date('2026-09-13T12:00:00Z'));
|
||||||
|
|||||||
+4
-4
@@ -22,10 +22,10 @@ export default defineConfig({
|
|||||||
exclude: ['src/lib/models/**', 'src/lib/stores/**', 'src/lib/actions/**'],
|
exclude: ['src/lib/models/**', 'src/lib/stores/**', 'src/lib/actions/**'],
|
||||||
// Set to the measured baseline. Ratchet these up as coverage grows; never down.
|
// Set to the measured baseline. Ratchet these up as coverage grows; never down.
|
||||||
thresholds: {
|
thresholds: {
|
||||||
statements: 34.58,
|
statements: 53.84,
|
||||||
functions: 73.68,
|
functions: 82.89,
|
||||||
lines: 34.58,
|
lines: 53.84,
|
||||||
branches: 79.79
|
branches: 86.93
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user