mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-14 17:42:17 +00:00
test: replace ad-hoc tests with a layered suite and CI workflow
Splits testing into five layers so a failure points at the responsible one: - tests/unit isolated utility, repository and service tests - tests/data validates the tracked Pokémon, game, region and dex files - tests/integration schema, views, constraints, RLS and repositories - tests/bdd executable Gherkin for user-visible behaviour - tests/build service worker and manifest artifacts per build variant Replaces the two Playwright specs in client-test/ and the two Vitest files in test/. Adds a GitHub Actions workflow running the layers as separate jobs, a mock OAuth provider server so the Drive and Dropbox scenarios never touch real accounts, and a wrapper that reads the local Supabase keys from `supabase status` rather than hard-coding them. Extracts the pure formatting helpers out of PokedexExportService so they can be unit tested, and makes the provider endpoints configurable so the mock server can stand in for Google and Dropbox.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import type { CombinedData } from '$lib/models/CombinedData';
|
||||
import type { Pokedex } from '$lib/models/Pokedex';
|
||||
|
||||
export function csvEscape(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
const str = String(value);
|
||||
if (/[",\n\r]/.test(str)) return `"${str.replace(/"/g, '""')}"`;
|
||||
return str;
|
||||
}
|
||||
|
||||
export function sanitizeFileName(name: string, fallback: string): string {
|
||||
const trimmed = name.trim();
|
||||
const safe = trimmed.replace(/[\\/:*?"<>|]+/g, '-');
|
||||
if (!safe) return fallback;
|
||||
return safe.endsWith('.csv') ? safe : `${safe}.csv`;
|
||||
}
|
||||
|
||||
export function buildCsv(pokedex: Pokedex, combinedData: CombinedData[]): string {
|
||||
void pokedex;
|
||||
const headers = [
|
||||
'pokemonId',
|
||||
'pokedexNumber',
|
||||
'pokemon',
|
||||
'form',
|
||||
'caught',
|
||||
'haveToEvolve',
|
||||
'inHome',
|
||||
'personalNotes'
|
||||
];
|
||||
const lines = [headers.map(csvEscape).join(',')];
|
||||
|
||||
for (const row of combinedData) {
|
||||
const entry = row.pokedexEntry;
|
||||
const catchRecord = row.catchRecord ?? {
|
||||
caught: false,
|
||||
haveToEvolve: false,
|
||||
inHome: false,
|
||||
hasGigantamaxed: false,
|
||||
personalNotes: ''
|
||||
};
|
||||
lines.push(
|
||||
[
|
||||
entry._id,
|
||||
entry.pokedexNumber,
|
||||
entry.pokemon,
|
||||
entry.form || '',
|
||||
catchRecord.caught,
|
||||
catchRecord.haveToEvolve,
|
||||
catchRecord.inHome,
|
||||
catchRecord.personalNotes || ''
|
||||
]
|
||||
.map(csvEscape)
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join('\r\n');
|
||||
}
|
||||
|
||||
export function shouldRefreshToken(expiresAt: string | null): boolean {
|
||||
if (!expiresAt) return false;
|
||||
const expiry = new Date(expiresAt).getTime();
|
||||
if (!Number.isFinite(expiry)) return false;
|
||||
return expiry - Date.now() < 60_000;
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import type { CombinedData } from '$lib/models/CombinedData';
|
||||
import type { Pokedex } from '$lib/models/Pokedex';
|
||||
import type { ExportProvider, PokedexExportIntegration } from '$lib/models/PokedexExportIntegration';
|
||||
import type {
|
||||
ExportProvider,
|
||||
PokedexExportIntegration
|
||||
} from '$lib/models/PokedexExportIntegration';
|
||||
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
||||
import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository';
|
||||
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
|
||||
import { getEnv } from '$lib/utils/env';
|
||||
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
|
||||
import {
|
||||
buildCsv,
|
||||
sanitizeFileName,
|
||||
shouldRefreshToken
|
||||
} from '$lib/services/PokedexExportFormatting';
|
||||
|
||||
type ExportFailure = {
|
||||
integrationId: string;
|
||||
@@ -21,71 +29,6 @@ export type PokedexExportResult = {
|
||||
failed: ExportFailure[];
|
||||
};
|
||||
|
||||
function csvEscape(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
const str = String(value);
|
||||
if (/[",\n\r]/.test(str)) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function sanitizeFileName(name: string, fallback: string): string {
|
||||
const trimmed = name.trim();
|
||||
const safe = trimmed.replace(/[\\/:*?"<>|]+/g, '-');
|
||||
if (!safe) return fallback;
|
||||
return safe.endsWith('.csv') ? safe : `${safe}.csv`;
|
||||
}
|
||||
|
||||
function buildCsv(pokedex: Pokedex, combinedData: CombinedData[]): string {
|
||||
const headers = [
|
||||
'pokemonId',
|
||||
'pokedexNumber',
|
||||
'pokemon',
|
||||
'form',
|
||||
'caught',
|
||||
'haveToEvolve',
|
||||
'inHome',
|
||||
'personalNotes'
|
||||
];
|
||||
|
||||
const lines = [headers.map(csvEscape).join(',')];
|
||||
|
||||
for (const row of combinedData) {
|
||||
const entry = row.pokedexEntry;
|
||||
const catchRecord = row.catchRecord ?? {
|
||||
caught: false,
|
||||
haveToEvolve: false,
|
||||
inHome: false,
|
||||
hasGigantamaxed: false,
|
||||
personalNotes: ''
|
||||
};
|
||||
|
||||
const values = [
|
||||
entry._id,
|
||||
entry.pokedexNumber,
|
||||
entry.pokemon,
|
||||
entry.form || '',
|
||||
catchRecord.caught,
|
||||
catchRecord.haveToEvolve,
|
||||
catchRecord.inHome,
|
||||
catchRecord.personalNotes || ''
|
||||
];
|
||||
|
||||
lines.push(values.map(csvEscape).join(','));
|
||||
}
|
||||
|
||||
return lines.join('\r\n');
|
||||
}
|
||||
|
||||
function shouldRefreshToken(expiresAt: string | null): boolean {
|
||||
if (!expiresAt) return false;
|
||||
const expiry = new Date(expiresAt).getTime();
|
||||
if (!Number.isFinite(expiry)) return false;
|
||||
// Refresh if within 60 seconds of expiry.
|
||||
return expiry - Date.now() < 60_000;
|
||||
}
|
||||
|
||||
async function refreshGoogleToken(
|
||||
integration: PokedexExportIntegration,
|
||||
repo: PokedexExportIntegrationRepository
|
||||
@@ -108,7 +51,7 @@ async function refreshGoogleToken(
|
||||
grant_type: 'refresh_token'
|
||||
});
|
||||
|
||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||
const response = await fetch(getProviderEndpoints().google.token, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString()
|
||||
@@ -162,7 +105,7 @@ async function refreshDropboxToken(
|
||||
grant_type: 'refresh_token'
|
||||
});
|
||||
|
||||
const response = await fetch('https://api.dropbox.com/oauth2/token', {
|
||||
const response = await fetch(getProviderEndpoints().dropbox.token, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString()
|
||||
@@ -217,7 +160,10 @@ type GoogleDriveMetadata = {
|
||||
files?: Record<string, string>;
|
||||
};
|
||||
|
||||
function getGoogleFileId(metadata: Record<string, unknown> | null, pokedexId: string): string | null {
|
||||
function getGoogleFileId(
|
||||
metadata: Record<string, unknown> | null,
|
||||
pokedexId: string
|
||||
): string | null {
|
||||
const data = metadata as GoogleDriveMetadata | null;
|
||||
const fileId = data?.files?.[pokedexId];
|
||||
return typeof fileId === 'string' && fileId ? fileId : null;
|
||||
@@ -262,7 +208,7 @@ async function uploadToGoogleDrive(
|
||||
if (!folderId) {
|
||||
try {
|
||||
const folderResponse = await fetch(
|
||||
'https://www.googleapis.com/drive/v3/files?' +
|
||||
`${getProviderEndpoints().google.driveApi}/files?` +
|
||||
new URLSearchParams({
|
||||
q: "name='Living Dex Tracker' and mimeType='application/vnd.google-apps.folder' and trashed=false",
|
||||
fields: 'files(id,name)',
|
||||
@@ -286,7 +232,7 @@ async function uploadToGoogleDrive(
|
||||
|
||||
if (!folderId) {
|
||||
try {
|
||||
const createResponse = await fetch('https://www.googleapis.com/drive/v3/files', {
|
||||
const createResponse = await fetch(`${getProviderEndpoints().google.driveApi}/files`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${refreshed.accessToken}`,
|
||||
@@ -332,12 +278,11 @@ async function uploadToGoogleDrive(
|
||||
].join('\r\n');
|
||||
|
||||
const url = currentFileId
|
||||
? `https://www.googleapis.com/upload/drive/v3/files/${currentFileId}?uploadType=multipart`
|
||||
: 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart';
|
||||
? `${getProviderEndpoints().google.driveUpload}/files/${currentFileId}?uploadType=multipart`
|
||||
: `${getProviderEndpoints().google.driveUpload}/files?uploadType=multipart`;
|
||||
const method = currentFileId ? 'PATCH' : 'POST';
|
||||
const uploadUrl = currentFileId && folderId
|
||||
? `${url}&addParents=${encodeURIComponent(folderId)}`
|
||||
: url;
|
||||
const uploadUrl =
|
||||
currentFileId && folderId ? `${url}&addParents=${encodeURIComponent(folderId)}` : url;
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
method,
|
||||
@@ -392,7 +337,7 @@ async function uploadToDropbox(
|
||||
targetPath = `${targetPath}/${fileName}`;
|
||||
}
|
||||
|
||||
const response = await fetch('https://content.dropboxapi.com/2/files/upload', {
|
||||
const response = await fetch(getProviderEndpoints().dropbox.upload, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${refreshed.accessToken}`,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getEnv } from '$lib/utils/env';
|
||||
|
||||
export function getProviderEndpoints() {
|
||||
const env = getEnv();
|
||||
return {
|
||||
google: {
|
||||
authorize: env.GOOGLE_OAUTH_AUTHORIZE_URL || 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
token: env.GOOGLE_OAUTH_TOKEN_URL || 'https://oauth2.googleapis.com/token',
|
||||
driveApi: env.GOOGLE_DRIVE_API_URL || 'https://www.googleapis.com/drive/v3',
|
||||
driveUpload: env.GOOGLE_DRIVE_UPLOAD_URL || 'https://www.googleapis.com/upload/drive/v3'
|
||||
},
|
||||
dropbox: {
|
||||
authorize: env.DROPBOX_OAUTH_AUTHORIZE_URL || 'https://www.dropbox.com/oauth2/authorize',
|
||||
token: env.DROPBOX_OAUTH_TOKEN_URL || 'https://api.dropbox.com/oauth2/token',
|
||||
upload: env.DROPBOX_UPLOAD_URL || 'https://content.dropboxapi.com/2/files/upload'
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportI
|
||||
import { requireAuth } from '$lib/utils/auth';
|
||||
import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState';
|
||||
import { getEnv } from '$lib/utils/env';
|
||||
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
|
||||
|
||||
export const GET = async (event: RequestEvent) => {
|
||||
try {
|
||||
@@ -56,7 +57,7 @@ export const GET = async (event: RequestEvent) => {
|
||||
grant_type: 'authorization_code'
|
||||
});
|
||||
|
||||
const tokenResponse = await fetch('https://api.dropbox.com/oauth2/token', {
|
||||
const tokenResponse = await fetch(getProviderEndpoints().dropbox.token, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: tokenParams.toString()
|
||||
|
||||
@@ -4,6 +4,7 @@ import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||
import { requireAuth } from '$lib/utils/auth';
|
||||
import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState';
|
||||
import { getEnv } from '$lib/utils/env';
|
||||
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
|
||||
|
||||
export const GET = async (event: RequestEvent) => {
|
||||
const userId = await requireAuth(event);
|
||||
@@ -60,5 +61,5 @@ export const GET = async (event: RequestEvent) => {
|
||||
scope: 'files.content.write'
|
||||
});
|
||||
|
||||
throw redirect(302, `https://www.dropbox.com/oauth2/authorize?${params.toString()}`);
|
||||
throw redirect(302, `${getProviderEndpoints().dropbox.authorize}?${params.toString()}`);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportI
|
||||
import { requireAuth } from '$lib/utils/auth';
|
||||
import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState';
|
||||
import { getEnv } from '$lib/utils/env';
|
||||
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
|
||||
|
||||
export const GET = async (event: RequestEvent) => {
|
||||
try {
|
||||
@@ -57,7 +58,7 @@ export const GET = async (event: RequestEvent) => {
|
||||
grant_type: 'authorization_code'
|
||||
});
|
||||
|
||||
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
||||
const tokenResponse = await fetch(getProviderEndpoints().google.token, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: tokenParams.toString()
|
||||
|
||||
@@ -4,6 +4,7 @@ import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||
import { requireAuth } from '$lib/utils/auth';
|
||||
import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState';
|
||||
import { getEnv } from '$lib/utils/env';
|
||||
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
|
||||
|
||||
export const GET = async (event: RequestEvent) => {
|
||||
const userId = await requireAuth(event);
|
||||
@@ -65,5 +66,5 @@ export const GET = async (event: RequestEvent) => {
|
||||
state
|
||||
});
|
||||
|
||||
throw redirect(302, `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`);
|
||||
throw redirect(302, `${getProviderEndpoints().google.authorize}?${params.toString()}`);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user