Files
LivingDexTracker/tests/bdd/steps/backup.steps.ts
T
2026-09-14 12:56:09 +01:00

185 lines
6.5 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';
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 fetch(`${MOCK_URL}/__mock/fail-uploads`);
await ensureExportDex(page, state);
});
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();
});