mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-14 17:42:17 +00:00
185 lines
6.5 KiB
TypeScript
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();
|
|
});
|