mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-17 11:02:06 +00:00
Merge pull request #97 from jcreek/fix/backup-reconnect-and-offline-guide
fix(backup): pause revoked backups and tell users to reconnect
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface PokedexExportIntegration {
|
||||
metadata: Record<string, unknown> | null;
|
||||
lastExportedAt: string | null;
|
||||
lastError: string | null;
|
||||
/** Set by a database trigger on every write, so it doubles as the row's version. */
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface PokedexExportIntegrationDB {
|
||||
@@ -32,4 +34,5 @@ export interface PokedexExportIntegrationDB {
|
||||
metadata: Record<string, unknown> | null;
|
||||
lastExportedAt: string | null;
|
||||
lastError: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ class PokedexExportIntegrationRepository {
|
||||
accessTokenExpiresAt: db.accessTokenExpiresAt,
|
||||
metadata: db.metadata,
|
||||
lastExportedAt: db.lastExportedAt,
|
||||
lastError: db.lastError
|
||||
lastError: db.lastError,
|
||||
updatedAt: db.updatedAt ?? null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,28 +137,38 @@ class PokedexExportIntegrationRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a row was updated. With `ifUpdatedAt`, the write only applies if the row is
|
||||
* unchanged since it was read, so a stale export can't overwrite credentials a reconnect saved.
|
||||
*/
|
||||
async updateExportStatus(
|
||||
id: string,
|
||||
patch: {
|
||||
enabled?: boolean;
|
||||
lastExportedAt?: string | null;
|
||||
lastError?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
folderId?: string | null;
|
||||
path?: string | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
const query = this.supabase
|
||||
},
|
||||
ifUpdatedAt?: string
|
||||
): Promise<boolean> {
|
||||
let query = this.supabase
|
||||
.from('pokedex_export_integrations')
|
||||
.update(patch)
|
||||
.eq('id', id)
|
||||
.eq('userId', this.userId);
|
||||
const { error } = this.pokedexId
|
||||
? await query.eq('pokedexId', this.pokedexId)
|
||||
: await query.is('pokedexId', null);
|
||||
if (ifUpdatedAt) query = query.eq('updatedAt', ifUpdatedAt);
|
||||
const scoped = this.pokedexId
|
||||
? query.eq('pokedexId', this.pokedexId)
|
||||
: query.is('pokedexId', null);
|
||||
const { data, error } = await scoped.select('id');
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to update export integration status:', error);
|
||||
return false;
|
||||
}
|
||||
return (data?.length ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<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(
|
||||
integration: PokedexExportIntegration,
|
||||
repo: PokedexExportIntegrationRepository
|
||||
): Promise<PokedexExportIntegration> {
|
||||
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<PokedexExportIntegration> {
|
||||
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,24 @@ export async function exportPokedexIfConfigured(
|
||||
successes++;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
let reconnectRequired = false;
|
||||
if (error instanceof ReconnectRequiredError) {
|
||||
// A revoked grant never succeeds on retry, so pause this integration until the user
|
||||
// reconnects - but only if its row is unchanged since this export read it. A reconnect
|
||||
// in the meantime saved new credentials, which this stale failure must not disable.
|
||||
reconnectRequired = await scopedRepo.updateExportStatus(
|
||||
integration._id,
|
||||
{ lastError: message, enabled: false },
|
||||
integration.updatedAt ?? undefined
|
||||
);
|
||||
} else {
|
||||
await scopedRepo.updateExportStatus(integration._id, { lastError: message });
|
||||
}
|
||||
failures.push({
|
||||
integrationId: integration._id,
|
||||
provider: integration.provider,
|
||||
error: message
|
||||
});
|
||||
await scopedRepo.updateExportStatus(integration._id, {
|
||||
lastError: message
|
||||
error: message,
|
||||
reconnectRequired
|
||||
});
|
||||
console.error('Failed to export pokedex:', integration.provider, message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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 };
|
||||
|
||||
// A response is only applied if no newer refresh has started and nothing has changed the status
|
||||
// since it was requested, so a slow response can't overwrite newer or cleared state.
|
||||
let refreshSequence = 0;
|
||||
let mutationGeneration = 0;
|
||||
|
||||
export function setBackupStatus(integrations: IntegrationSummary[]): void {
|
||||
mutationGeneration++;
|
||||
backupsNeedingReconnect.set(integrations.filter((i) => !i.enabled).map((i) => i.provider));
|
||||
}
|
||||
|
||||
export async function refreshBackupStatus(): Promise<void> {
|
||||
if (typeof window === 'undefined' || !navigator.onLine) return;
|
||||
const sequence = ++refreshSequence;
|
||||
const generation = mutationGeneration;
|
||||
try {
|
||||
const response = await fetch('/api/export-integrations', { credentials: 'include' });
|
||||
if (!response.ok) return;
|
||||
const integrations = (await response.json()) as IntegrationSummary[];
|
||||
if (sequence !== refreshSequence || generation !== mutationGeneration) return;
|
||||
setBackupStatus(integrations);
|
||||
} catch (error) {
|
||||
console.error('Unable to check backup status', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function markReconnectNeeded(providers: ExportProvider[]): void {
|
||||
mutationGeneration++;
|
||||
backupsNeedingReconnect.update((current) => [...new Set([...current, ...providers])]);
|
||||
}
|
||||
|
||||
export function clearBackupStatus(): void {
|
||||
mutationGeneration++;
|
||||
backupsNeedingReconnect.set([]);
|
||||
}
|
||||
+28
-39
@@ -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 @@
|
||||
<li>
|
||||
<a href="/backup-settings"> Backup Settings </a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/offline-guide"> Using Offline </a>
|
||||
</li>
|
||||
<li>
|
||||
<SignOut
|
||||
{supabase}
|
||||
@@ -222,34 +227,18 @@
|
||||
<div class="alert rounded-none" role="status">
|
||||
<span>Offline read-only mode: saved data remains available, but changes are disabled.</span>
|
||||
</div>
|
||||
{:else if localUser && $offlineSyncStatus.state === 'error'}
|
||||
<div class="alert alert-warning rounded-none" role="status">
|
||||
<span>Offline copy could not be refreshed: {$offlineSyncStatus.message}</span>
|
||||
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
|
||||
</div>
|
||||
{:else if localUser && $artworkDownloadStatus.state === 'error'}
|
||||
<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>
|
||||
</div>
|
||||
{/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 localUser && reconnectLabels.length > 0 && $page.url.pathname !== '/backup-settings'}
|
||||
<div
|
||||
class="alert alert-warning rounded-none"
|
||||
role="alert"
|
||||
data-testid="backup-reconnect-banner"
|
||||
>
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<main class="flex-grow">
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
</svg>
|
||||
Open Source
|
||||
</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
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
@@ -130,7 +130,7 @@
|
||||
/>
|
||||
</svg>
|
||||
Offline-friendly
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<h1 class="text-5xl font-bold mb-6">Start Your Pokédex Journey!</h1>
|
||||
<p class="text-xl mb-6 text-base-content/80">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { setBackupStatus } from '$lib/stores/backupStatus';
|
||||
|
||||
type ExportIntegrationSummary = {
|
||||
id: string;
|
||||
@@ -40,6 +41,8 @@
|
||||
exportIntegrations = (await response.json()) as ExportIntegrationSummary[];
|
||||
googleIntegration = exportIntegrations.find((i) => i.provider === 'google_drive');
|
||||
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) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
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 {
|
||||
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="flex items-center justify-between">
|
||||
<h2 class="font-semibold">Google Drive</h2>
|
||||
<span class={`badge ${googleIntegration ? 'badge-success' : 'badge-ghost'}`}>
|
||||
{googleIntegration ? 'Connected' : 'Not Connected'}
|
||||
</span>
|
||||
<span class={`badge ${googleBadge.className}`}>{googleBadge.label}</span>
|
||||
</div>
|
||||
<div class="mt-3 space-y-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'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -142,13 +156,14 @@
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold">Dropbox</h2>
|
||||
<span class={`badge ${dropboxIntegration ? 'badge-success' : 'badge-ghost'}`}>
|
||||
{dropboxIntegration ? 'Connected' : 'Not Connected'}
|
||||
</span>
|
||||
<span class={`badge ${dropboxBadge.className}`}>{dropboxBadge.label}</span>
|
||||
</div>
|
||||
<div class="mt-3 space-y-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'}
|
||||
</button>
|
||||
</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 { 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<typeof setTimeout> | 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 @@
|
||||
});
|
||||
</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>
|
||||
<title>{pokedex ? `${pokedex.name} - Living Dex Tracker` : 'Pokédex - Living Dex Tracker'}</title>
|
||||
</svelte:head>
|
||||
|
||||
@@ -306,6 +306,7 @@
|
||||
</svg>
|
||||
<h4 class="font-semibold">Works Offline</h4>
|
||||
<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>
|
||||
|
||||
@@ -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 "<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
|
||||
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
|
||||
|
||||
@@ -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<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 }) => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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']);
|
||||
});
|
||||
|
||||
function deferredResponse() {
|
||||
let resolve!: (response: Response) => void;
|
||||
const promise = new Promise<Response>((r) => (resolve = r));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const json = (body: unknown) => new Response(JSON.stringify(body));
|
||||
|
||||
it('ignores a response overtaken by a newer refresh', async () => {
|
||||
const slow = deferredResponse();
|
||||
fetchMock
|
||||
.mockReturnValueOnce(slow.promise)
|
||||
.mockResolvedValueOnce(json([{ provider: 'google_drive', enabled: true }]));
|
||||
|
||||
const first = refreshBackupStatus();
|
||||
await refreshBackupStatus();
|
||||
slow.resolve(json([{ provider: 'google_drive', enabled: false }]));
|
||||
await first;
|
||||
|
||||
expect(get(backupsNeedingReconnect)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores a response that arrives after the status was flagged directly', async () => {
|
||||
const slow = deferredResponse();
|
||||
fetchMock.mockReturnValueOnce(slow.promise);
|
||||
|
||||
const pending = refreshBackupStatus();
|
||||
markReconnectNeeded(['dropbox']);
|
||||
slow.resolve(json([{ provider: 'dropbox', enabled: true }]));
|
||||
await pending;
|
||||
|
||||
expect(get(backupsNeedingReconnect)).toEqual(['dropbox']);
|
||||
});
|
||||
|
||||
it('ignores a response that arrives after the status was cleared', async () => {
|
||||
const slow = deferredResponse();
|
||||
fetchMock.mockReturnValueOnce(slow.promise);
|
||||
|
||||
const pending = refreshBackupStatus();
|
||||
clearBackupStatus();
|
||||
slow.resolve(json([{ provider: 'google_drive', enabled: false }]));
|
||||
await pending;
|
||||
|
||||
expect(get(backupsNeedingReconnect)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores a response that arrives after the status was set from another source', async () => {
|
||||
const slow = deferredResponse();
|
||||
fetchMock.mockReturnValueOnce(slow.promise);
|
||||
|
||||
const pending = refreshBackupStatus();
|
||||
setBackupStatus([{ provider: 'google_drive', enabled: true }]);
|
||||
slow.resolve(json([{ provider: 'google_drive', enabled: false }]));
|
||||
await pending;
|
||||
|
||||
expect(get(backupsNeedingReconnect)).toEqual([]);
|
||||
});
|
||||
|
||||
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,257 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository';
|
||||
|
||||
type Result = { data: unknown; error: unknown };
|
||||
|
||||
/** A chainable stand-in for the Supabase query builder that records each call made on it. */
|
||||
function fakeSupabase(result: Result) {
|
||||
const calls: unknown[][] = [];
|
||||
const builder: Record<string, unknown> = {};
|
||||
for (const method of ['select', 'update', 'upsert', 'single', 'eq', 'is', 'or']) {
|
||||
builder[method] = (...args: unknown[]) => {
|
||||
calls.push([method, ...args]);
|
||||
return builder;
|
||||
};
|
||||
}
|
||||
// Awaiting the builder runs the query, as it does in supabase-js.
|
||||
builder.then = (resolve: (value: Result) => unknown, reject?: (reason: unknown) => unknown) =>
|
||||
Promise.resolve(result).then(resolve, reject);
|
||||
const from = vi.fn(() => builder);
|
||||
return { supabase: { from } as unknown as SupabaseClient, calls, from };
|
||||
}
|
||||
|
||||
const VERSION = '2026-09-14T12:00:00.123456+00:00';
|
||||
const WRITTEN: Result = { data: [{ id: 'int-1' }], error: null };
|
||||
|
||||
describe('PokedexExportIntegrationRepository.updateExportStatus', () => {
|
||||
it('writes only while the row still has the version the caller read', async () => {
|
||||
const { supabase, calls, from } = fakeSupabase(WRITTEN);
|
||||
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', null);
|
||||
|
||||
const applied = await repo.updateExportStatus('int-1', { enabled: false }, VERSION);
|
||||
|
||||
expect(applied).toBe(true);
|
||||
expect(from).toHaveBeenCalledWith('pokedex_export_integrations');
|
||||
expect(calls).toEqual([
|
||||
['update', { enabled: false }],
|
||||
['eq', 'id', 'int-1'],
|
||||
['eq', 'userId', 'user-1'],
|
||||
['eq', 'updatedAt', VERSION],
|
||||
['is', 'pokedexId', null],
|
||||
['select', 'id']
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes unconditionally without a version, scoped to the Pokédex', async () => {
|
||||
const { supabase, calls } = fakeSupabase(WRITTEN);
|
||||
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', 'dex-1');
|
||||
|
||||
expect(await repo.updateExportStatus('int-1', { lastError: null })).toBe(true);
|
||||
expect(calls).toEqual([
|
||||
['update', { lastError: null }],
|
||||
['eq', 'id', 'int-1'],
|
||||
['eq', 'userId', 'user-1'],
|
||||
['eq', 'pokedexId', 'dex-1'],
|
||||
['select', 'id']
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no row matched, e.g. after a reconnect changed it', { data: [], error: null }],
|
||||
['the response carries no rows', { data: null, error: null }]
|
||||
])('reports nothing written when %s', async (_label, result) => {
|
||||
const { supabase } = fakeSupabase(result);
|
||||
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', null);
|
||||
|
||||
expect(await repo.updateExportStatus('int-1', { enabled: false }, VERSION)).toBe(false);
|
||||
});
|
||||
|
||||
it('logs and reports nothing written when the update fails', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const { supabase } = fakeSupabase({ data: null, error: { message: 'boom' } });
|
||||
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', null);
|
||||
|
||||
expect(await repo.updateExportStatus('int-1', { enabled: false })).toBe(false);
|
||||
expect(consoleError).toHaveBeenCalled();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PokedexExportIntegrationRepository.listAll', () => {
|
||||
it('maps each row version, defaulting a missing one to null', async () => {
|
||||
const row = {
|
||||
userId: 'user-1',
|
||||
pokedexId: null,
|
||||
provider: 'google_drive',
|
||||
enabled: true,
|
||||
fileName: null,
|
||||
folderId: null,
|
||||
path: null,
|
||||
accessToken: 'access',
|
||||
refreshToken: 'refresh',
|
||||
accessTokenExpiresAt: null,
|
||||
metadata: null,
|
||||
lastExportedAt: null,
|
||||
lastError: null
|
||||
};
|
||||
const { supabase } = fakeSupabase({
|
||||
data: [
|
||||
{ ...row, id: 'with-version', updatedAt: VERSION },
|
||||
{ ...row, id: 'without-version' }
|
||||
],
|
||||
error: null
|
||||
});
|
||||
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', null);
|
||||
|
||||
const [withVersion, withoutVersion] = await repo.listAll();
|
||||
|
||||
expect(withVersion).toMatchObject({ _id: 'with-version', updatedAt: VERSION });
|
||||
expect(withoutVersion).toMatchObject({ _id: 'without-version', updatedAt: null });
|
||||
});
|
||||
});
|
||||
|
||||
function dbRow(id: string) {
|
||||
return {
|
||||
id,
|
||||
userId: 'user-1',
|
||||
pokedexId: null,
|
||||
provider: 'dropbox',
|
||||
enabled: true,
|
||||
fileName: null,
|
||||
folderId: null,
|
||||
path: '/Backups',
|
||||
accessToken: 'access',
|
||||
refreshToken: 'refresh',
|
||||
accessTokenExpiresAt: null,
|
||||
metadata: null,
|
||||
lastExportedAt: null,
|
||||
lastError: null,
|
||||
updatedAt: VERSION
|
||||
};
|
||||
}
|
||||
|
||||
describe('PokedexExportIntegrationRepository queries', () => {
|
||||
it('lists enabled integrations scoped to one Pokédex', async () => {
|
||||
const { supabase, calls } = fakeSupabase({ data: [dbRow('int-1')], error: null });
|
||||
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', 'dex-1');
|
||||
|
||||
const [integration] = await repo.listEnabled();
|
||||
|
||||
expect(integration).toMatchObject({ _id: 'int-1', provider: 'dropbox', path: '/Backups' });
|
||||
expect(calls).toEqual([
|
||||
['select', '*'],
|
||||
['eq', 'userId', 'user-1'],
|
||||
['eq', 'pokedexId', 'dex-1'],
|
||||
['eq', 'enabled', true]
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['dex-1', ['or', 'pokedexId.eq.dex-1,pokedexId.is.null']],
|
||||
[null, ['is', 'pokedexId', null]]
|
||||
])('lists enabled integrations for Pokédex %j or the whole account', async (pokedexId, scope) => {
|
||||
const { supabase, calls } = fakeSupabase({ data: [dbRow('int-1')], error: null });
|
||||
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', pokedexId);
|
||||
|
||||
expect(await repo.listEnabledForPokedexOrUser()).toHaveLength(1);
|
||||
expect(calls).toEqual([
|
||||
['select', '*'],
|
||||
['eq', 'userId', 'user-1'],
|
||||
['eq', 'enabled', true],
|
||||
scope
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['listAll', 'listEnabled', 'listEnabledForPokedexOrUser'] as const)(
|
||||
'%s returns nothing when no rows come back',
|
||||
async (method) => {
|
||||
const { supabase } = fakeSupabase({ data: null, error: null });
|
||||
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', null);
|
||||
|
||||
expect(await repo[method]()).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['listAll', 'listEnabled', 'listEnabledForPokedexOrUser'] as const)(
|
||||
'%s throws when the query fails',
|
||||
async (method) => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const { supabase } = fakeSupabase({ data: null, error: { message: 'boom' } });
|
||||
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', null);
|
||||
|
||||
await expect(repo[method]()).rejects.toThrow('Failed to load export integrations: boom');
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('PokedexExportIntegrationRepository.upsert', () => {
|
||||
it('saves one account-wide integration per provider', async () => {
|
||||
const { supabase, calls } = fakeSupabase({ data: dbRow('int-1'), error: null });
|
||||
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', 'dex-1');
|
||||
|
||||
const saved = await repo.upsert({ provider: 'dropbox', enabled: true, lastError: null });
|
||||
|
||||
expect(saved).toMatchObject({ _id: 'int-1', updatedAt: VERSION });
|
||||
expect(calls).toEqual([
|
||||
[
|
||||
'upsert',
|
||||
{
|
||||
userId: 'user-1',
|
||||
pokedexId: null,
|
||||
provider: 'dropbox',
|
||||
enabled: true,
|
||||
lastError: null
|
||||
},
|
||||
{ onConflict: 'userId,provider' }
|
||||
],
|
||||
['select'],
|
||||
['single']
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the save fails', { data: null, error: { message: 'boom' } }, 'boom'],
|
||||
['nothing comes back', { data: null, error: null }, 'No result returned']
|
||||
])('throws when %s', async (_label, result, message) => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const { supabase } = fakeSupabase(result);
|
||||
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', null);
|
||||
|
||||
await expect(repo.upsert({ provider: 'dropbox' })).rejects.toThrow(
|
||||
`Failed to save export integration: ${message}`
|
||||
);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PokedexExportIntegrationRepository.updateTokens', () => {
|
||||
it.each([
|
||||
['dex-1', ['eq', 'pokedexId', 'dex-1']],
|
||||
[null, ['is', 'pokedexId', null]]
|
||||
])('stores refreshed tokens scoped to Pokédex %j', async (pokedexId, scope) => {
|
||||
const { supabase, calls } = fakeSupabase({ data: null, error: null });
|
||||
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', pokedexId);
|
||||
const patch = { accessToken: 'new-access', accessTokenExpiresAt: null };
|
||||
|
||||
await repo.updateTokens('int-1', patch);
|
||||
|
||||
expect(calls).toEqual([
|
||||
['update', patch],
|
||||
['eq', 'id', 'int-1'],
|
||||
['eq', 'userId', 'user-1'],
|
||||
scope
|
||||
]);
|
||||
});
|
||||
|
||||
it('logs instead of throwing when the token update fails', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const { supabase } = fakeSupabase({ data: null, error: { message: 'boom' } });
|
||||
const repo = new PokedexExportIntegrationRepository(supabase, 'user-1', null);
|
||||
|
||||
await expect(repo.updateTokens('int-1', { accessToken: 'x' })).resolves.toBeUndefined();
|
||||
expect(consoleError).toHaveBeenCalled();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,519 @@
|
||||
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();
|
||||
/** The row version an export read; a guarded write only applies while it still matches. */
|
||||
const VERSION = '2026-09-14T12:00:00.123456+00:00';
|
||||
|
||||
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,
|
||||
updatedAt: VERSION,
|
||||
...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();
|
||||
// The repository reports whether a row was written; by default every write applies.
|
||||
mocks.updateExportStatus.mockResolvedValue(true);
|
||||
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 },
|
||||
VERSION
|
||||
);
|
||||
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 },
|
||||
VERSION
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves a backup reconnected during the export enabled and unflagged', async () => {
|
||||
mocks.integrations = [integration()];
|
||||
stubProvider(REVOKED);
|
||||
// The guarded pause matches no row: a reconnect changed it after this export read it.
|
||||
mocks.updateExportStatus.mockResolvedValue(false);
|
||||
|
||||
const result = await exportPokedexIfConfigured(supabase, 'user-1', 'dex-1');
|
||||
|
||||
expect(mocks.updateExportStatus).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.updateExportStatus).toHaveBeenCalledWith(
|
||||
'google-1',
|
||||
{ lastError: expect.stringMatching(/Reconnect Google Drive/), enabled: false },
|
||||
VERSION
|
||||
);
|
||||
// The client must not tell the user to reconnect a connection that is already fresh.
|
||||
expect(result.failed[0]).toMatchObject({ reconnectRequired: false });
|
||||
});
|
||||
|
||||
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 {
|
||||
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'));
|
||||
|
||||
+4
-4
@@ -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: 59.73,
|
||||
functions: 85.88,
|
||||
lines: 59.73,
|
||||
branches: 88.35
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user