mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 17:51:12 -05:00
Implement integration testing system
This commit is contained in:
280
src/components/admin/IntegrationTestRunner.tsx
Normal file
280
src/components/admin/IntegrationTestRunner.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
/**
|
||||||
|
* Integration Test Runner Component
|
||||||
|
*
|
||||||
|
* Superuser-only UI for running comprehensive integration tests.
|
||||||
|
* Requires AAL2 if MFA is enrolled.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
import { useSuperuserGuard } from '@/hooks/useSuperuserGuard';
|
||||||
|
import { IntegrationTestRunner as TestRunner, allTestSuites, type TestResult } from '@/lib/integrationTests';
|
||||||
|
import { Play, Square, Download, ChevronDown, CheckCircle2, XCircle, Clock, SkipForward } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export function IntegrationTestRunner() {
|
||||||
|
const superuserGuard = useSuperuserGuard();
|
||||||
|
const [selectedSuites, setSelectedSuites] = useState<string[]>(allTestSuites.map(s => s.id));
|
||||||
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
const [results, setResults] = useState<TestResult[]>([]);
|
||||||
|
const [runner] = useState(() => new TestRunner((result) => {
|
||||||
|
setResults(prev => {
|
||||||
|
const existing = prev.findIndex(r => r.id === result.id);
|
||||||
|
if (existing >= 0) {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[existing] = result;
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return [...prev, result];
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
const toggleSuite = useCallback((suiteId: string) => {
|
||||||
|
setSelectedSuites(prev =>
|
||||||
|
prev.includes(suiteId)
|
||||||
|
? prev.filter(id => id !== suiteId)
|
||||||
|
: [...prev, suiteId]
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runTests = useCallback(async () => {
|
||||||
|
const suitesToRun = allTestSuites.filter(s => selectedSuites.includes(s.id));
|
||||||
|
|
||||||
|
if (suitesToRun.length === 0) {
|
||||||
|
toast.error('Please select at least one test suite');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRunning(true);
|
||||||
|
setResults([]);
|
||||||
|
runner.reset();
|
||||||
|
|
||||||
|
toast.info(`Running ${suitesToRun.length} test suite(s)...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runner.runAllSuites(suitesToRun);
|
||||||
|
const summary = runner.getSummary();
|
||||||
|
|
||||||
|
if (summary.failed > 0) {
|
||||||
|
toast.error(`Tests completed with ${summary.failed} failure(s)`);
|
||||||
|
} else {
|
||||||
|
toast.success(`All ${summary.passed} tests passed!`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Test run error:', error);
|
||||||
|
toast.error('Test run failed');
|
||||||
|
} finally {
|
||||||
|
setIsRunning(false);
|
||||||
|
}
|
||||||
|
}, [selectedSuites, runner]);
|
||||||
|
|
||||||
|
const stopTests = useCallback(() => {
|
||||||
|
runner.stop();
|
||||||
|
setIsRunning(false);
|
||||||
|
toast.info('Test run stopped');
|
||||||
|
}, [runner]);
|
||||||
|
|
||||||
|
const exportResults = useCallback(() => {
|
||||||
|
const summary = runner.getSummary();
|
||||||
|
const exportData = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
summary,
|
||||||
|
results: runner.getResults()
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `integration-tests-${Date.now()}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast.success('Test results exported');
|
||||||
|
}, [runner]);
|
||||||
|
|
||||||
|
// Guard is handled by the route/page, no loading state needed here
|
||||||
|
|
||||||
|
const summary = runner.getSummary();
|
||||||
|
const totalTests = allTestSuites
|
||||||
|
.filter(s => selectedSuites.includes(s.id))
|
||||||
|
.reduce((sum, s) => sum + s.tests.length, 0);
|
||||||
|
const progress = totalTests > 0 ? (results.length / totalTests) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
🧪 Integration Test Runner
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Superuser-only comprehensive testing system. Tests run against real database functions and edge functions.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Suite Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="font-medium">Select Test Suites:</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{allTestSuites.map(suite => (
|
||||||
|
<div key={suite.id} className="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id={suite.id}
|
||||||
|
checked={selectedSuites.includes(suite.id)}
|
||||||
|
onCheckedChange={() => toggleSuite(suite.id)}
|
||||||
|
disabled={isRunning}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1 flex-1">
|
||||||
|
<label
|
||||||
|
htmlFor={suite.id}
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||||
|
>
|
||||||
|
{suite.name} ({suite.tests.length} tests)
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{suite.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={runTests} disabled={isRunning || selectedSuites.length === 0}>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
Run Selected
|
||||||
|
</Button>
|
||||||
|
{isRunning && (
|
||||||
|
<Button onClick={stopTests} variant="destructive">
|
||||||
|
<Square className="w-4 h-4 mr-2" />
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{results.length > 0 && !isRunning && (
|
||||||
|
<Button onClick={exportResults} variant="outline">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export Results
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>Progress: {results.length}/{totalTests} tests</span>
|
||||||
|
<span>{progress.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={progress} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="flex gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||||
|
<span>{summary.passed} passed</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<XCircle className="w-4 h-4 text-destructive" />
|
||||||
|
<span>{summary.failed} failed</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SkipForward className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span>{summary.skipped} skipped</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>{(summary.totalDuration / 1000).toFixed(2)}s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{results.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Test Results</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-[600px] pr-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{results.map(result => (
|
||||||
|
<Collapsible key={result.id}>
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg border bg-card">
|
||||||
|
<div className="pt-0.5">
|
||||||
|
{result.status === 'pass' && <CheckCircle2 className="w-4 h-4 text-green-500" />}
|
||||||
|
{result.status === 'fail' && <XCircle className="w-4 h-4 text-destructive" />}
|
||||||
|
{result.status === 'skip' && <SkipForward className="w-4 h-4 text-muted-foreground" />}
|
||||||
|
{result.status === 'running' && <Clock className="w-4 h-4 text-blue-500 animate-pulse" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">{result.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{result.suite}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{result.duration}ms
|
||||||
|
</Badge>
|
||||||
|
{(result.error || result.details) && (
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{result.error && (
|
||||||
|
<p className="text-sm text-destructive">{result.error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(result.error || result.details) && (
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="ml-7 mt-2 p-3 rounded-lg bg-muted/50 space-y-2">
|
||||||
|
{result.error && result.stack && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium mb-1">Stack Trace:</p>
|
||||||
|
<pre className="text-xs whitespace-pre-wrap font-mono bg-background p-2 rounded">
|
||||||
|
{result.stack}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result.details && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium mb-1">Details:</p>
|
||||||
|
<pre className="text-xs whitespace-pre-wrap font-mono bg-background p-2 rounded">
|
||||||
|
{JSON.stringify(result.details, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
)}
|
||||||
|
</Collapsible>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/lib/integrationTests/index.ts
Normal file
10
src/lib/integrationTests/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Integration Testing System
|
||||||
|
*
|
||||||
|
* Main exports for the comprehensive integration testing framework.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { IntegrationTestRunner } from './testRunner';
|
||||||
|
export { allTestSuites } from './suites';
|
||||||
|
|
||||||
|
export type { TestResult, Test, TestSuite } from './testRunner';
|
||||||
259
src/lib/integrationTests/suites/authTests.ts
Normal file
259
src/lib/integrationTests/suites/authTests.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* Authentication & Authorization Test Suite
|
||||||
|
*
|
||||||
|
* Tests auth flows, MFA enforcement, role checks, and session management.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import type { TestSuite, TestResult } from '../testRunner';
|
||||||
|
|
||||||
|
export const authTestSuite: TestSuite = {
|
||||||
|
id: 'auth',
|
||||||
|
name: 'Authentication & Authorization',
|
||||||
|
description: 'Tests for auth flows, MFA, roles, and permissions',
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
id: 'auth-001',
|
||||||
|
name: 'User Session Validation',
|
||||||
|
description: 'Validates current user session is valid with proper JWT structure',
|
||||||
|
run: async (): Promise<TestResult> => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get current session
|
||||||
|
const { data: { session }, error } = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
if (error) throw new Error(`Session fetch failed: ${error.message}`);
|
||||||
|
if (!session) throw new Error('No active session found');
|
||||||
|
if (!session.access_token) throw new Error('No access token in session');
|
||||||
|
if (!session.user) throw new Error('No user in session');
|
||||||
|
if (!session.user.id) throw new Error('No user ID in session');
|
||||||
|
|
||||||
|
// Validate token structure (JWT has 3 parts separated by dots)
|
||||||
|
const tokenParts = session.access_token.split('.');
|
||||||
|
if (tokenParts.length !== 3) {
|
||||||
|
throw new Error(`Invalid JWT structure: expected 3 parts, got ${tokenParts.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (session.expires_at && session.expires_at < Date.now() / 1000) {
|
||||||
|
throw new Error('Session token is expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'auth-001',
|
||||||
|
name: 'User Session Validation',
|
||||||
|
suite: 'Authentication & Authorization',
|
||||||
|
status: 'pass',
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
userId: session.user.id,
|
||||||
|
email: session.user.email,
|
||||||
|
expiresAt: session.expires_at,
|
||||||
|
aal: (session.user as any).aal || 'aal1'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
return {
|
||||||
|
id: 'auth-001',
|
||||||
|
name: 'User Session Validation',
|
||||||
|
suite: 'Authentication & Authorization',
|
||||||
|
status: 'fail',
|
||||||
|
duration,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'auth-002',
|
||||||
|
name: 'Role-Based Access Control (RBAC)',
|
||||||
|
description: 'Tests role checks are consistent across hooks and database functions',
|
||||||
|
run: async (): Promise<TestResult> => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) throw new Error('No authenticated user');
|
||||||
|
|
||||||
|
// Query user_roles table
|
||||||
|
const { data: roles, error: rolesError } = await supabase
|
||||||
|
.from('user_roles')
|
||||||
|
.select('role')
|
||||||
|
.eq('user_id', user.id);
|
||||||
|
|
||||||
|
if (rolesError) throw new Error(`Failed to fetch roles: ${rolesError.message}`);
|
||||||
|
|
||||||
|
// Test is_moderator() database function
|
||||||
|
const { data: isMod, error: modError } = await supabase
|
||||||
|
.rpc('is_moderator', { _user_id: user.id });
|
||||||
|
|
||||||
|
if (modError) throw new Error(`is_moderator() failed: ${modError.message}`);
|
||||||
|
|
||||||
|
// Test is_superuser() database function
|
||||||
|
const { data: isSuper, error: superError } = await supabase
|
||||||
|
.rpc('is_superuser', { _user_id: user.id });
|
||||||
|
|
||||||
|
if (superError) throw new Error(`is_superuser() failed: ${superError.message}`);
|
||||||
|
|
||||||
|
// Validate consistency
|
||||||
|
const hasModRole = roles?.some(r => ['moderator', 'admin', 'superuser'].includes(r.role));
|
||||||
|
if (hasModRole !== isMod) {
|
||||||
|
throw new Error(`Inconsistent moderator check: has role=${hasModRole}, is_moderator()=${isMod}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSuperRole = roles?.some(r => r.role === 'superuser');
|
||||||
|
if (hasSuperRole !== isSuper) {
|
||||||
|
throw new Error(`Inconsistent superuser check: has role=${hasSuperRole}, is_superuser()=${isSuper}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'auth-002',
|
||||||
|
name: 'Role-Based Access Control (RBAC)',
|
||||||
|
suite: 'Authentication & Authorization',
|
||||||
|
status: 'pass',
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
roles: roles?.map(r => r.role) || [],
|
||||||
|
isModerator: isMod,
|
||||||
|
isSuperuser: isSuper,
|
||||||
|
consistent: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
return {
|
||||||
|
id: 'auth-002',
|
||||||
|
name: 'Role-Based Access Control (RBAC)',
|
||||||
|
suite: 'Authentication & Authorization',
|
||||||
|
status: 'fail',
|
||||||
|
duration,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'auth-003',
|
||||||
|
name: 'MFA Factor Detection',
|
||||||
|
description: 'Tests MFA enrollment detection and AAL level',
|
||||||
|
run: async (): Promise<TestResult> => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) throw new Error('No authenticated user');
|
||||||
|
|
||||||
|
// Get MFA factors
|
||||||
|
const { data: factors, error: factorsError } = await supabase.auth.mfa.listFactors();
|
||||||
|
|
||||||
|
if (factorsError) throw new Error(`Failed to list MFA factors: ${factorsError.message}`);
|
||||||
|
|
||||||
|
const hasVerifiedFactor = factors?.totp?.some(f => f.status === 'verified') || false;
|
||||||
|
const currentAAL = (user as any).aal || 'aal1';
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'auth-003',
|
||||||
|
name: 'MFA Factor Detection',
|
||||||
|
suite: 'Authentication & Authorization',
|
||||||
|
status: 'pass',
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
hasVerifiedMFA: hasVerifiedFactor,
|
||||||
|
currentAAL: currentAAL,
|
||||||
|
totpFactorCount: factors?.totp?.length || 0,
|
||||||
|
verifiedFactorCount: factors?.totp?.filter(f => f.status === 'verified').length || 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
return {
|
||||||
|
id: 'auth-003',
|
||||||
|
name: 'MFA Factor Detection',
|
||||||
|
suite: 'Authentication & Authorization',
|
||||||
|
status: 'fail',
|
||||||
|
duration,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'auth-004',
|
||||||
|
name: 'Banned User Detection',
|
||||||
|
description: 'Tests banned user detection in profiles table',
|
||||||
|
run: async (): Promise<TestResult> => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) throw new Error('No authenticated user');
|
||||||
|
|
||||||
|
// Query profile banned status
|
||||||
|
const { data: profile, error: profileError } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('banned')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (profileError) throw new Error(`Failed to fetch profile: ${profileError.message}`);
|
||||||
|
if (!profile) throw new Error('No profile found');
|
||||||
|
|
||||||
|
// Test is_user_banned() database function
|
||||||
|
const { data: isBanned, error: bannedError } = await supabase
|
||||||
|
.rpc('is_user_banned', { _user_id: user.id });
|
||||||
|
|
||||||
|
if (bannedError) throw new Error(`is_user_banned() failed: ${bannedError.message}`);
|
||||||
|
|
||||||
|
// Validate consistency
|
||||||
|
if (profile.banned !== isBanned) {
|
||||||
|
throw new Error(`Inconsistent banned check: profile=${profile.banned}, is_user_banned()=${isBanned}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'auth-004',
|
||||||
|
name: 'Banned User Detection',
|
||||||
|
suite: 'Authentication & Authorization',
|
||||||
|
status: 'pass',
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
isBanned: profile.banned,
|
||||||
|
consistent: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
return {
|
||||||
|
id: 'auth-004',
|
||||||
|
name: 'Banned User Detection',
|
||||||
|
suite: 'Authentication & Authorization',
|
||||||
|
status: 'fail',
|
||||||
|
duration,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
292
src/lib/integrationTests/suites/dataIntegrityTests.ts
Normal file
292
src/lib/integrationTests/suites/dataIntegrityTests.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* Data Integrity & Constraints Test Suite
|
||||||
|
*
|
||||||
|
* Tests database constraints, RLS policies, and data integrity rules.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import type { TestSuite, TestResult } from '../testRunner';
|
||||||
|
|
||||||
|
export const dataIntegrityTestSuite: TestSuite = {
|
||||||
|
id: 'data-integrity',
|
||||||
|
name: 'Data Integrity & Constraints',
|
||||||
|
description: 'Tests database constraints, RLS policies, and data integrity',
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
id: 'integrity-001',
|
||||||
|
name: 'RLS Policy Enforcement - Public Read',
|
||||||
|
description: 'Validates public read access to entity tables',
|
||||||
|
run: async (): Promise<TestResult> => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test public read access to parks
|
||||||
|
const { data: parks, error: parksError } = await supabase
|
||||||
|
.from('parks')
|
||||||
|
.select('id, name, slug')
|
||||||
|
.limit(5);
|
||||||
|
|
||||||
|
if (parksError) throw new Error(`Parks read failed: ${parksError.message}`);
|
||||||
|
|
||||||
|
// Test public read access to rides
|
||||||
|
const { data: rides, error: ridesError } = await supabase
|
||||||
|
.from('rides')
|
||||||
|
.select('id, name, slug')
|
||||||
|
.limit(5);
|
||||||
|
|
||||||
|
if (ridesError) throw new Error(`Rides read failed: ${ridesError.message}`);
|
||||||
|
|
||||||
|
// Test public read access to companies
|
||||||
|
const { data: companies, error: companiesError } = await supabase
|
||||||
|
.from('companies')
|
||||||
|
.select('id, name, slug')
|
||||||
|
.limit(5);
|
||||||
|
|
||||||
|
if (companiesError) throw new Error(`Companies read failed: ${companiesError.message}`);
|
||||||
|
|
||||||
|
// Test public read access to ride_models
|
||||||
|
const { data: models, error: modelsError } = await supabase
|
||||||
|
.from('ride_models')
|
||||||
|
.select('id, name, slug')
|
||||||
|
.limit(5);
|
||||||
|
|
||||||
|
if (modelsError) throw new Error(`Ride models read failed: ${modelsError.message}`);
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'integrity-001',
|
||||||
|
name: 'RLS Policy Enforcement - Public Read',
|
||||||
|
suite: 'Data Integrity & Constraints',
|
||||||
|
status: 'pass',
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
parksReadable: Array.isArray(parks),
|
||||||
|
ridesReadable: Array.isArray(rides),
|
||||||
|
companiesReadable: Array.isArray(companies),
|
||||||
|
rideModelsReadable: Array.isArray(models)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
return {
|
||||||
|
id: 'integrity-001',
|
||||||
|
name: 'RLS Policy Enforcement - Public Read',
|
||||||
|
suite: 'Data Integrity & Constraints',
|
||||||
|
status: 'fail',
|
||||||
|
duration,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'integrity-002',
|
||||||
|
name: 'Foreign Key Constraint Enforcement',
|
||||||
|
description: 'Tests foreign key constraints prevent invalid references',
|
||||||
|
run: async (): Promise<TestResult> => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to create a ride with non-existent park_id
|
||||||
|
const invalidParkId = '00000000-0000-0000-0000-000000000000';
|
||||||
|
const slug = `test-ride-${Date.now()}`;
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('rides')
|
||||||
|
.insert({
|
||||||
|
name: 'Invalid Ride',
|
||||||
|
slug,
|
||||||
|
park_id: invalidParkId,
|
||||||
|
category: 'roller_coaster',
|
||||||
|
status: 'operating'
|
||||||
|
});
|
||||||
|
|
||||||
|
// This SHOULD fail with foreign key violation
|
||||||
|
if (!error) {
|
||||||
|
throw new Error('Foreign key constraint not enforced - invalid park_id was accepted');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's a foreign key violation
|
||||||
|
if (!error.message.includes('foreign key') && !error.message.includes('violates')) {
|
||||||
|
throw new Error(`Expected foreign key error, got: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'integrity-002',
|
||||||
|
name: 'Foreign Key Constraint Enforcement',
|
||||||
|
suite: 'Data Integrity & Constraints',
|
||||||
|
status: 'pass',
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
constraintEnforced: true,
|
||||||
|
errorMessage: error.message
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
return {
|
||||||
|
id: 'integrity-002',
|
||||||
|
name: 'Foreign Key Constraint Enforcement',
|
||||||
|
suite: 'Data Integrity & Constraints',
|
||||||
|
status: 'fail',
|
||||||
|
duration,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'integrity-003',
|
||||||
|
name: 'Unique Constraint Enforcement',
|
||||||
|
description: 'Tests unique constraints prevent duplicate slugs',
|
||||||
|
run: async (): Promise<TestResult> => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
let parkId: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a park
|
||||||
|
const slug = `unique-test-${Date.now()}`;
|
||||||
|
const { data: park, error: createError } = await supabase
|
||||||
|
.from('parks')
|
||||||
|
.insert({
|
||||||
|
name: 'Unique Test Park',
|
||||||
|
slug,
|
||||||
|
park_type: 'theme_park',
|
||||||
|
status: 'operating'
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (createError) throw new Error(`Park creation failed: ${createError.message}`);
|
||||||
|
if (!park) throw new Error('No park returned');
|
||||||
|
|
||||||
|
parkId = park.id;
|
||||||
|
|
||||||
|
// Try to create another park with same slug
|
||||||
|
const { error: duplicateError } = await supabase
|
||||||
|
.from('parks')
|
||||||
|
.insert({
|
||||||
|
name: 'Duplicate Park',
|
||||||
|
slug, // Same slug
|
||||||
|
park_type: 'theme_park',
|
||||||
|
status: 'operating'
|
||||||
|
});
|
||||||
|
|
||||||
|
// This SHOULD fail with unique violation
|
||||||
|
if (!duplicateError) {
|
||||||
|
throw new Error('Unique constraint not enforced - duplicate slug was accepted');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's a unique violation
|
||||||
|
if (!duplicateError.message.includes('unique') && !duplicateError.message.includes('duplicate')) {
|
||||||
|
throw new Error(`Expected unique constraint error, got: ${duplicateError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'integrity-003',
|
||||||
|
name: 'Unique Constraint Enforcement',
|
||||||
|
suite: 'Data Integrity & Constraints',
|
||||||
|
status: 'pass',
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
constraintEnforced: true,
|
||||||
|
errorMessage: duplicateError.message
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
return {
|
||||||
|
id: 'integrity-003',
|
||||||
|
name: 'Unique Constraint Enforcement',
|
||||||
|
suite: 'Data Integrity & Constraints',
|
||||||
|
status: 'fail',
|
||||||
|
duration,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (parkId) {
|
||||||
|
await supabase.from('parks').delete().eq('id', parkId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'integrity-004',
|
||||||
|
name: 'No JSONB in Entity Tables',
|
||||||
|
description: 'Validates no JSONB columns exist in entity tables (per requirements)',
|
||||||
|
run: async (): Promise<TestResult> => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Sample actual data and check structure (information_schema not accessible via RLS)
|
||||||
|
const { data: parks } = await supabase.from('parks').select('*').limit(1);
|
||||||
|
const { data: rides } = await supabase.from('rides').select('*').limit(1);
|
||||||
|
const { data: companies } = await supabase.from('companies').select('*').limit(1);
|
||||||
|
const { data: models } = await supabase.from('ride_models').select('*').limit(1);
|
||||||
|
|
||||||
|
// Check if any fields appear to be JSONB objects
|
||||||
|
const hasJsonbFields = [parks, rides, companies, models].some(dataset => {
|
||||||
|
if (!dataset || dataset.length === 0) return false;
|
||||||
|
const record = dataset[0] as any;
|
||||||
|
return Object.keys(record).some(key => {
|
||||||
|
const val = record[key];
|
||||||
|
// Check if value is a plain object (not Date, not Array, not null)
|
||||||
|
if (val === null || val === undefined) return false;
|
||||||
|
if (typeof val !== 'object') return false;
|
||||||
|
if (Array.isArray(val)) return false;
|
||||||
|
// Check if it's a Date by checking if it has getTime method
|
||||||
|
if (val && typeof val.getTime === 'function') return false;
|
||||||
|
// If we get here, it's likely a JSONB object
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasJsonbFields) {
|
||||||
|
throw new Error('Found JSONB-like fields in entity tables');
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'integrity-004',
|
||||||
|
name: 'No JSONB in Entity Tables',
|
||||||
|
suite: 'Data Integrity & Constraints',
|
||||||
|
status: 'pass',
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
noJsonbColumns: true,
|
||||||
|
validation: 'Entity tables use relational structure only'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
return {
|
||||||
|
id: 'integrity-004',
|
||||||
|
name: 'No JSONB in Entity Tables',
|
||||||
|
suite: 'Data Integrity & Constraints',
|
||||||
|
status: 'fail',
|
||||||
|
duration,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
22
src/lib/integrationTests/suites/index.ts
Normal file
22
src/lib/integrationTests/suites/index.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Integration Test Suites Export
|
||||||
|
*
|
||||||
|
* Exports all test suites for the integration testing system.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { authTestSuite } from './authTests';
|
||||||
|
import { versioningTestSuite } from './versioningTests';
|
||||||
|
import { dataIntegrityTestSuite } from './dataIntegrityTests';
|
||||||
|
import type { TestSuite } from '../testRunner';
|
||||||
|
|
||||||
|
export const allTestSuites: TestSuite[] = [
|
||||||
|
authTestSuite,
|
||||||
|
versioningTestSuite,
|
||||||
|
dataIntegrityTestSuite,
|
||||||
|
];
|
||||||
|
|
||||||
|
export {
|
||||||
|
authTestSuite,
|
||||||
|
versioningTestSuite,
|
||||||
|
dataIntegrityTestSuite,
|
||||||
|
};
|
||||||
456
src/lib/integrationTests/suites/versioningTests.ts
Normal file
456
src/lib/integrationTests/suites/versioningTests.ts
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
/**
|
||||||
|
* Versioning & Rollback Test Suite
|
||||||
|
*
|
||||||
|
* Tests the complete versioning system end-to-end including automatic
|
||||||
|
* version creation, attribution, and rollback functionality.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import type { TestSuite, TestResult } from '../testRunner';
|
||||||
|
|
||||||
|
export const versioningTestSuite: TestSuite = {
|
||||||
|
id: 'versioning',
|
||||||
|
name: 'Versioning & Rollback',
|
||||||
|
description: 'Tests version creation, attribution, rollback, and cleanup',
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
id: 'version-001',
|
||||||
|
name: 'Automatic Version Creation on Insert',
|
||||||
|
description: 'Verifies version 1 is created automatically when entity is created',
|
||||||
|
run: async (): Promise<TestResult> => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
let parkId: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a park
|
||||||
|
const slug = `test-park-${Date.now()}`;
|
||||||
|
const { data: park, error: createError } = await supabase
|
||||||
|
.from('parks')
|
||||||
|
.insert({
|
||||||
|
name: 'Version Test Park',
|
||||||
|
slug,
|
||||||
|
park_type: 'theme_park',
|
||||||
|
status: 'operating'
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (createError) throw new Error(`Park creation failed: ${createError.message}`);
|
||||||
|
if (!park) throw new Error('No park returned from insert');
|
||||||
|
|
||||||
|
parkId = park.id;
|
||||||
|
|
||||||
|
// Wait a bit for trigger to execute
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check version was created
|
||||||
|
const { data: version, error: versionError } = await supabase
|
||||||
|
.from('park_versions')
|
||||||
|
.select('*')
|
||||||
|
.eq('park_id', park.id)
|
||||||
|
.eq('version_number', 1)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (versionError) throw new Error(`Version query failed: ${versionError.message}`);
|
||||||
|
if (!version) throw new Error('Version 1 not created');
|
||||||
|
if (version.name !== 'Version Test Park') throw new Error('Version has incorrect name');
|
||||||
|
if (version.change_type !== 'created') throw new Error(`Expected change_type "created", got "${version.change_type}"`);
|
||||||
|
if (!version.is_current) throw new Error('Version is not marked as current');
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'version-001',
|
||||||
|
name: 'Automatic Version Creation on Insert',
|
||||||
|
suite: 'Versioning & Rollback',
|
||||||
|
status: 'pass',
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
parkId: park.id,
|
||||||
|
versionNumber: version.version_number,
|
||||||
|
changeType: version.change_type,
|
||||||
|
isCurrent: version.is_current
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
return {
|
||||||
|
id: 'version-001',
|
||||||
|
name: 'Automatic Version Creation on Insert',
|
||||||
|
suite: 'Versioning & Rollback',
|
||||||
|
status: 'fail',
|
||||||
|
duration,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
// Cleanup
|
||||||
|
if (parkId) {
|
||||||
|
await supabase.from('parks').delete().eq('id', parkId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'version-002',
|
||||||
|
name: 'Automatic Version Creation on Update',
|
||||||
|
description: 'Verifies version 2 is created when entity is updated',
|
||||||
|
run: async (): Promise<TestResult> => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
let parkId: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a park
|
||||||
|
const slug = `test-park-${Date.now()}`;
|
||||||
|
const { data: park, error: createError } = await supabase
|
||||||
|
.from('parks')
|
||||||
|
.insert({
|
||||||
|
name: 'Original Name',
|
||||||
|
slug,
|
||||||
|
park_type: 'theme_park',
|
||||||
|
status: 'operating'
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (createError) throw new Error(`Park creation failed: ${createError.message}`);
|
||||||
|
if (!park) throw new Error('No park returned');
|
||||||
|
|
||||||
|
parkId = park.id;
|
||||||
|
|
||||||
|
// Wait for version 1
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Update the park
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('parks')
|
||||||
|
.update({ name: 'Updated Name' })
|
||||||
|
.eq('id', park.id);
|
||||||
|
|
||||||
|
if (updateError) throw new Error(`Park update failed: ${updateError.message}`);
|
||||||
|
|
||||||
|
// Wait for version 2
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check version 2 exists
|
||||||
|
const { data: v2, error: v2Error } = await supabase
|
||||||
|
.from('park_versions')
|
||||||
|
.select('*')
|
||||||
|
.eq('park_id', park.id)
|
||||||
|
.eq('version_number', 2)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (v2Error) throw new Error(`Version 2 query failed: ${v2Error.message}`);
|
||||||
|
if (!v2) throw new Error('Version 2 not created');
|
||||||
|
if (v2.name !== 'Updated Name') throw new Error('Version 2 has incorrect name');
|
||||||
|
if (v2.change_type !== 'updated') throw new Error(`Expected change_type "updated", got "${v2.change_type}"`);
|
||||||
|
if (!v2.is_current) throw new Error('Version 2 is not marked as current');
|
||||||
|
|
||||||
|
// Check version 1 is no longer current
|
||||||
|
const { data: v1, error: v1Error } = await supabase
|
||||||
|
.from('park_versions')
|
||||||
|
.select('is_current')
|
||||||
|
.eq('park_id', park.id)
|
||||||
|
.eq('version_number', 1)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (v1Error) throw new Error(`Version 1 query failed: ${v1Error.message}`);
|
||||||
|
if (v1?.is_current) throw new Error('Version 1 is still marked as current');
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'version-002',
|
||||||
|
name: 'Automatic Version Creation on Update',
|
||||||
|
suite: 'Versioning & Rollback',
|
||||||
|
status: 'pass',
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
parkId: park.id,
|
||||||
|
v1IsCurrent: v1?.is_current,
|
||||||
|
v2IsCurrent: v2.is_current,
|
||||||
|
v2ChangeType: v2.change_type
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
return {
|
||||||
|
id: 'version-002',
|
||||||
|
name: 'Automatic Version Creation on Update',
|
||||||
|
suite: 'Versioning & Rollback',
|
||||||
|
status: 'fail',
|
||||||
|
duration,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (parkId) {
|
||||||
|
await supabase.from('parks').delete().eq('id', parkId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'version-003',
|
||||||
|
name: 'Rollback Authorization Check',
|
||||||
|
description: 'Tests that rollback_to_version requires moderator role',
|
||||||
|
run: async (): Promise<TestResult> => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
let parkId: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a park
|
||||||
|
const slug = `test-park-${Date.now()}`;
|
||||||
|
const { data: park, error: createError } = await supabase
|
||||||
|
.from('parks')
|
||||||
|
.insert({
|
||||||
|
name: 'Rollback Test Park',
|
||||||
|
slug,
|
||||||
|
park_type: 'theme_park',
|
||||||
|
status: 'operating'
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (createError) throw new Error(`Park creation failed: ${createError.message}`);
|
||||||
|
if (!park) throw new Error('No park returned');
|
||||||
|
|
||||||
|
parkId = park.id;
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Get version 1 ID
|
||||||
|
const { data: v1, error: v1Error } = await supabase
|
||||||
|
.from('park_versions')
|
||||||
|
.select('version_id')
|
||||||
|
.eq('park_id', park.id)
|
||||||
|
.eq('version_number', 1)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (v1Error || !v1) throw new Error('Version 1 not found');
|
||||||
|
|
||||||
|
// Check current user is moderator
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) throw new Error('No authenticated user');
|
||||||
|
|
||||||
|
const { data: isMod } = await supabase.rpc('is_moderator', { _user_id: user.id });
|
||||||
|
|
||||||
|
// Try rollback
|
||||||
|
const { error: rollbackError } = await supabase.rpc('rollback_to_version', {
|
||||||
|
p_entity_type: 'park',
|
||||||
|
p_entity_id: park.id,
|
||||||
|
p_target_version_id: v1.version_id,
|
||||||
|
p_changed_by: user.id,
|
||||||
|
p_reason: 'Authorization test'
|
||||||
|
});
|
||||||
|
|
||||||
|
// If user is moderator, rollback should succeed
|
||||||
|
// If not, rollback should fail with permission error
|
||||||
|
if (isMod && rollbackError) {
|
||||||
|
throw new Error(`Rollback failed for moderator: ${rollbackError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMod && !rollbackError) {
|
||||||
|
throw new Error('Rollback succeeded for non-moderator (should have failed)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'version-003',
|
||||||
|
name: 'Rollback Authorization Check',
|
||||||
|
suite: 'Versioning & Rollback',
|
||||||
|
status: 'pass',
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
userIsModerator: isMod,
|
||||||
|
rollbackBlocked: !isMod && !!rollbackError,
|
||||||
|
authorizationEnforced: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
return {
|
||||||
|
id: 'version-003',
|
||||||
|
name: 'Rollback Authorization Check',
|
||||||
|
suite: 'Versioning & Rollback',
|
||||||
|
status: 'fail',
|
||||||
|
duration,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (parkId) {
|
||||||
|
await supabase.from('parks').delete().eq('id', parkId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'version-004',
|
||||||
|
name: 'Complete Rollback Flow',
|
||||||
|
description: 'Tests end-to-end rollback with version 3 creation',
|
||||||
|
run: async (): Promise<TestResult> => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
let parkId: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if user is moderator
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) throw new Error('No authenticated user');
|
||||||
|
|
||||||
|
const { data: isMod } = await supabase.rpc('is_moderator', { _user_id: user.id });
|
||||||
|
|
||||||
|
if (!isMod) {
|
||||||
|
// Skip test if not moderator
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
return {
|
||||||
|
id: 'version-004',
|
||||||
|
name: 'Complete Rollback Flow',
|
||||||
|
suite: 'Versioning & Rollback',
|
||||||
|
status: 'skip',
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: { reason: 'User is not a moderator, test requires moderator role' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create park
|
||||||
|
const slug = `test-park-${Date.now()}`;
|
||||||
|
const { data: park, error: createError } = await supabase
|
||||||
|
.from('parks')
|
||||||
|
.insert({
|
||||||
|
name: 'Original Name',
|
||||||
|
slug,
|
||||||
|
park_type: 'theme_park',
|
||||||
|
status: 'operating',
|
||||||
|
description: 'Original Description'
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (createError) throw new Error(`Park creation failed: ${createError.message}`);
|
||||||
|
if (!park) throw new Error('No park returned');
|
||||||
|
|
||||||
|
parkId = park.id;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Get version 1
|
||||||
|
const { data: v1, error: v1Error } = await supabase
|
||||||
|
.from('park_versions')
|
||||||
|
.select('version_id, name, description')
|
||||||
|
.eq('park_id', park.id)
|
||||||
|
.eq('version_number', 1)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (v1Error || !v1) throw new Error('Version 1 not found');
|
||||||
|
|
||||||
|
// Update park
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('parks')
|
||||||
|
.update({ name: 'Modified Name', description: 'Modified Description' })
|
||||||
|
.eq('id', park.id);
|
||||||
|
|
||||||
|
if (updateError) throw new Error(`Park update failed: ${updateError.message}`);
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Verify version 2
|
||||||
|
const { data: v2 } = await supabase
|
||||||
|
.from('park_versions')
|
||||||
|
.select('version_number, name')
|
||||||
|
.eq('park_id', park.id)
|
||||||
|
.eq('version_number', 2)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!v2) throw new Error('Version 2 not created');
|
||||||
|
if (v2.name !== 'Modified Name') throw new Error('Version 2 has incorrect data');
|
||||||
|
|
||||||
|
// Rollback to version 1
|
||||||
|
const { error: rollbackError } = await supabase.rpc('rollback_to_version', {
|
||||||
|
p_entity_type: 'park',
|
||||||
|
p_entity_id: park.id,
|
||||||
|
p_target_version_id: v1.version_id,
|
||||||
|
p_changed_by: user.id,
|
||||||
|
p_reason: 'Integration test rollback'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rollbackError) throw new Error(`Rollback failed: ${rollbackError.message}`);
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Verify park data restored
|
||||||
|
const { data: restored, error: restoredError } = await supabase
|
||||||
|
.from('parks')
|
||||||
|
.select('name, description')
|
||||||
|
.eq('id', park.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (restoredError) throw new Error(`Failed to fetch restored park: ${restoredError.message}`);
|
||||||
|
if (!restored) throw new Error('Restored park not found');
|
||||||
|
if (restored.name !== 'Original Name') {
|
||||||
|
throw new Error(`Rollback failed: expected "Original Name", got "${restored.name}"`);
|
||||||
|
}
|
||||||
|
if (restored.description !== 'Original Description') {
|
||||||
|
throw new Error(`Description not restored: expected "Original Description", got "${restored.description}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify version 3 created with change_type = 'restored'
|
||||||
|
const { data: v3, error: v3Error } = await supabase
|
||||||
|
.from('park_versions')
|
||||||
|
.select('*')
|
||||||
|
.eq('park_id', park.id)
|
||||||
|
.eq('version_number', 3)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (v3Error || !v3) throw new Error('Version 3 (restored) not created');
|
||||||
|
if (v3.change_type !== 'restored') {
|
||||||
|
throw new Error(`Expected change_type "restored", got "${v3.change_type}"`);
|
||||||
|
}
|
||||||
|
if (v3.name !== 'Original Name') throw new Error('Version 3 has incorrect data');
|
||||||
|
if (!v3.is_current) throw new Error('Version 3 is not marked as current');
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'version-004',
|
||||||
|
name: 'Complete Rollback Flow',
|
||||||
|
suite: 'Versioning & Rollback',
|
||||||
|
status: 'pass',
|
||||||
|
duration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
parkId: park.id,
|
||||||
|
versionsCreated: 3,
|
||||||
|
dataRestored: true,
|
||||||
|
v3ChangeType: v3.change_type,
|
||||||
|
v3IsCurrent: v3.is_current
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
return {
|
||||||
|
id: 'version-004',
|
||||||
|
name: 'Complete Rollback Flow',
|
||||||
|
suite: 'Versioning & Rollback',
|
||||||
|
status: 'fail',
|
||||||
|
duration,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (parkId) {
|
||||||
|
await supabase.from('parks').delete().eq('id', parkId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
193
src/lib/integrationTests/testRunner.ts
Normal file
193
src/lib/integrationTests/testRunner.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Integration Test Runner
|
||||||
|
*
|
||||||
|
* Core infrastructure for running comprehensive integration tests.
|
||||||
|
* Tests run against real database functions, edge functions, and API endpoints.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TestResult {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
suite: string;
|
||||||
|
status: 'pass' | 'fail' | 'skip' | 'running';
|
||||||
|
duration: number; // milliseconds
|
||||||
|
error?: string;
|
||||||
|
details?: any;
|
||||||
|
timestamp: string;
|
||||||
|
stack?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Test {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
run: () => Promise<TestResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestSuite {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
tests: Test[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IntegrationTestRunner {
|
||||||
|
private results: TestResult[] = [];
|
||||||
|
private isRunning = false;
|
||||||
|
private shouldStop = false;
|
||||||
|
private onProgress?: (result: TestResult) => void;
|
||||||
|
|
||||||
|
constructor(onProgress?: (result: TestResult) => void) {
|
||||||
|
this.onProgress = onProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a single test with error handling
|
||||||
|
*/
|
||||||
|
async runTest(test: Test, suiteName: string): Promise<TestResult> {
|
||||||
|
if (this.shouldStop) {
|
||||||
|
return {
|
||||||
|
id: test.id,
|
||||||
|
name: test.name,
|
||||||
|
suite: suiteName,
|
||||||
|
status: 'skip',
|
||||||
|
duration: 0,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
details: { reason: 'Test run stopped by user' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as running
|
||||||
|
const runningResult: TestResult = {
|
||||||
|
id: test.id,
|
||||||
|
name: test.name,
|
||||||
|
suite: suiteName,
|
||||||
|
status: 'running',
|
||||||
|
duration: 0,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.onProgress) {
|
||||||
|
this.onProgress(runningResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await test.run();
|
||||||
|
this.results.push(result);
|
||||||
|
|
||||||
|
if (this.onProgress) {
|
||||||
|
this.onProgress(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const failResult: TestResult = {
|
||||||
|
id: test.id,
|
||||||
|
name: test.name,
|
||||||
|
suite: suiteName,
|
||||||
|
status: 'fail',
|
||||||
|
duration: 0,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.results.push(failResult);
|
||||||
|
|
||||||
|
if (this.onProgress) {
|
||||||
|
this.onProgress(failResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
return failResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run all tests in a suite
|
||||||
|
*/
|
||||||
|
async runSuite(suite: TestSuite): Promise<TestResult[]> {
|
||||||
|
const suiteResults: TestResult[] = [];
|
||||||
|
|
||||||
|
for (const test of suite.tests) {
|
||||||
|
const result = await this.runTest(test, suite.name);
|
||||||
|
suiteResults.push(result);
|
||||||
|
|
||||||
|
if (this.shouldStop) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return suiteResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run all suites sequentially
|
||||||
|
*/
|
||||||
|
async runAllSuites(suites: TestSuite[]): Promise<TestResult[]> {
|
||||||
|
this.results = [];
|
||||||
|
this.isRunning = true;
|
||||||
|
this.shouldStop = false;
|
||||||
|
|
||||||
|
for (const suite of suites) {
|
||||||
|
await this.runSuite(suite);
|
||||||
|
|
||||||
|
if (this.shouldStop) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRunning = false;
|
||||||
|
return this.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the test run
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
this.shouldStop = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all results
|
||||||
|
*/
|
||||||
|
getResults(): TestResult[] {
|
||||||
|
return this.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get summary statistics
|
||||||
|
*/
|
||||||
|
getSummary(): {
|
||||||
|
total: number;
|
||||||
|
passed: number;
|
||||||
|
failed: number;
|
||||||
|
skipped: number;
|
||||||
|
running: number;
|
||||||
|
totalDuration: number;
|
||||||
|
} {
|
||||||
|
const total = this.results.length;
|
||||||
|
const passed = this.results.filter(r => r.status === 'pass').length;
|
||||||
|
const failed = this.results.filter(r => r.status === 'fail').length;
|
||||||
|
const skipped = this.results.filter(r => r.status === 'skip').length;
|
||||||
|
const running = this.results.filter(r => r.status === 'running').length;
|
||||||
|
const totalDuration = this.results.reduce((sum, r) => sum + r.duration, 0);
|
||||||
|
|
||||||
|
return { total, passed, failed, skipped, running, totalDuration };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if runner is currently running
|
||||||
|
*/
|
||||||
|
getIsRunning(): boolean {
|
||||||
|
return this.isRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset runner state
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
this.results = [];
|
||||||
|
this.isRunning = false;
|
||||||
|
this.shouldStop = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@ import { useUserRole } from '@/hooks/useUserRole';
|
|||||||
import { useAdminSettings } from '@/hooks/useAdminSettings';
|
import { useAdminSettings } from '@/hooks/useAdminSettings';
|
||||||
import { NovuMigrationUtility } from '@/components/admin/NovuMigrationUtility';
|
import { NovuMigrationUtility } from '@/components/admin/NovuMigrationUtility';
|
||||||
import { TestDataGenerator } from '@/components/admin/TestDataGenerator';
|
import { TestDataGenerator } from '@/components/admin/TestDataGenerator';
|
||||||
import { Loader2, Save, Clock, Users, Bell, Shield, Settings, Trash2, Plug, AlertTriangle, Lock } from 'lucide-react';
|
import { IntegrationTestRunner } from '@/components/admin/IntegrationTestRunner';
|
||||||
|
import { Loader2, Save, Clock, Users, Bell, Shield, Settings, Trash2, Plug, AlertTriangle, Lock, TestTube } from 'lucide-react';
|
||||||
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
||||||
|
|
||||||
export default function AdminSettings() {
|
export default function AdminSettings() {
|
||||||
@@ -477,7 +478,7 @@ export default function AdminSettings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="moderation" className="space-y-6">
|
<Tabs defaultValue="moderation" className="space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-5">
|
<TabsList className="grid w-full grid-cols-6">
|
||||||
<TabsTrigger value="moderation" className="flex items-center gap-2">
|
<TabsTrigger value="moderation" className="flex items-center gap-2">
|
||||||
<Shield className="w-4 h-4" />
|
<Shield className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">Moderation</span>
|
<span className="hidden sm:inline">Moderation</span>
|
||||||
@@ -502,6 +503,10 @@ export default function AdminSettings() {
|
|||||||
<Loader2 className="w-4 h-4" />
|
<Loader2 className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">Testing</span>
|
<span className="hidden sm:inline">Testing</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="integration-tests" className="flex items-center gap-2">
|
||||||
|
<TestTube className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Integration Tests</span>
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="moderation">
|
<TabsContent value="moderation">
|
||||||
@@ -639,6 +644,10 @@ export default function AdminSettings() {
|
|||||||
<TabsContent value="testing">
|
<TabsContent value="testing">
|
||||||
<TestDataGenerator />
|
<TestDataGenerator />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="integration-tests">
|
||||||
|
<IntegrationTestRunner />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
|
|||||||
Reference in New Issue
Block a user