mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-16 02:22:17 +00:00
fb95b43b30
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.
284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
import { createBdd } from 'playwright-bdd';
|
|
import { test, expect } from '../fixtures';
|
|
import { createDexThroughUi, firstPokemon, openFirstPokemon } from '../support/app';
|
|
import { requireLoopbackUrl } from '../../support/loopback';
|
|
|
|
const { Given, When, Then } = createBdd(test);
|
|
const MOCK_URL = process.env.MOCK_PROVIDER_URL ?? 'http://127.0.0.1:4199';
|
|
const SUPABASE_URL = requireLoopbackUrl(
|
|
process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321',
|
|
'TEST_SUPABASE_URL'
|
|
);
|
|
|
|
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,
|
|
overrides: Record<string, unknown> = {}
|
|
) {
|
|
const key = process.env.E2E_SERVICE_ROLE_KEY;
|
|
if (!key || !state.userId)
|
|
throw new Error('A confirmed user and E2E_SERVICE_ROLE_KEY are required');
|
|
const response = await fetch(`${SUPABASE_URL}/rest/v1/pokedex_export_integrations`, {
|
|
method: 'POST',
|
|
headers: {
|
|
apikey: key,
|
|
Authorization: `Bearer ${key}`,
|
|
'Content-Type': 'application/json',
|
|
Prefer: 'resolution=merge-duplicates,return=representation'
|
|
},
|
|
body: JSON.stringify({
|
|
userId: state.userId,
|
|
pokedexId: null,
|
|
provider,
|
|
enabled: true,
|
|
accessToken: 'mock-access-token',
|
|
refreshToken: 'mock-refresh-token',
|
|
accessTokenExpiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
...overrides
|
|
})
|
|
});
|
|
if (!response.ok) throw new Error(await response.text());
|
|
}
|
|
|
|
async function ensureExportDex(
|
|
page: import('@playwright/test').Page,
|
|
state: import('../fixtures').ScenarioState
|
|
) {
|
|
if (!state.pokedexId) {
|
|
await createDexThroughUi(page, state, {
|
|
name: state.pokedexName ?? `Export ${Date.now()}`,
|
|
type: 'Living Dex'
|
|
});
|
|
}
|
|
}
|
|
|
|
Given('Google Drive is connected to the mocked provider', async ({ state }) => {
|
|
await seedIntegration(state, 'google_drive');
|
|
});
|
|
|
|
Given('Dropbox is connected with an expired token', async ({ page, state }) => {
|
|
await seedIntegration(state, 'dropbox', {
|
|
accessTokenExpiresAt: new Date(Date.now() - 60_000).toISOString()
|
|
});
|
|
await ensureExportDex(page, state);
|
|
});
|
|
|
|
Given('Google Drive is connected to a failing mocked provider', async ({ page, state }) => {
|
|
await seedIntegration(state, 'google_drive');
|
|
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');
|
|
});
|
|
|
|
When('I connect the mocked {string} provider', async ({ page }, provider: string) => {
|
|
await page.goto('/backup-settings');
|
|
const card = page.locator('.card, .border').filter({ hasText: provider }).last();
|
|
await card.getByRole('button', { name: 'Connect' }).click();
|
|
await page.waitForURL(/\/backup-settings\?export=.*-connected/);
|
|
});
|
|
|
|
When('a mocked OAuth callback has an invalid state', async ({ page, state }) => {
|
|
const response = await page.request.get(
|
|
'/api/integrations/google-drive/callback?code=mock&state=invalid-state'
|
|
);
|
|
state.lastResponseStatus = response.status();
|
|
});
|
|
|
|
When('I save a catch note containing a comma and quote', async ({ page, state }) => {
|
|
await ensureExportDex(page, state);
|
|
await page.goto(`/pokedex/${state.pokedexId}`);
|
|
await openFirstPokemon(page);
|
|
await page.getByRole('dialog').getByLabel('Notes:').fill('A comma, and a "quote"');
|
|
await page.getByRole('dialog').getByLabel('Notes:').blur();
|
|
await expect(page.getByText(/Saving…/)).toHaveCount(0, { timeout: 15_000 });
|
|
});
|
|
|
|
When('an export is requested', async ({ page, state }) => {
|
|
const response = await page.request.post(`/api/pokedexes/${state.pokedexId}/export`);
|
|
state.lastResponseStatus = response.status();
|
|
});
|
|
|
|
When('I update collection progress', async ({ page, state }) => {
|
|
await page.goto(`/pokedex/${state.pokedexId}`);
|
|
await openFirstPokemon(page);
|
|
await page
|
|
.getByRole('dialog')
|
|
.getByText('Caught:', { exact: true })
|
|
.locator('..')
|
|
.getByRole('checkbox')
|
|
.check();
|
|
await expect(page.getByText(/Saving…/)).toHaveCount(0, { timeout: 15_000 });
|
|
});
|
|
|
|
Then('Google Drive and Dropbox are shown as not connected', async ({ page }) => {
|
|
await expect(page.getByText('Not Connected', { exact: true })).toHaveCount(2);
|
|
});
|
|
|
|
Then('{string} is shown as connected', async ({ page }, provider: string) => {
|
|
const card = page.locator('.border').filter({ hasText: provider });
|
|
await expect(card.getByText('Connected', { exact: true })).toBeVisible();
|
|
});
|
|
|
|
Then('the backup connection is rejected', async ({ state }) => {
|
|
expect(state.lastResponseStatus).toBe(400);
|
|
});
|
|
|
|
async function mockState() {
|
|
const response = await fetch(`${MOCK_URL}/__mock/state`);
|
|
if (!response.ok) throw new Error(`Mock provider is unavailable: ${response.status}`);
|
|
return (await response.json()) as {
|
|
refreshes: number;
|
|
requests: Array<{ method: string; path: string; body: string }>;
|
|
};
|
|
}
|
|
|
|
Then('the mocked provider receives a valid escaped CSV', async () => {
|
|
const { requests } = await mockState();
|
|
const upload = requests.find((request) => request.path.includes('upload'));
|
|
expect(upload, 'no upload reached the mock provider').toBeDefined();
|
|
expect(upload!.body).toContain('"A comma, and a ""quote"""');
|
|
});
|
|
|
|
Then('the note survives a reload', async ({ page, state }) => {
|
|
await page.goto(`/pokedex/${state.pokedexId}`);
|
|
await expect(firstPokemon(page)).toBeVisible();
|
|
await openFirstPokemon(page);
|
|
await expect(page.getByRole('dialog').getByLabel('Notes:')).toHaveValue('A comma, and a "quote"');
|
|
});
|
|
|
|
Then('the catch remains marked caught', async ({ page, state }) => {
|
|
await page.goto(`/pokedex/${state.pokedexId}`);
|
|
await expect(firstPokemon(page)).toBeVisible();
|
|
await openFirstPokemon(page);
|
|
const caught = page
|
|
.getByRole('dialog')
|
|
.getByText('Caught:', { exact: true })
|
|
.locator('..')
|
|
.getByRole('checkbox');
|
|
await expect(caught).toBeChecked();
|
|
});
|
|
|
|
Then('the token is refreshed before the mocked upload', async ({ state }) => {
|
|
expect(state.lastResponseStatus).toBe(200);
|
|
const { refreshes, requests } = await mockState();
|
|
// Exactly one refresh, and it has to come before the upload it was needed for - a global
|
|
// ">= 1" would be satisfied by any earlier scenario's traffic.
|
|
expect(refreshes).toBe(1);
|
|
const refreshIndex = requests.findIndex(
|
|
(request) =>
|
|
request.path.endsWith('/token') && request.body.includes('grant_type=refresh_token')
|
|
);
|
|
const uploadIndex = requests.findIndex((request) => request.path.includes('upload'));
|
|
expect(refreshIndex).toBeGreaterThanOrEqual(0);
|
|
expect(uploadIndex).toBeGreaterThan(refreshIndex);
|
|
});
|
|
|
|
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);
|
|
});
|