Implement auth composables and starter selection (F1-003, F1-004, F1-005)
Features: - Add useAuth composable with OAuth flow and token management - Add useStarter composable with API integration and dev mock fallback - Implement app auth initialization blocking navigation until ready - Complete StarterSelectionPage with 5 themed deck options Bug fixes: - Fix CORS by adding localhost:3001 to allowed origins - Fix OAuth URL to include redirect_uri parameter - Fix emoji rendering in nav components (use actual chars, not escapes) - Fix requireStarter guard timing by allowing navigation from /starter - Fix starter "already selected" detection for 400 status code Documentation: - Update dev-server skill to use `docker compose` (newer CLI syntax) - Update .env.example with port 3001 in CORS comment Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f687909f91
commit
3cc8d6645e
@ -1,6 +1,6 @@
|
||||
# Dev Server Skill
|
||||
|
||||
Start both frontend and backend development servers with pre-flight checks.
|
||||
Start the complete Mantimon TCG development environment including all services.
|
||||
|
||||
## Usage
|
||||
|
||||
@ -9,9 +9,9 @@ Start both frontend and backend development servers with pre-flight checks.
|
||||
```
|
||||
|
||||
**Commands:**
|
||||
- `start` (default) - Run checks and start both servers
|
||||
- `stop` - Stop all running dev servers
|
||||
- `status` - Check if servers are running
|
||||
- `start` (default) - Start all services (Docker infra + backend + frontend)
|
||||
- `stop` - Stop all running services
|
||||
- `status` - Check status of all services
|
||||
|
||||
## Instructions
|
||||
|
||||
@ -19,144 +19,225 @@ When this skill is invoked, follow these steps:
|
||||
|
||||
### 1. Pre-flight Checks
|
||||
|
||||
Run all checks in parallel where possible:
|
||||
|
||||
#### Environment Variables
|
||||
Check that required env files exist and have valid values:
|
||||
|
||||
**Frontend** (`frontend/.env.development`):
|
||||
- `VITE_API_BASE_URL` - Must be a valid URL
|
||||
- `VITE_WS_URL` - Must be a valid URL
|
||||
- `VITE_OAUTH_REDIRECT_URI` - Must be a valid URL
|
||||
|
||||
**Backend** (`backend/.env` or environment):
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `REDIS_URL` - Redis connection string
|
||||
- `JWT_SECRET` - Must be set (check length >= 32)
|
||||
|
||||
#### Port Availability
|
||||
Check if default ports are available:
|
||||
- Frontend: 5173, 3000, 3001 (Vite will auto-increment)
|
||||
- Backend: 8000, 8001
|
||||
Run checks to understand current state:
|
||||
|
||||
```bash
|
||||
# Check port availability
|
||||
lsof -i :8000 2>/dev/null | grep LISTEN
|
||||
lsof -i :5173 2>/dev/null | grep LISTEN
|
||||
# Check Docker services
|
||||
cd /mnt/NV2/Development/mantimon-tcg/backend
|
||||
docker compose ps 2>/dev/null | grep -E "mantimon-(postgres|redis|adminer)" || echo "Docker services: not running"
|
||||
|
||||
# Check application servers
|
||||
curl -s http://localhost:8001/health 2>/dev/null && echo "Backend: running" || echo "Backend: not running"
|
||||
curl -s http://localhost:3001 >/dev/null 2>&1 && echo "Frontend: running" || echo "Frontend: not running"
|
||||
|
||||
# Check environment files
|
||||
[ -f "/mnt/NV2/Development/mantimon-tcg/frontend/.env.development" ] && echo "Frontend .env: OK" || echo "Frontend .env: MISSING"
|
||||
[ -f "/mnt/NV2/Development/mantimon-tcg/backend/.env" ] && echo "Backend .env: OK" || echo "Backend .env: MISSING"
|
||||
|
||||
# Check dependencies
|
||||
[ -d "/mnt/NV2/Development/mantimon-tcg/frontend/node_modules" ] && echo "node_modules: OK" || echo "node_modules: MISSING"
|
||||
[ -d "/mnt/NV2/Development/mantimon-tcg/backend/.venv" ] && echo "Backend venv: OK" || echo "Backend venv: MISSING"
|
||||
```
|
||||
|
||||
#### Dependencies
|
||||
Verify dependencies are installed:
|
||||
### 2. Start Infrastructure Services (Docker Compose)
|
||||
|
||||
Start PostgreSQL, Redis, and Adminer using the existing docker compose file:
|
||||
|
||||
```bash
|
||||
cd /mnt/NV2/Development/mantimon-tcg/backend
|
||||
docker compose up -d
|
||||
|
||||
# Wait for services to be healthy
|
||||
echo "Waiting for services to be ready..."
|
||||
sleep 5
|
||||
|
||||
# Verify services are up
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### 3. Install Dependencies (if needed)
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
[ -d "frontend/node_modules" ] || echo "Run: cd frontend && npm install"
|
||||
if [ ! -d "/mnt/NV2/Development/mantimon-tcg/frontend/node_modules" ]; then
|
||||
echo "Installing frontend dependencies..."
|
||||
cd /mnt/NV2/Development/mantimon-tcg/frontend && npm install
|
||||
fi
|
||||
|
||||
# Backend
|
||||
[ -f "backend/.venv/bin/activate" ] || echo "Run: cd backend && uv sync"
|
||||
if [ ! -d "/mnt/NV2/Development/mantimon-tcg/backend/.venv" ]; then
|
||||
echo "Installing backend dependencies..."
|
||||
cd /mnt/NV2/Development/mantimon-tcg/backend && uv sync
|
||||
fi
|
||||
```
|
||||
|
||||
#### Database/Redis
|
||||
Check if PostgreSQL and Redis are accessible:
|
||||
### 4. Run Database Migrations
|
||||
|
||||
```bash
|
||||
# PostgreSQL (if DATABASE_URL is set)
|
||||
pg_isready -d "$DATABASE_URL" 2>/dev/null
|
||||
|
||||
# Redis (if REDIS_URL is set)
|
||||
redis-cli -u "$REDIS_URL" ping 2>/dev/null
|
||||
cd /mnt/NV2/Development/mantimon-tcg/backend && uv run alembic upgrade head
|
||||
```
|
||||
|
||||
### 2. Report Issues
|
||||
|
||||
If any checks fail, display a clear report:
|
||||
|
||||
```markdown
|
||||
## Pre-flight Check Results
|
||||
|
||||
| Check | Status | Action Required |
|
||||
|-------|--------|-----------------|
|
||||
| Frontend .env | MISSING | Create frontend/.env.development |
|
||||
| Port 8000 | IN USE | Kill process or use --port 8001 |
|
||||
| node_modules | MISSING | Run: cd frontend && npm install |
|
||||
| PostgreSQL | OK | - |
|
||||
```
|
||||
|
||||
If there are blocking issues, ask the user how to proceed.
|
||||
|
||||
### 3. Start Servers
|
||||
### 5. Start Application Servers
|
||||
|
||||
Start both servers in the background:
|
||||
|
||||
```bash
|
||||
# Start backend first (frontend depends on it)
|
||||
cd backend && uv run uvicorn app.main:app --reload --port $BACKEND_PORT 2>&1 &
|
||||
# Start backend (port 8001)
|
||||
cd /mnt/NV2/Development/mantimon-tcg/backend && uv run uvicorn app.main:app --reload --port 8001 2>&1 &
|
||||
|
||||
# Wait for backend to be ready
|
||||
sleep 3
|
||||
for i in {1..15}; do
|
||||
curl -s http://localhost:8001/health 2>/dev/null && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Start frontend
|
||||
cd frontend && npm run dev 2>&1 &
|
||||
# Start frontend (port 3001)
|
||||
cd /mnt/NV2/Development/mantimon-tcg/frontend && npm run dev 2>&1 &
|
||||
|
||||
# Wait for frontend to be ready
|
||||
for i in {1..15}; do
|
||||
curl -s http://localhost:3001 >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
```
|
||||
|
||||
### 4. Verify and Display Status
|
||||
### 6. Display Status
|
||||
|
||||
Wait for both servers to be ready, then display:
|
||||
After starting, display the status table:
|
||||
|
||||
```markdown
|
||||
## Dev Environment Ready
|
||||
## Dev Environment Status
|
||||
|
||||
| Service | URL | Status |
|
||||
|---------|-----|--------|
|
||||
| Frontend | http://localhost:5173/ | Running |
|
||||
| Backend API | http://localhost:8000/ | Running |
|
||||
| Backend Docs | http://localhost:8000/docs | Running |
|
||||
| PostgreSQL | localhost:5432 | Connected |
|
||||
| Redis | localhost:6379 | Connected |
|
||||
| Service | URL/Port | Status |
|
||||
|---------|----------|--------|
|
||||
| Frontend | http://localhost:3001 | Running |
|
||||
| Backend API | http://localhost:8001 | Running |
|
||||
| API Docs | http://localhost:8001/docs | Running |
|
||||
| PostgreSQL | localhost:5433 | Running |
|
||||
| Redis | localhost:6380 | Running |
|
||||
| Adminer | http://localhost:8090 | Running |
|
||||
|
||||
**Quick Links:**
|
||||
- App: http://localhost:5173/
|
||||
- API Docs: http://localhost:8000/docs
|
||||
- Health Check: http://localhost:8000/api/health
|
||||
- App: http://localhost:3001
|
||||
- API Docs: http://localhost:8001/docs
|
||||
- Health Check: http://localhost:8001/health
|
||||
- Database Admin: http://localhost:8090 (System: PostgreSQL, Server: mantimon-postgres, User: mantimon, Pass: mantimon)
|
||||
|
||||
**OAuth Status:**
|
||||
- Discord: [Configured/Not configured]
|
||||
- Google: [Configured/Not configured]
|
||||
```
|
||||
|
||||
### 5. Stop Command
|
||||
Check OAuth configuration:
|
||||
```bash
|
||||
# Check if Discord is configured (has actual values, not placeholder)
|
||||
grep -E "^DISCORD_CLIENT_ID=.+" /mnt/NV2/Development/mantimon-tcg/backend/.env 2>/dev/null | grep -v "your-client-id" && \
|
||||
echo "Discord OAuth: Configured" || echo "Discord OAuth: Not configured"
|
||||
|
||||
# Check if Google is configured
|
||||
grep -E "^GOOGLE_CLIENT_ID=.+" /mnt/NV2/Development/mantimon-tcg/backend/.env 2>/dev/null | grep -v "your-client-id" && \
|
||||
echo "Google OAuth: Configured" || echo "Google OAuth: Not configured"
|
||||
```
|
||||
|
||||
### 7. Stop Command
|
||||
|
||||
When `stop` is requested:
|
||||
|
||||
```bash
|
||||
pkill -f "uvicorn app.main:app"
|
||||
pkill -f "vite"
|
||||
# Stop application servers
|
||||
pkill -f "uvicorn app.main:app" 2>/dev/null
|
||||
pkill -f "vite" 2>/dev/null
|
||||
|
||||
echo "Application servers stopped."
|
||||
```
|
||||
|
||||
### 6. Status Command
|
||||
Then ask: "Stop Docker containers (PostgreSQL/Redis/Adminer) too? They can be left running for faster restarts."
|
||||
|
||||
When `status` is requested, check running processes and display current state.
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
The skill should check for and warn about:
|
||||
|
||||
1. **Port mismatch** - Frontend .env pointing to wrong backend port
|
||||
2. **Missing OAuth config** - Google/Discord credentials not set
|
||||
3. **Database not migrated** - Check if alembic migrations are pending
|
||||
4. **Redis not running** - Socket.IO requires Redis for pub/sub
|
||||
5. **Old processes** - Zombie uvicorn/vite processes from crashed sessions
|
||||
6. **TypeScript errors** - Run `npm run typecheck` before starting
|
||||
|
||||
## Environment Variable Reference
|
||||
|
||||
### Frontend (.env.development)
|
||||
If yes:
|
||||
```bash
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
VITE_WS_URL=http://localhost:8000
|
||||
VITE_OAUTH_REDIRECT_URI=http://localhost:5173/auth/callback
|
||||
cd /mnt/NV2/Development/mantimon-tcg/backend && docker compose down
|
||||
```
|
||||
|
||||
### Backend (.env)
|
||||
### 8. Status Command
|
||||
|
||||
When `status` is requested, check and display all services without starting anything:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/mantimon
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
JWT_SECRET=your-secret-key-at-least-32-characters
|
||||
GOOGLE_CLIENT_ID=optional
|
||||
GOOGLE_CLIENT_SECRET=optional
|
||||
DISCORD_CLIENT_ID=optional
|
||||
DISCORD_CLIENT_SECRET=optional
|
||||
cd /mnt/NV2/Development/mantimon-tcg/backend
|
||||
|
||||
echo "=== Docker Services ==="
|
||||
docker compose ps
|
||||
|
||||
echo ""
|
||||
echo "=== Application Servers ==="
|
||||
curl -s http://localhost:8001/health 2>/dev/null && echo "Backend: healthy" || echo "Backend: not running"
|
||||
curl -s http://localhost:3001 >/dev/null 2>&1 && echo "Frontend: running" || echo "Frontend: not running"
|
||||
```
|
||||
|
||||
## Port Configuration
|
||||
|
||||
| Service | Port | Notes |
|
||||
|---------|------|-------|
|
||||
| Frontend | 3001 | Vite dev server |
|
||||
| Backend | 8001 | FastAPI with uvicorn |
|
||||
| PostgreSQL | 5433 | Non-standard to avoid conflicts |
|
||||
| Redis | 6380 | Non-standard to avoid conflicts |
|
||||
| Adminer | 8090 | Database admin UI |
|
||||
|
||||
## Environment Files
|
||||
|
||||
### Frontend (`frontend/.env.development`)
|
||||
```bash
|
||||
VITE_API_BASE_URL=http://localhost:8001
|
||||
VITE_WS_URL=http://localhost:8001
|
||||
VITE_OAUTH_REDIRECT_URI=http://localhost:3001/auth/callback
|
||||
```
|
||||
|
||||
### Backend (`backend/.env`)
|
||||
```bash
|
||||
# Database
|
||||
DATABASE_URL=postgresql+asyncpg://mantimon:mantimon@localhost:5433/mantimon
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6380/0
|
||||
|
||||
# Security
|
||||
SECRET_KEY=dev-secret-key-change-in-production
|
||||
|
||||
# OAuth (optional for dev)
|
||||
DISCORD_CLIENT_ID=your-client-id
|
||||
DISCORD_CLIENT_SECRET=your-client-secret
|
||||
# GOOGLE_CLIENT_ID=your-client-id
|
||||
# GOOGLE_CLIENT_SECRET=your-client-secret
|
||||
|
||||
# Base URL for OAuth callbacks
|
||||
BASE_URL=http://localhost:8001
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port already in use
|
||||
```bash
|
||||
lsof -i :8001 # Find process
|
||||
kill -9 <PID> # Kill it
|
||||
```
|
||||
|
||||
### Docker services not starting
|
||||
```bash
|
||||
cd backend
|
||||
docker compose logs # Check what went wrong
|
||||
docker compose down && docker compose up -d # Restart
|
||||
```
|
||||
|
||||
### Database migrations failed
|
||||
```bash
|
||||
cd backend && uv run alembic downgrade -1 && uv run alembic upgrade head
|
||||
```
|
||||
|
||||
### Clear all and start fresh
|
||||
```bash
|
||||
cd backend
|
||||
docker compose down -v # Remove volumes too
|
||||
docker compose up -d
|
||||
uv run alembic upgrade head
|
||||
```
|
||||
|
||||
@ -68,7 +68,8 @@ JWT_REFRESH_EXPIRE_DAYS=7
|
||||
# CORS
|
||||
# =============================================================================
|
||||
# Comma-separated list of allowed origins
|
||||
# CORS_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||
# Default: http://localhost:3000,http://localhost:3001,http://localhost:5173
|
||||
# CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:5173
|
||||
|
||||
# =============================================================================
|
||||
# GAME SETTINGS
|
||||
|
||||
@ -150,7 +150,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# CORS
|
||||
cors_origins: list[str] = Field(
|
||||
default=["http://localhost:3000", "http://localhost:5173"],
|
||||
default=["http://localhost:3000", "http://localhost:3001", "http://localhost:5173"],
|
||||
description="Allowed CORS origins",
|
||||
)
|
||||
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
"name": "Authentication Flow",
|
||||
"version": "1.0.0",
|
||||
"created": "2026-01-30",
|
||||
"lastUpdated": "2026-01-30",
|
||||
"lastUpdated": "2026-01-30T15:00:00Z",
|
||||
"totalTasks": 10,
|
||||
"completedTasks": 2,
|
||||
"completedTasks": 5,
|
||||
"status": "in_progress",
|
||||
"description": "Complete OAuth authentication flow including login, callback handling, starter deck selection, profile management, and app initialization."
|
||||
},
|
||||
@ -95,8 +95,8 @@
|
||||
"description": "Vue composable for auth operations with loading/error states",
|
||||
"category": "composables",
|
||||
"priority": 3,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["F1-002"],
|
||||
"files": [
|
||||
{"path": "src/composables/useAuth.ts", "status": "create"},
|
||||
@ -124,8 +124,8 @@
|
||||
"description": "Initialize auth state on app startup",
|
||||
"category": "setup",
|
||||
"priority": 4,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["F1-003"],
|
||||
"files": [
|
||||
{"path": "src/App.vue", "status": "modify"},
|
||||
@ -153,8 +153,8 @@
|
||||
"description": "Complete starter deck selection with API integration",
|
||||
"category": "pages",
|
||||
"priority": 5,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["F1-003"],
|
||||
"files": [
|
||||
{"path": "src/pages/StarterSelectionPage.vue", "status": "modify"},
|
||||
|
||||
234
frontend/src/App.spec.ts
Normal file
234
frontend/src/App.spec.ts
Normal file
@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Tests for App.vue root component.
|
||||
*
|
||||
* Verifies that auth initialization happens on app startup and that
|
||||
* the loading state is properly displayed during initialization.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { defineComponent, h, nextTick } from 'vue'
|
||||
|
||||
// Track initialization calls and control resolution
|
||||
let initializeResolve: ((value: boolean) => void) | null = null
|
||||
let initializeCalled = false
|
||||
|
||||
// Mock useAuth composable
|
||||
vi.mock('@/composables/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
initialize: vi.fn(() => {
|
||||
initializeCalled = true
|
||||
return new Promise<boolean>((resolve) => {
|
||||
initializeResolve = resolve
|
||||
})
|
||||
}),
|
||||
isInitialized: { value: false },
|
||||
isAuthenticated: { value: false },
|
||||
user: { value: null },
|
||||
isLoading: { value: false },
|
||||
error: { value: null },
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock vue-router
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
meta: { layout: 'default' },
|
||||
name: 'Dashboard',
|
||||
path: '/',
|
||||
}),
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
// Create stub components for layouts
|
||||
const DefaultLayoutStub = defineComponent({
|
||||
name: 'DefaultLayout',
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', { class: 'default-layout' }, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
const MinimalLayoutStub = defineComponent({
|
||||
name: 'MinimalLayout',
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', { class: 'minimal-layout' }, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
const GameLayoutStub = defineComponent({
|
||||
name: 'GameLayout',
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', { class: 'game-layout' }, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
const LoadingOverlayStub = defineComponent({
|
||||
name: 'LoadingOverlay',
|
||||
setup() {
|
||||
return () => h('div', { class: 'loading-overlay-mock' })
|
||||
},
|
||||
})
|
||||
|
||||
const ToastContainerStub = defineComponent({
|
||||
name: 'ToastContainer',
|
||||
setup() {
|
||||
return () => h('div', { class: 'toast-container-mock' })
|
||||
},
|
||||
})
|
||||
|
||||
describe('App.vue', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
initializeCalled = false
|
||||
initializeResolve = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const mountApp = () => {
|
||||
return mount(App, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterView: true,
|
||||
DefaultLayout: DefaultLayoutStub,
|
||||
MinimalLayout: MinimalLayoutStub,
|
||||
GameLayout: GameLayoutStub,
|
||||
LoadingOverlay: LoadingOverlayStub,
|
||||
ToastContainer: ToastContainerStub,
|
||||
// Also stub async component wrappers
|
||||
AsyncComponentWrapper: true,
|
||||
Suspense: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('auth initialization', () => {
|
||||
it('shows loading spinner while auth is initializing', async () => {
|
||||
/**
|
||||
* Test loading state during auth initialization.
|
||||
*
|
||||
* When the app starts, it should show a loading spinner while
|
||||
* auth tokens are being validated. This prevents navigation guards
|
||||
* from running before auth state is known.
|
||||
*/
|
||||
const wrapper = mountApp()
|
||||
|
||||
// Should show loading text initially
|
||||
expect(wrapper.text()).toContain('Loading...')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('calls initialize on mount', async () => {
|
||||
/**
|
||||
* Test that auth initialization is triggered on mount.
|
||||
*
|
||||
* The initialize() function must be called to validate tokens,
|
||||
* refresh if needed, and fetch the user profile.
|
||||
*/
|
||||
const wrapper = mountApp()
|
||||
|
||||
// Wait for onMounted to run
|
||||
await nextTick()
|
||||
|
||||
expect(initializeCalled).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('hides loading spinner after initialization completes', async () => {
|
||||
/**
|
||||
* Test transition from loading to content.
|
||||
*
|
||||
* Once auth initialization completes, the loading spinner should
|
||||
* be hidden and the main app content should be rendered.
|
||||
*/
|
||||
const wrapper = mountApp()
|
||||
|
||||
// Wait for onMounted
|
||||
await nextTick()
|
||||
|
||||
// Should show loading initially
|
||||
expect(wrapper.text()).toContain('Loading...')
|
||||
|
||||
// Resolve initialization (successful)
|
||||
expect(initializeResolve).not.toBeNull()
|
||||
initializeResolve!(true)
|
||||
|
||||
// Wait for state update
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
// Loading text should be gone
|
||||
expect(wrapper.text()).not.toContain('Loading...')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('initialization state machine', () => {
|
||||
it('starts in initializing state and transitions to initialized', async () => {
|
||||
/**
|
||||
* Test the auth initialization state machine.
|
||||
*
|
||||
* The app should start showing the loading state and transition
|
||||
* to the main content after initialization completes.
|
||||
*/
|
||||
const wrapper = mountApp()
|
||||
await nextTick()
|
||||
|
||||
// Before initialization completes
|
||||
expect(wrapper.find('.bg-gray-900').exists()).toBe(true)
|
||||
|
||||
// Complete initialization (successful)
|
||||
initializeResolve!(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
// After initialization - the fixed loading div should be gone
|
||||
// (checking that the loading screen specific class combo is gone)
|
||||
const loadingScreen = wrapper.find('.fixed.inset-0.z-50.bg-gray-900')
|
||||
expect(loadingScreen.exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders app content even when initialization returns false (auth failed)', async () => {
|
||||
/**
|
||||
* Test that app renders correctly when auth initialization fails.
|
||||
*
|
||||
* When initialize() returns false (e.g., tokens invalid, network error
|
||||
* during profile fetch), the app should still transition out of the
|
||||
* loading state and render the main content. Navigation guards will
|
||||
* handle redirecting unauthenticated users to login.
|
||||
*/
|
||||
const wrapper = mountApp()
|
||||
await nextTick()
|
||||
|
||||
// Should show loading initially
|
||||
expect(wrapper.text()).toContain('Loading...')
|
||||
|
||||
// Complete initialization with failure (auth failed)
|
||||
initializeResolve!(false)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
// Loading screen should be gone - app renders even on auth failure
|
||||
const loadingScreen = wrapper.find('.fixed.inset-0.z-50.bg-gray-900')
|
||||
expect(loadingScreen.exists()).toBe(false)
|
||||
|
||||
// Loading text should be gone
|
||||
expect(wrapper.text()).not.toContain('Loading...')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -2,14 +2,21 @@
|
||||
/**
|
||||
* Root application component.
|
||||
*
|
||||
* Renders the appropriate layout based on the current route's meta.layout
|
||||
* property. Also includes global UI components (loading overlay, toasts).
|
||||
* Handles auth initialization on startup and renders the appropriate layout
|
||||
* based on the current route's meta.layout property.
|
||||
*
|
||||
* Auth initialization:
|
||||
* - Shows loading spinner while validating tokens
|
||||
* - Refreshes expired tokens automatically
|
||||
* - Fetches user profile if authenticated
|
||||
* - Blocks navigation until initialization completes
|
||||
*/
|
||||
import { computed, defineAsyncComponent } from 'vue'
|
||||
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import LoadingOverlay from '@/components/ui/LoadingOverlay.vue'
|
||||
import ToastContainer from '@/components/ui/ToastContainer.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
|
||||
// Lazy-load layouts to reduce initial bundle size
|
||||
const DefaultLayout = defineAsyncComponent(() => import('@/layouts/DefaultLayout.vue'))
|
||||
@ -17,6 +24,10 @@ const MinimalLayout = defineAsyncComponent(() => import('@/layouts/MinimalLayout
|
||||
const GameLayout = defineAsyncComponent(() => import('@/layouts/GameLayout.vue'))
|
||||
|
||||
const route = useRoute()
|
||||
const { initialize } = useAuth()
|
||||
|
||||
// Track if auth initialization is in progress
|
||||
const isAuthInitializing = ref(true)
|
||||
|
||||
type LayoutType = 'default' | 'minimal' | 'game'
|
||||
|
||||
@ -30,12 +41,43 @@ const currentLayout = computed(() => {
|
||||
const layout = (route.meta.layout as LayoutType) || 'default'
|
||||
return layoutComponents[layout] || layoutComponents.default
|
||||
})
|
||||
|
||||
// Initialize auth on app mount
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await initialize()
|
||||
} finally {
|
||||
isAuthInitializing.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="currentLayout">
|
||||
<RouterView />
|
||||
</component>
|
||||
<!-- Show loading spinner while auth initializes -->
|
||||
<div
|
||||
v-if="isAuthInitializing"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<!-- Spinner -->
|
||||
<div class="relative">
|
||||
<div class="w-12 h-12 border-4 border-primary/30 rounded-full" />
|
||||
<div
|
||||
class="absolute inset-0 w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-gray-300 text-sm">
|
||||
Loading...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main app content (only after auth is initialized) -->
|
||||
<template v-else>
|
||||
<component :is="currentLayout">
|
||||
<RouterView />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<!-- Global UI components -->
|
||||
<LoadingOverlay />
|
||||
|
||||
@ -18,11 +18,11 @@ interface NavTab {
|
||||
}
|
||||
|
||||
const tabs: NavTab[] = [
|
||||
{ path: '/', name: 'Dashboard', label: 'Home', icon: '\uD83C\uDFE0' },
|
||||
{ path: '/play', name: 'PlayMenu', label: 'Play', icon: '\u2694\uFE0F' },
|
||||
{ path: '/decks', name: 'DeckList', label: 'Decks', icon: '\uD83C\uDCCF' },
|
||||
{ path: '/collection', name: 'Collection', label: 'Cards', icon: '\uD83D\uDCDA' },
|
||||
{ path: '/profile', name: 'Profile', label: 'Profile', icon: '\uD83D\uDC64' },
|
||||
{ path: '/', name: 'Dashboard', label: 'Home', icon: '🏠' },
|
||||
{ path: '/play', name: 'PlayMenu', label: 'Play', icon: '⚔️' },
|
||||
{ path: '/decks', name: 'DeckList', label: 'Decks', icon: '🃏' },
|
||||
{ path: '/collection', name: 'Collection', label: 'Cards', icon: '📚' },
|
||||
{ path: '/profile', name: 'Profile', label: 'Profile', icon: '👤' },
|
||||
]
|
||||
|
||||
function isActive(tab: NavTab): boolean {
|
||||
|
||||
@ -27,11 +27,11 @@ interface NavItem {
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ path: '/', name: 'Dashboard', label: 'Home', icon: '\uD83C\uDFE0' },
|
||||
{ path: '/play', name: 'PlayMenu', label: 'Play', icon: '\u2694\uFE0F' },
|
||||
{ path: '/decks', name: 'DeckList', label: 'Decks', icon: '\uD83C\uDCCF' },
|
||||
{ path: '/collection', name: 'Collection', label: 'Collection', icon: '\uD83D\uDCDA' },
|
||||
{ path: '/campaign', name: 'Campaign', label: 'Campaign', icon: '\uD83C\uDFC6' },
|
||||
{ path: '/', name: 'Dashboard', label: 'Home', icon: '🏠' },
|
||||
{ path: '/play', name: 'PlayMenu', label: 'Play', icon: '⚔️' },
|
||||
{ path: '/decks', name: 'DeckList', label: 'Decks', icon: '🃏' },
|
||||
{ path: '/collection', name: 'Collection', label: 'Collection', icon: '📚' },
|
||||
{ path: '/campaign', name: 'Campaign', label: 'Campaign', icon: '🏆' },
|
||||
]
|
||||
|
||||
function isActive(item: NavItem): boolean {
|
||||
@ -89,7 +89,7 @@ async function handleLogout(): Promise<void> {
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-surface-light hover:text-white transition-colors"
|
||||
:class="{ 'bg-primary/20 text-primary-light': route.path === '/profile' }"
|
||||
>
|
||||
<span class="text-lg">\uD83D\uDC64</span>
|
||||
<span class="text-lg">👤</span>
|
||||
<span class="flex-1 truncate">{{ displayName }}</span>
|
||||
</RouterLink>
|
||||
|
||||
@ -97,7 +97,7 @@ async function handleLogout(): Promise<void> {
|
||||
class="flex items-center gap-3 w-full px-4 py-3 mt-2 rounded-lg text-gray-400 hover:bg-surface-light hover:text-white transition-colors"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<span class="text-lg">\uD83D\uDEAA</span>
|
||||
<span class="text-lg">🚪</span>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
955
frontend/src/composables/useAuth.spec.ts
Normal file
955
frontend/src/composables/useAuth.spec.ts
Normal file
@ -0,0 +1,955 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// Create mock router instance
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
currentRoute: { value: { name: 'AuthCallback', path: '/auth/callback' } },
|
||||
}
|
||||
|
||||
// Mock vue-router (hoisted)
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => mockRouter,
|
||||
useRoute: () => ({ query: {} }),
|
||||
}))
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the config
|
||||
vi.mock('@/config', () => ({
|
||||
config: {
|
||||
apiBaseUrl: 'http://localhost:8000',
|
||||
wsUrl: 'http://localhost:8000',
|
||||
oauthRedirectUri: 'http://localhost:5173/auth/callback',
|
||||
isDev: true,
|
||||
isProd: false,
|
||||
},
|
||||
}))
|
||||
|
||||
import { apiClient } from '@/api/client'
|
||||
import { useAuth } from './useAuth'
|
||||
|
||||
describe('useAuth', () => {
|
||||
let mockLocation: { hash: string; pathname: string; search: string; href: string }
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// Reset mock router
|
||||
mockRouter.push.mockReset()
|
||||
|
||||
// Mock window.location with a property descriptor that allows href assignment
|
||||
mockLocation = {
|
||||
hash: '',
|
||||
pathname: '/auth/callback',
|
||||
search: '',
|
||||
href: 'http://localhost:5173/auth/callback',
|
||||
}
|
||||
|
||||
// Delete and redefine to avoid conflicts
|
||||
delete (window as unknown as Record<string, unknown>).location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: mockLocation,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
// Mock history.replaceState
|
||||
vi.spyOn(window.history, 'replaceState').mockImplementation(() => {})
|
||||
|
||||
// Mock fetch for logout calls
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
})
|
||||
|
||||
// Reset mocks
|
||||
vi.mocked(apiClient.get).mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('starts with unauthenticated state', () => {
|
||||
/**
|
||||
* Test that useAuth starts in an unauthenticated state.
|
||||
*
|
||||
* Before OAuth flow or initialization completes, the composable
|
||||
* should report that the user is not authenticated.
|
||||
*/
|
||||
const { isAuthenticated, isInitialized, user, error } = useAuth()
|
||||
|
||||
expect(isAuthenticated.value).toBe(false)
|
||||
expect(isInitialized.value).toBe(false)
|
||||
expect(user.value).toBeNull()
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('starts with isLoading as false', () => {
|
||||
/**
|
||||
* Test initial loading state.
|
||||
*
|
||||
* The composable should not be in a loading state until
|
||||
* an async operation is initiated.
|
||||
*/
|
||||
const { isLoading } = useAuth()
|
||||
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('initiateOAuth', () => {
|
||||
it('redirects to Google OAuth URL with redirect_uri', () => {
|
||||
/**
|
||||
* Test Google OAuth initiation.
|
||||
*
|
||||
* When initiating Google OAuth, the browser should be redirected
|
||||
* to the backend's Google OAuth endpoint with a redirect_uri param,
|
||||
* which will then redirect to Google's consent screen.
|
||||
*/
|
||||
const { initiateOAuth } = useAuth()
|
||||
|
||||
initiateOAuth('google')
|
||||
|
||||
expect(mockLocation.href).toContain('/api/auth/google')
|
||||
expect(mockLocation.href).toContain('redirect_uri=')
|
||||
})
|
||||
|
||||
it('redirects to Discord OAuth URL with redirect_uri', () => {
|
||||
/**
|
||||
* Test Discord OAuth initiation.
|
||||
*
|
||||
* When initiating Discord OAuth, the browser should be redirected
|
||||
* to the backend's Discord OAuth endpoint with a redirect_uri param,
|
||||
* which will then redirect to Discord's consent screen.
|
||||
*/
|
||||
const { initiateOAuth } = useAuth()
|
||||
|
||||
initiateOAuth('discord')
|
||||
|
||||
expect(mockLocation.href).toContain('/api/auth/discord')
|
||||
expect(mockLocation.href).toContain('redirect_uri=')
|
||||
})
|
||||
|
||||
it('clears any existing error', () => {
|
||||
/**
|
||||
* Test error clearing on OAuth initiation.
|
||||
*
|
||||
* Starting a new OAuth flow should clear any previous errors
|
||||
* so users don't see stale error messages.
|
||||
*/
|
||||
const auth = useAuth()
|
||||
|
||||
auth.initiateOAuth('google')
|
||||
|
||||
expect(auth.error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleCallback', () => {
|
||||
it('successfully extracts tokens from URL hash', async () => {
|
||||
/**
|
||||
* Test token extraction from OAuth callback.
|
||||
*
|
||||
* The OAuth provider redirects back with tokens in the URL fragment.
|
||||
* handleCallback must parse these tokens and store them correctly.
|
||||
*/
|
||||
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
id: 'user-1',
|
||||
display_name: 'Test User',
|
||||
avatar_url: null,
|
||||
has_starter_deck: true,
|
||||
})
|
||||
|
||||
const { handleCallback } = useAuth()
|
||||
const result = await handleCallback()
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.user?.id).toBe('user-1')
|
||||
expect(result.user?.displayName).toBe('Test User')
|
||||
})
|
||||
|
||||
it('stores tokens in auth store', async () => {
|
||||
/**
|
||||
* Test that tokens are persisted in the auth store.
|
||||
*
|
||||
* After extracting tokens, they must be stored in the auth store
|
||||
* so they can be used for subsequent API requests.
|
||||
*/
|
||||
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
id: 'user-1',
|
||||
display_name: 'Test User',
|
||||
avatar_url: null,
|
||||
has_starter_deck: true,
|
||||
})
|
||||
|
||||
const { handleCallback } = useAuth()
|
||||
await handleCallback()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
expect(authStore.accessToken).toBe('abc123')
|
||||
expect(authStore.refreshToken).toBe('xyz789')
|
||||
expect(authStore.expiresAt).toBeGreaterThan(Date.now())
|
||||
})
|
||||
|
||||
it('fetches user profile after storing tokens', async () => {
|
||||
/**
|
||||
* Test profile fetch after token storage.
|
||||
*
|
||||
* Once tokens are stored, we need to fetch the user's profile
|
||||
* to know their display name, avatar, and starter deck status.
|
||||
*/
|
||||
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
id: 'user-1',
|
||||
display_name: 'Test User',
|
||||
avatar_url: 'https://example.com/avatar.png',
|
||||
has_starter_deck: true,
|
||||
})
|
||||
|
||||
const { handleCallback } = useAuth()
|
||||
await handleCallback()
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/api/users/me')
|
||||
|
||||
const authStore = useAuthStore()
|
||||
expect(authStore.user).toEqual({
|
||||
id: 'user-1',
|
||||
displayName: 'Test User',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
hasStarterDeck: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns needsStarter=true for users without starter deck', async () => {
|
||||
/**
|
||||
* Test starter deck status in callback result.
|
||||
*
|
||||
* The callback result should indicate if the user needs to select
|
||||
* a starter deck, allowing the caller to redirect appropriately.
|
||||
*/
|
||||
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
id: 'user-1',
|
||||
display_name: 'New User',
|
||||
avatar_url: null,
|
||||
has_starter_deck: false,
|
||||
})
|
||||
|
||||
const { handleCallback } = useAuth()
|
||||
const result = await handleCallback()
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.needsStarter).toBe(true)
|
||||
})
|
||||
|
||||
it('returns needsStarter=false for users with starter deck', async () => {
|
||||
/**
|
||||
* Test starter deck status for existing users.
|
||||
*
|
||||
* Users who already have a starter deck should have needsStarter=false,
|
||||
* allowing them to proceed directly to the dashboard.
|
||||
*/
|
||||
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
id: 'user-1',
|
||||
display_name: 'Existing User',
|
||||
avatar_url: null,
|
||||
has_starter_deck: true,
|
||||
})
|
||||
|
||||
const { handleCallback } = useAuth()
|
||||
const result = await handleCallback()
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.needsStarter).toBe(false)
|
||||
})
|
||||
|
||||
it('returns error for missing tokens in hash', async () => {
|
||||
/**
|
||||
* Test error handling for malformed OAuth response.
|
||||
*
|
||||
* If the URL fragment is missing required tokens, handleCallback
|
||||
* should return an error result, not throw an exception.
|
||||
*/
|
||||
mockLocation.hash = '#access_token=abc123' // Missing refresh_token and expires_in
|
||||
|
||||
const { handleCallback } = useAuth()
|
||||
const result = await handleCallback()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Missing tokens')
|
||||
})
|
||||
|
||||
it('returns error for empty hash', async () => {
|
||||
/**
|
||||
* Test error handling for empty hash fragment.
|
||||
*
|
||||
* If the OAuth provider redirects without any tokens,
|
||||
* handleCallback should return an appropriate error.
|
||||
*/
|
||||
mockLocation.hash = ''
|
||||
|
||||
const { handleCallback } = useAuth()
|
||||
const result = await handleCallback()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBeDefined()
|
||||
})
|
||||
|
||||
it('returns error from OAuth provider query params', async () => {
|
||||
/**
|
||||
* Test error forwarding from OAuth provider.
|
||||
*
|
||||
* When OAuth fails, the backend redirects with error info
|
||||
* in query params. handleCallback should extract and return this.
|
||||
*/
|
||||
mockLocation.hash = ''
|
||||
mockLocation.search = '?error=access_denied&message=User%20cancelled'
|
||||
|
||||
const { handleCallback } = useAuth()
|
||||
const result = await handleCallback()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('User cancelled')
|
||||
})
|
||||
|
||||
it('uses default error message when message param is missing', async () => {
|
||||
/**
|
||||
* Test fallback error message.
|
||||
*
|
||||
* If the backend only provides an error code without a message,
|
||||
* we should use a sensible default.
|
||||
*/
|
||||
mockLocation.hash = ''
|
||||
mockLocation.search = '?error=unknown_error'
|
||||
|
||||
const { handleCallback } = useAuth()
|
||||
const result = await handleCallback()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Authentication failed. Please try again.')
|
||||
})
|
||||
|
||||
it('returns error when profile fetch fails', async () => {
|
||||
/**
|
||||
* Test error handling for profile fetch failures.
|
||||
*
|
||||
* If we successfully get tokens but fail to fetch the profile,
|
||||
* handleCallback should return an error result.
|
||||
*/
|
||||
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
||||
|
||||
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { handleCallback } = useAuth()
|
||||
const result = await handleCallback()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Network error')
|
||||
})
|
||||
|
||||
it('clears tokens from URL after parsing', async () => {
|
||||
/**
|
||||
* Test security cleanup of URL.
|
||||
*
|
||||
* Tokens in the URL should be cleared after parsing to prevent
|
||||
* them from appearing in browser history or being logged.
|
||||
*/
|
||||
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
id: 'user-1',
|
||||
display_name: 'Test User',
|
||||
avatar_url: null,
|
||||
has_starter_deck: true,
|
||||
})
|
||||
|
||||
const { handleCallback } = useAuth()
|
||||
await handleCallback()
|
||||
|
||||
expect(window.history.replaceState).toHaveBeenCalledWith(
|
||||
null,
|
||||
'',
|
||||
'/auth/callback'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('calls auth store logout with server revocation', async () => {
|
||||
/**
|
||||
* Test logout with server-side token revocation.
|
||||
*
|
||||
* When logging out, we should revoke the refresh token on the
|
||||
* server to prevent it from being used if somehow compromised.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
const logoutSpy = vi.spyOn(authStore, 'logout')
|
||||
|
||||
const { logout } = useAuth()
|
||||
await logout()
|
||||
|
||||
expect(logoutSpy).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('clears auth state after logout', async () => {
|
||||
/**
|
||||
* Test state clearing after logout.
|
||||
*
|
||||
* All authentication state should be cleared so the user
|
||||
* is properly logged out and cannot access protected resources.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
authStore.setUser({
|
||||
id: 'user-1',
|
||||
displayName: 'Test User',
|
||||
avatarUrl: null,
|
||||
hasStarterDeck: true,
|
||||
})
|
||||
|
||||
const { logout, isAuthenticated, user } = useAuth()
|
||||
await logout(false) // Don't redirect in test
|
||||
|
||||
expect(isAuthenticated.value).toBe(false)
|
||||
expect(user.value).toBeNull()
|
||||
})
|
||||
|
||||
it('redirects to login by default', async () => {
|
||||
/**
|
||||
* Test default redirect behavior after logout.
|
||||
*
|
||||
* After logging out, users should be redirected to the login page
|
||||
* by default so they can log in again if desired.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
const { logout } = useAuth()
|
||||
await logout()
|
||||
|
||||
expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Login' })
|
||||
})
|
||||
|
||||
it('skips redirect when redirectToLogin is false', async () => {
|
||||
/**
|
||||
* Test optional redirect suppression.
|
||||
*
|
||||
* Sometimes we want to log out without redirecting (e.g., when
|
||||
* the user is already on the login page or when handling errors).
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
const { logout } = useAuth()
|
||||
await logout(false)
|
||||
|
||||
expect(mockRouter.push).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('logoutAll', () => {
|
||||
it('calls logout-all endpoint', async () => {
|
||||
/**
|
||||
* Test all-device logout API call.
|
||||
*
|
||||
* logoutAll should call the special endpoint that revokes
|
||||
* all refresh tokens for the user, logging them out everywhere.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
const { logoutAll } = useAuth()
|
||||
await logoutAll(false)
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'http://localhost:8000/api/auth/logout-all',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'Authorization': 'Bearer test-token',
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('clears local state even if server call fails', async () => {
|
||||
/**
|
||||
* Test graceful handling of server errors.
|
||||
*
|
||||
* Even if the server call to revoke all tokens fails, we should
|
||||
* still clear local state so the user is logged out locally.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { logoutAll, isAuthenticated } = useAuth()
|
||||
await logoutAll(false)
|
||||
|
||||
expect(isAuthenticated.value).toBe(false)
|
||||
})
|
||||
|
||||
it('redirects to login by default', async () => {
|
||||
/**
|
||||
* Test default redirect after all-device logout.
|
||||
*
|
||||
* After logging out from all devices, users should be
|
||||
* redirected to the login page.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
const { logoutAll } = useAuth()
|
||||
await logoutAll()
|
||||
|
||||
expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Login' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('initialize', () => {
|
||||
it('sets isInitialized to true after completion', async () => {
|
||||
/**
|
||||
* Test initialization completion flag.
|
||||
*
|
||||
* After initialize() completes, isInitialized should be true
|
||||
* so navigation guards know it's safe to check auth state.
|
||||
*/
|
||||
const { initialize, isInitialized } = useAuth()
|
||||
|
||||
expect(isInitialized.value).toBe(false)
|
||||
|
||||
await initialize()
|
||||
|
||||
expect(isInitialized.value).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when authenticated with valid tokens', async () => {
|
||||
/**
|
||||
* Test initialization with existing valid session.
|
||||
*
|
||||
* If tokens are already stored and valid, initialization should
|
||||
* return true and keep the user logged in.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
authStore.setUser({
|
||||
id: 'user-1',
|
||||
displayName: 'Test User',
|
||||
avatarUrl: null,
|
||||
hasStarterDeck: true,
|
||||
})
|
||||
|
||||
const { initialize } = useAuth()
|
||||
const result = await initialize()
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when not authenticated', async () => {
|
||||
/**
|
||||
* Test initialization without existing session.
|
||||
*
|
||||
* If no tokens are stored, initialization should return false
|
||||
* indicating the user needs to log in.
|
||||
*/
|
||||
const { initialize } = useAuth()
|
||||
const result = await initialize()
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('fetches profile if tokens exist but user is null', async () => {
|
||||
/**
|
||||
* Test profile fetch during initialization.
|
||||
*
|
||||
* If we have tokens persisted but no user data (e.g., after
|
||||
* page reload), we should fetch the profile during init.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
// Note: user is NOT set
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
id: 'user-1',
|
||||
display_name: 'Test User',
|
||||
avatar_url: null,
|
||||
has_starter_deck: true,
|
||||
})
|
||||
|
||||
const { initialize } = useAuth()
|
||||
await initialize()
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/api/users/me')
|
||||
expect(authStore.user).toEqual({
|
||||
id: 'user-1',
|
||||
displayName: 'Test User',
|
||||
avatarUrl: null,
|
||||
hasStarterDeck: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('logs out if profile fetch fails during initialization', async () => {
|
||||
/**
|
||||
* Test handling of invalid tokens during initialization.
|
||||
*
|
||||
* If tokens exist but profile fetch fails (invalid tokens, etc.),
|
||||
* we should clear the invalid tokens and return false.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
vi.mocked(apiClient.get).mockRejectedValue(new Error('Unauthorized'))
|
||||
|
||||
const { initialize, isAuthenticated } = useAuth()
|
||||
const result = await initialize()
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(isAuthenticated.value).toBe(false)
|
||||
})
|
||||
|
||||
it('only runs once (returns cached result on second call)', async () => {
|
||||
/**
|
||||
* Test initialization idempotency.
|
||||
*
|
||||
* Multiple calls to initialize() should only run the
|
||||
* initialization logic once. Subsequent calls should
|
||||
* return the same result immediately.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
authStore.setUser({
|
||||
id: 'user-1',
|
||||
displayName: 'Test User',
|
||||
avatarUrl: null,
|
||||
hasStarterDeck: true,
|
||||
})
|
||||
|
||||
const { initialize, isInitialized } = useAuth()
|
||||
|
||||
await initialize()
|
||||
expect(isInitialized.value).toBe(true)
|
||||
|
||||
// Call again - should return immediately
|
||||
const result = await initialize()
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchProfile', () => {
|
||||
it('fetches and updates user profile', async () => {
|
||||
/**
|
||||
* Test manual profile refresh.
|
||||
*
|
||||
* Components may need to refresh the user profile (e.g., after
|
||||
* updating display name). fetchProfile should update the store.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
id: 'user-1',
|
||||
display_name: 'Updated Name',
|
||||
avatar_url: 'https://example.com/new-avatar.png',
|
||||
has_starter_deck: true,
|
||||
})
|
||||
|
||||
const { fetchProfile } = useAuth()
|
||||
const result = await fetchProfile()
|
||||
|
||||
expect(result.displayName).toBe('Updated Name')
|
||||
expect(authStore.user?.displayName).toBe('Updated Name')
|
||||
})
|
||||
|
||||
it('throws error when not authenticated', async () => {
|
||||
/**
|
||||
* Test unauthenticated profile fetch rejection.
|
||||
*
|
||||
* fetchProfile should throw an error if called when not
|
||||
* authenticated, rather than making a doomed API call.
|
||||
*/
|
||||
const { fetchProfile } = useAuth()
|
||||
|
||||
await expect(fetchProfile()).rejects.toThrow('Not authenticated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearError', () => {
|
||||
it('clears the error state', async () => {
|
||||
/**
|
||||
* Test error clearing.
|
||||
*
|
||||
* After displaying an error, components may want to clear it
|
||||
* (e.g., when user dismisses the error or tries again).
|
||||
*/
|
||||
// First cause an error
|
||||
mockLocation.hash = ''
|
||||
|
||||
const { handleCallback, clearError, error } = useAuth()
|
||||
await handleCallback()
|
||||
|
||||
// Error should be set
|
||||
expect(error.value).toBeDefined()
|
||||
|
||||
// Clear it
|
||||
clearError()
|
||||
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading states', () => {
|
||||
it('isLoading is true during handleCallback', async () => {
|
||||
/**
|
||||
* Test loading state during callback processing.
|
||||
*
|
||||
* Components should show loading indicators while the callback
|
||||
* is being processed (token storage, profile fetch).
|
||||
*/
|
||||
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
||||
|
||||
// Create a deferred promise to control when the API responds
|
||||
let resolveApi: (value: unknown) => void
|
||||
vi.mocked(apiClient.get).mockImplementation(
|
||||
() => new Promise((resolve) => { resolveApi = resolve })
|
||||
)
|
||||
|
||||
const { handleCallback, isLoading } = useAuth()
|
||||
|
||||
// Start callback (don't await)
|
||||
const promise = handleCallback()
|
||||
|
||||
// Should be loading
|
||||
expect(isLoading.value).toBe(true)
|
||||
|
||||
// Resolve the API call
|
||||
resolveApi!({
|
||||
id: 'user-1',
|
||||
display_name: 'Test User',
|
||||
avatar_url: null,
|
||||
has_starter_deck: true,
|
||||
})
|
||||
|
||||
await promise
|
||||
|
||||
// Should no longer be loading
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('isLoading is true during logout', async () => {
|
||||
/**
|
||||
* Test loading state during logout.
|
||||
*
|
||||
* Components should disable logout buttons and show feedback
|
||||
* while the logout operation is in progress.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
// Create a deferred promise
|
||||
let resolveFetch: () => void
|
||||
global.fetch = vi.fn().mockImplementation(
|
||||
() => new Promise((resolve) => {
|
||||
resolveFetch = () => resolve({ ok: true })
|
||||
})
|
||||
)
|
||||
|
||||
const { logout, isLoading } = useAuth()
|
||||
|
||||
// Start logout (don't await)
|
||||
const promise = logout(false)
|
||||
|
||||
// Should be loading
|
||||
expect(isLoading.value).toBe(true)
|
||||
|
||||
// Resolve the fetch
|
||||
resolveFetch!()
|
||||
|
||||
await promise
|
||||
|
||||
// Should no longer be loading
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('isLoading is true during initialize', async () => {
|
||||
/**
|
||||
* Test loading state during initialization.
|
||||
*
|
||||
* The app should show a loading screen while auth state
|
||||
* is being initialized on startup.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
// Note: NOT setting user, so init will need to fetch profile
|
||||
|
||||
// Create a deferred promise that we control
|
||||
let resolveApi: (value: unknown) => void = () => {}
|
||||
const apiPromise = new Promise((resolve) => {
|
||||
resolveApi = resolve
|
||||
})
|
||||
vi.mocked(apiClient.get).mockReturnValue(apiPromise as Promise<unknown>)
|
||||
|
||||
const { initialize, isLoading } = useAuth()
|
||||
|
||||
// Start init (don't await)
|
||||
const promise = initialize()
|
||||
|
||||
// Wait a tick for async operations to start
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
// Should be loading (needs to fetch profile)
|
||||
expect(isLoading.value).toBe(true)
|
||||
|
||||
// Resolve the API call
|
||||
resolveApi({
|
||||
id: 'user-1',
|
||||
display_name: 'Test User',
|
||||
avatar_url: null,
|
||||
has_starter_deck: true,
|
||||
})
|
||||
|
||||
await promise
|
||||
|
||||
// Should no longer be loading
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed properties', () => {
|
||||
it('isAuthenticated reflects auth store state', () => {
|
||||
/**
|
||||
* Test isAuthenticated reactivity.
|
||||
*
|
||||
* The isAuthenticated computed should automatically update
|
||||
* when the auth store's authentication state changes.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
expect(isAuthenticated.value).toBe(false)
|
||||
|
||||
authStore.setTokens({
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
|
||||
expect(isAuthenticated.value).toBe(true)
|
||||
})
|
||||
|
||||
it('user reflects auth store state', () => {
|
||||
/**
|
||||
* Test user computed reactivity.
|
||||
*
|
||||
* The user computed should automatically update when the
|
||||
* auth store's user data changes.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
const { user } = useAuth()
|
||||
|
||||
expect(user.value).toBeNull()
|
||||
|
||||
authStore.setUser({
|
||||
id: 'user-1',
|
||||
displayName: 'Test User',
|
||||
avatarUrl: null,
|
||||
hasStarterDeck: true,
|
||||
})
|
||||
|
||||
expect(user.value?.displayName).toBe('Test User')
|
||||
})
|
||||
|
||||
it('error combines local and store errors', () => {
|
||||
/**
|
||||
* Test error state composition.
|
||||
*
|
||||
* The error computed should show local errors (from composable
|
||||
* operations) or store errors (from token refresh, etc.).
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
const { error } = useAuth()
|
||||
|
||||
expect(error.value).toBeNull()
|
||||
|
||||
// Store error should be reflected
|
||||
authStore.error = 'Store error'
|
||||
expect(error.value).toBe('Store error')
|
||||
})
|
||||
})
|
||||
})
|
||||
372
frontend/src/composables/useAuth.ts
Normal file
372
frontend/src/composables/useAuth.ts
Normal file
@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Authentication composable for OAuth-based login flow.
|
||||
*
|
||||
* Provides a higher-level API on top of the auth store, with:
|
||||
* - Loading and error state management
|
||||
* - OAuth flow helpers (initiate, callback)
|
||||
* - Logout with navigation
|
||||
* - App initialization with token validation
|
||||
*
|
||||
* Use this composable in components instead of accessing the auth store directly
|
||||
* for operations that involve async operations or navigation.
|
||||
*/
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { User } from '@/stores/auth'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { config } from '@/config'
|
||||
|
||||
/** OAuth provider types supported by the backend */
|
||||
export type OAuthProvider = 'google' | 'discord'
|
||||
|
||||
/** User profile response from the backend API */
|
||||
interface UserProfileResponse {
|
||||
id: string
|
||||
display_name: string
|
||||
avatar_url: string | null
|
||||
has_starter_deck: boolean
|
||||
}
|
||||
|
||||
/** Parsed tokens from OAuth callback URL */
|
||||
interface ParsedTokens {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresIn: number
|
||||
}
|
||||
|
||||
/** Result of handleCallback operation */
|
||||
export interface CallbackResult {
|
||||
success: boolean
|
||||
user?: User
|
||||
error?: string
|
||||
needsStarter?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tokens from URL hash fragment.
|
||||
*
|
||||
* The backend redirects with tokens in the fragment (after #) for security,
|
||||
* since fragments are not sent to servers in HTTP requests.
|
||||
*/
|
||||
function parseTokensFromHash(): ParsedTokens | null {
|
||||
const hash = window.location.hash.substring(1)
|
||||
if (!hash) return null
|
||||
|
||||
const params = new URLSearchParams(hash)
|
||||
const accessToken = params.get('access_token')
|
||||
const refreshToken = params.get('refresh_token')
|
||||
const expiresIn = params.get('expires_in')
|
||||
|
||||
if (!accessToken || !refreshToken || !expiresIn) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: parseInt(expiresIn, 10),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse error from URL query params.
|
||||
*
|
||||
* When OAuth fails, the backend redirects with error info in query params.
|
||||
*/
|
||||
function parseErrorFromQuery(): { error: string; message: string } | null {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const error = params.get('error')
|
||||
const message = params.get('message')
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
error,
|
||||
message: message || 'Authentication failed. Please try again.',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform API user profile to auth store User type.
|
||||
*/
|
||||
function transformUserProfile(response: UserProfileResponse): User {
|
||||
return {
|
||||
id: response.id,
|
||||
displayName: response.display_name,
|
||||
avatarUrl: response.avatar_url,
|
||||
hasStarterDeck: response.has_starter_deck,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication composable.
|
||||
*
|
||||
* Wraps the auth store with loading/error state management and provides
|
||||
* higher-level methods for OAuth flows.
|
||||
*
|
||||
* @example
|
||||
* ```vue
|
||||
* <script setup lang="ts">
|
||||
* import { useAuth } from '@/composables/useAuth'
|
||||
*
|
||||
* const { isAuthenticated, isLoading, initiateOAuth, logout } = useAuth()
|
||||
*
|
||||
* function handleGoogleLogin() {
|
||||
* initiateOAuth('google')
|
||||
* }
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
export function useAuth() {
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Local state for this composable instance
|
||||
const isInitializing = ref(false)
|
||||
const isInitialized = ref(false)
|
||||
const isProcessingCallback = ref(false)
|
||||
const isLoggingOut = ref(false)
|
||||
const localError = ref<string | null>(null)
|
||||
|
||||
// Computed properties from store
|
||||
const isAuthenticated = computed(() => authStore.isAuthenticated)
|
||||
const user = computed(() => authStore.user)
|
||||
const isLoading = computed(
|
||||
() => authStore.isLoading || isInitializing.value || isProcessingCallback.value || isLoggingOut.value
|
||||
)
|
||||
const error = computed(() => localError.value || authStore.error)
|
||||
|
||||
/**
|
||||
* Initiate OAuth login flow.
|
||||
*
|
||||
* Redirects the user to the backend OAuth endpoint, which then redirects
|
||||
* to the OAuth provider (Google/Discord). After authentication, the user
|
||||
* is redirected back to /auth/callback with tokens.
|
||||
*
|
||||
* @param provider - OAuth provider to use ('google' or 'discord')
|
||||
*/
|
||||
function initiateOAuth(provider: OAuthProvider): void {
|
||||
localError.value = null
|
||||
const url = authStore.getOAuthUrl(provider)
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback.
|
||||
*
|
||||
* Called from AuthCallbackPage after being redirected from OAuth provider.
|
||||
* Extracts tokens from URL, fetches user profile, and determines redirect.
|
||||
*
|
||||
* @returns Result indicating success/failure and redirect info
|
||||
*/
|
||||
async function handleCallback(): Promise<CallbackResult> {
|
||||
isProcessingCallback.value = true
|
||||
localError.value = null
|
||||
|
||||
try {
|
||||
// Check for OAuth errors first
|
||||
const errorInfo = parseErrorFromQuery()
|
||||
if (errorInfo) {
|
||||
return {
|
||||
success: false,
|
||||
error: errorInfo.message,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse tokens from URL fragment
|
||||
const tokens = parseTokensFromHash()
|
||||
if (!tokens) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid authentication response. Missing tokens.',
|
||||
}
|
||||
}
|
||||
|
||||
// Store tokens in auth store
|
||||
authStore.setTokens({
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
expiresAt: Date.now() + tokens.expiresIn * 1000,
|
||||
})
|
||||
|
||||
// Clear tokens from URL for security
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
|
||||
// Fetch user profile
|
||||
const profileResponse = await apiClient.get<UserProfileResponse>('/api/users/me')
|
||||
const user = transformUserProfile(profileResponse)
|
||||
authStore.setUser(user)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
needsStarter: !user.hasStarterDeck,
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : 'Failed to complete authentication.'
|
||||
localError.value = errorMessage
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
}
|
||||
} finally {
|
||||
isProcessingCallback.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out the current user.
|
||||
*
|
||||
* Revokes the refresh token on the server, clears local state,
|
||||
* and redirects to the login page.
|
||||
*
|
||||
* @param redirectToLogin - Whether to redirect to login page (default: true)
|
||||
*/
|
||||
async function logout(redirectToLogin = true): Promise<void> {
|
||||
isLoggingOut.value = true
|
||||
localError.value = null
|
||||
|
||||
try {
|
||||
await authStore.logout(true)
|
||||
|
||||
if (redirectToLogin) {
|
||||
router.push({ name: 'Login' })
|
||||
}
|
||||
} finally {
|
||||
isLoggingOut.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out from all devices.
|
||||
*
|
||||
* Revokes all refresh tokens for the user, effectively logging them out
|
||||
* from all sessions. This requires an additional API call.
|
||||
*
|
||||
* @param redirectToLogin - Whether to redirect to login page (default: true)
|
||||
*/
|
||||
async function logoutAll(redirectToLogin = true): Promise<void> {
|
||||
isLoggingOut.value = true
|
||||
localError.value = null
|
||||
|
||||
try {
|
||||
// Call the logout-all endpoint to revoke all tokens
|
||||
const token = await authStore.getValidToken()
|
||||
if (token) {
|
||||
await fetch(`${config.apiBaseUrl}/api/auth/logout-all`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Clear local state without making another server call
|
||||
await authStore.logout(false)
|
||||
|
||||
if (redirectToLogin) {
|
||||
router.push({ name: 'Login' })
|
||||
}
|
||||
} catch {
|
||||
// Even if server call fails, clear local state
|
||||
await authStore.logout(false)
|
||||
|
||||
if (redirectToLogin) {
|
||||
router.push({ name: 'Login' })
|
||||
}
|
||||
} finally {
|
||||
isLoggingOut.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize authentication state.
|
||||
*
|
||||
* Should be called once on app startup. Validates existing tokens
|
||||
* and fetches user profile if authenticated.
|
||||
*
|
||||
* @returns Whether initialization succeeded with a valid session
|
||||
*/
|
||||
async function initialize(): Promise<boolean> {
|
||||
if (isInitialized.value) {
|
||||
return authStore.isAuthenticated
|
||||
}
|
||||
|
||||
isInitializing.value = true
|
||||
localError.value = null
|
||||
|
||||
try {
|
||||
// First, let the store validate/refresh tokens
|
||||
await authStore.init()
|
||||
|
||||
// If we have tokens but no user data, fetch the profile
|
||||
if (authStore.isAuthenticated && !authStore.user) {
|
||||
try {
|
||||
const profileResponse = await apiClient.get<UserProfileResponse>('/api/users/me')
|
||||
const user = transformUserProfile(profileResponse)
|
||||
authStore.setUser(user)
|
||||
} catch {
|
||||
// Failed to fetch profile - tokens may be invalid
|
||||
await authStore.logout(false)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return authStore.isAuthenticated
|
||||
} finally {
|
||||
isInitializing.value = false
|
||||
isInitialized.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current user's profile from the API.
|
||||
*
|
||||
* Updates the user in the auth store with fresh data.
|
||||
*
|
||||
* @returns The updated user profile
|
||||
* @throws Error if not authenticated or fetch fails
|
||||
*/
|
||||
async function fetchProfile(): Promise<User> {
|
||||
if (!authStore.isAuthenticated) {
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
const profileResponse = await apiClient.get<UserProfileResponse>('/api/users/me')
|
||||
const user = transformUserProfile(profileResponse)
|
||||
authStore.setUser(user)
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear any error state.
|
||||
*/
|
||||
function clearError(): void {
|
||||
localError.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State (readonly to prevent external mutation)
|
||||
isAuthenticated,
|
||||
isInitialized: readonly(isInitialized),
|
||||
isLoading,
|
||||
error,
|
||||
user,
|
||||
|
||||
// Actions
|
||||
initiateOAuth,
|
||||
handleCallback,
|
||||
logout,
|
||||
logoutAll,
|
||||
initialize,
|
||||
fetchProfile,
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
|
||||
export type UseAuth = ReturnType<typeof useAuth>
|
||||
630
frontend/src/composables/useStarter.spec.ts
Normal file
630
frontend/src/composables/useStarter.spec.ts
Normal file
@ -0,0 +1,630 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// Create mock router instance
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
currentRoute: { value: { name: 'StarterSelection', path: '/starter' } },
|
||||
}
|
||||
|
||||
// Mock vue-router (hoisted)
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => mockRouter,
|
||||
useRoute: () => ({ query: {} }),
|
||||
}))
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
post: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Store isDev state in a way that can be modified between tests
|
||||
let isDevMode = true
|
||||
|
||||
// Mock config with a getter for isDev so we can control it
|
||||
vi.mock('@/config', () => ({
|
||||
config: {
|
||||
apiBaseUrl: 'http://localhost:8000',
|
||||
wsUrl: 'http://localhost:8000',
|
||||
oauthRedirectUri: 'http://localhost:5173/auth/callback',
|
||||
get isDev() { return isDevMode },
|
||||
isProd: false,
|
||||
},
|
||||
}))
|
||||
|
||||
import { apiClient } from '@/api/client'
|
||||
import { ApiError } from '@/api/types'
|
||||
import { useStarter, STARTER_DECKS, type StarterType } from './useStarter'
|
||||
|
||||
describe('useStarter', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// Reset mock router
|
||||
mockRouter.push.mockReset()
|
||||
|
||||
// Reset API mock
|
||||
vi.mocked(apiClient.post).mockReset()
|
||||
|
||||
// Set up authenticated user in auth store
|
||||
const authStore = useAuthStore()
|
||||
authStore.setTokens({
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
})
|
||||
authStore.setUser({
|
||||
id: 'user-1',
|
||||
displayName: 'Test User',
|
||||
avatarUrl: null,
|
||||
hasStarterDeck: false,
|
||||
})
|
||||
|
||||
// Set DEV mode for mock fallback tests
|
||||
isDevMode = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('STARTER_DECKS constant', () => {
|
||||
it('contains exactly 5 starter deck options', () => {
|
||||
/**
|
||||
* Test that all 5 starter types are defined.
|
||||
*
|
||||
* The game offers exactly 5 starter deck types corresponding
|
||||
* to the main Pokemon types available at launch.
|
||||
*/
|
||||
expect(STARTER_DECKS).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('includes grass, fire, water, psychic, and lightning types', () => {
|
||||
/**
|
||||
* Test that all expected starter types are present.
|
||||
*
|
||||
* These specific types were chosen to provide variety in
|
||||
* playstyle: healing (grass), aggro (fire), control (water),
|
||||
* status (psychic), and speed (lightning).
|
||||
*/
|
||||
const types = STARTER_DECKS.map(d => d.type)
|
||||
expect(types).toContain('grass')
|
||||
expect(types).toContain('fire')
|
||||
expect(types).toContain('water')
|
||||
expect(types).toContain('psychic')
|
||||
expect(types).toContain('lightning')
|
||||
})
|
||||
|
||||
it('each deck has required properties', () => {
|
||||
/**
|
||||
* Test starter deck data structure.
|
||||
*
|
||||
* Each starter deck must have a type, name, description,
|
||||
* and card count for display in the selection UI.
|
||||
*/
|
||||
for (const deck of STARTER_DECKS) {
|
||||
expect(deck.type).toBeDefined()
|
||||
expect(deck.name).toBeDefined()
|
||||
expect(deck.description).toBeDefined()
|
||||
expect(deck.cardCount).toBe(40)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('starts with isLoading as false', () => {
|
||||
/**
|
||||
* Test initial loading state.
|
||||
*
|
||||
* The composable should not be in a loading state until
|
||||
* a selection operation is initiated.
|
||||
*/
|
||||
const { isLoading } = useStarter()
|
||||
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('starts with no error', () => {
|
||||
/**
|
||||
* Test initial error state.
|
||||
*
|
||||
* There should be no error until an operation fails.
|
||||
*/
|
||||
const { error } = useStarter()
|
||||
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('starts with no selected type', () => {
|
||||
/**
|
||||
* Test initial selection state.
|
||||
*
|
||||
* No deck type should be selected until the user picks one.
|
||||
*/
|
||||
const { selectedType } = useStarter()
|
||||
|
||||
expect(selectedType.value).toBeNull()
|
||||
})
|
||||
|
||||
it('provides starterDecks constant', () => {
|
||||
/**
|
||||
* Test that starter decks are exposed.
|
||||
*
|
||||
* Components need access to the deck options for rendering.
|
||||
*/
|
||||
const { starterDecks } = useStarter()
|
||||
|
||||
expect(starterDecks).toBe(STARTER_DECKS)
|
||||
})
|
||||
|
||||
it('hasStarterDeck reflects auth store user state', () => {
|
||||
/**
|
||||
* Test hasStarterDeck computed from auth store.
|
||||
*
|
||||
* The composable should expose the user's starter deck status
|
||||
* from the auth store for conditional rendering.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
const { hasStarterDeck } = useStarter()
|
||||
|
||||
expect(hasStarterDeck.value).toBe(false)
|
||||
|
||||
authStore.setUser({
|
||||
id: 'user-1',
|
||||
displayName: 'Test User',
|
||||
avatarUrl: null,
|
||||
hasStarterDeck: true,
|
||||
})
|
||||
|
||||
expect(hasStarterDeck.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectStarter - success', () => {
|
||||
it('calls API with correct starter type', async () => {
|
||||
/**
|
||||
* Test API call payload.
|
||||
*
|
||||
* The API must receive the correct starter_type to assign
|
||||
* the right deck to the user.
|
||||
*/
|
||||
vi.mocked(apiClient.post).mockResolvedValue({
|
||||
id: 'deck-1',
|
||||
name: 'Forest Guardians',
|
||||
cards: [],
|
||||
isValid: true,
|
||||
cardCount: 40,
|
||||
})
|
||||
|
||||
const { selectStarter } = useStarter()
|
||||
await selectStarter('grass')
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/users/me/starter-deck', {
|
||||
starter_type: 'grass',
|
||||
})
|
||||
})
|
||||
|
||||
it('updates auth store hasStarterDeck on success', async () => {
|
||||
/**
|
||||
* Test auth store update on success.
|
||||
*
|
||||
* After selecting a starter deck, the auth store must be updated
|
||||
* so navigation guards and other components know the user has a deck.
|
||||
*/
|
||||
vi.mocked(apiClient.post).mockResolvedValue({
|
||||
id: 'deck-1',
|
||||
name: 'Flame Warriors',
|
||||
cards: [],
|
||||
isValid: true,
|
||||
cardCount: 40,
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { selectStarter } = useStarter()
|
||||
|
||||
expect(authStore.user?.hasStarterDeck).toBe(false)
|
||||
|
||||
await selectStarter('fire')
|
||||
|
||||
expect(authStore.user?.hasStarterDeck).toBe(true)
|
||||
})
|
||||
|
||||
it('redirects to dashboard on success', async () => {
|
||||
/**
|
||||
* Test navigation after successful selection.
|
||||
*
|
||||
* After selecting a starter deck, the user should be taken
|
||||
* to the main dashboard to begin playing.
|
||||
*/
|
||||
vi.mocked(apiClient.post).mockResolvedValue({
|
||||
id: 'deck-1',
|
||||
name: 'Tidal Force',
|
||||
cards: [],
|
||||
isValid: true,
|
||||
cardCount: 40,
|
||||
})
|
||||
|
||||
const { selectStarter } = useStarter()
|
||||
await selectStarter('water')
|
||||
|
||||
expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Dashboard' })
|
||||
})
|
||||
|
||||
it('returns success result with deck', async () => {
|
||||
/**
|
||||
* Test success result structure.
|
||||
*
|
||||
* The result should include the deck data for potential
|
||||
* display or further processing.
|
||||
*/
|
||||
const mockDeck = {
|
||||
id: 'deck-1',
|
||||
name: 'Mind Masters',
|
||||
cards: [],
|
||||
isValid: true,
|
||||
cardCount: 40,
|
||||
}
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockDeck)
|
||||
|
||||
const { selectStarter } = useStarter()
|
||||
const result = await selectStarter('psychic')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.deck).toEqual(mockDeck)
|
||||
expect(result.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sets selectedType during operation', async () => {
|
||||
/**
|
||||
* Test selected type tracking.
|
||||
*
|
||||
* The selectedType should be set during the operation so
|
||||
* the UI can show which deck is being selected (e.g., loading indicator).
|
||||
*/
|
||||
vi.mocked(apiClient.post).mockResolvedValue({
|
||||
id: 'deck-1',
|
||||
name: 'Storm Riders',
|
||||
cards: [],
|
||||
isValid: true,
|
||||
cardCount: 40,
|
||||
})
|
||||
|
||||
const { selectStarter, selectedType } = useStarter()
|
||||
await selectStarter('lightning')
|
||||
|
||||
// After completion, selectedType should still reflect what was selected
|
||||
expect(selectedType.value).toBe('lightning')
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectStarter - error handling', () => {
|
||||
it('handles 409 conflict (already has starter)', async () => {
|
||||
/**
|
||||
* Test handling of "already has starter" error.
|
||||
*
|
||||
* If the user somehow tries to select a starter when they already
|
||||
* have one, the API returns 409. We should handle this gracefully
|
||||
* by updating state and redirecting anyway.
|
||||
*/
|
||||
const conflictError = new ApiError(409, 'Conflict', 'User already has starter deck', 'ALREADY_HAS_STARTER')
|
||||
vi.mocked(apiClient.post).mockRejectedValue(conflictError)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { selectStarter } = useStarter()
|
||||
const result = await selectStarter('grass')
|
||||
|
||||
// Should still count as "success" in terms of outcome
|
||||
expect(result.success).toBe(true)
|
||||
expect(authStore.user?.hasStarterDeck).toBe(true)
|
||||
expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Dashboard' })
|
||||
})
|
||||
|
||||
it('sets error state on API failure', async () => {
|
||||
/**
|
||||
* Test error state on API failure.
|
||||
*
|
||||
* When the API returns an error (other than 409), we should
|
||||
* set the error state so the UI can display it.
|
||||
*/
|
||||
const serverError = new ApiError(500, 'Internal Server Error', 'Database error')
|
||||
vi.mocked(apiClient.post).mockRejectedValue(serverError)
|
||||
|
||||
// Disable dev mode for this test
|
||||
isDevMode = false
|
||||
|
||||
const { selectStarter, error } = useStarter()
|
||||
await selectStarter('fire')
|
||||
|
||||
expect(error.value).toBe('Database error')
|
||||
})
|
||||
|
||||
it('returns error result on failure', async () => {
|
||||
/**
|
||||
* Test error result structure.
|
||||
*
|
||||
* The result should indicate failure and include the error
|
||||
* message for display.
|
||||
*/
|
||||
const serverError = new ApiError(422, 'Unprocessable Entity', 'Invalid starter type')
|
||||
vi.mocked(apiClient.post).mockRejectedValue(serverError)
|
||||
|
||||
// Disable dev mode for this test
|
||||
isDevMode = false
|
||||
|
||||
const { selectStarter } = useStarter()
|
||||
const result = await selectStarter('water')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Invalid starter type')
|
||||
expect(result.deck).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not redirect on failure', async () => {
|
||||
/**
|
||||
* Test no navigation on failure.
|
||||
*
|
||||
* When selection fails, the user should stay on the selection
|
||||
* page so they can try again or see the error.
|
||||
*/
|
||||
const serverError = new ApiError(500, 'Internal Server Error', 'Server down')
|
||||
vi.mocked(apiClient.post).mockRejectedValue(serverError)
|
||||
|
||||
// Disable dev mode for this test
|
||||
isDevMode = false
|
||||
|
||||
const { selectStarter } = useStarter()
|
||||
await selectStarter('psychic')
|
||||
|
||||
expect(mockRouter.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles unknown errors', async () => {
|
||||
/**
|
||||
* Test handling of non-ApiError exceptions.
|
||||
*
|
||||
* Network errors and other unexpected errors should be
|
||||
* handled gracefully with a generic message.
|
||||
*/
|
||||
vi.mocked(apiClient.post).mockRejectedValue(new Error('Network failed'))
|
||||
|
||||
const { selectStarter, error } = useStarter()
|
||||
const result = await selectStarter('lightning')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(error.value).toBe('Network failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectStarter - dev mock fallback', () => {
|
||||
it('uses mock when API returns 404 in dev mode', async () => {
|
||||
/**
|
||||
* Test dev mock fallback on 404.
|
||||
*
|
||||
* When the backend endpoint doesn't exist yet (404), we should
|
||||
* fall back to a mock response in development mode to allow
|
||||
* testing the full UI flow.
|
||||
*/
|
||||
const notFoundError = new ApiError(404, 'Not Found', 'Endpoint not found')
|
||||
vi.mocked(apiClient.post).mockRejectedValue(notFoundError)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { selectStarter } = useStarter()
|
||||
const result = await selectStarter('grass')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.wasMocked).toBe(true)
|
||||
expect(result.deck?.name).toBe('Forest Guardians')
|
||||
expect(authStore.user?.hasStarterDeck).toBe(true)
|
||||
expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Dashboard' })
|
||||
})
|
||||
|
||||
it('uses mock when API returns 500 in dev mode', async () => {
|
||||
/**
|
||||
* Test dev mock fallback on server error.
|
||||
*
|
||||
* Server errors in dev mode should also trigger the mock
|
||||
* since the backend may be unstable during development.
|
||||
*/
|
||||
const serverError = new ApiError(500, 'Internal Server Error', 'Database not configured')
|
||||
vi.mocked(apiClient.post).mockRejectedValue(serverError)
|
||||
|
||||
const { selectStarter } = useStarter()
|
||||
const result = await selectStarter('fire')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.wasMocked).toBe(true)
|
||||
expect(result.deck?.name).toBe('Flame Warriors')
|
||||
})
|
||||
|
||||
it('does not use mock in production mode', async () => {
|
||||
/**
|
||||
* Test no mock in production.
|
||||
*
|
||||
* The mock fallback should ONLY work in development mode.
|
||||
* Production errors should be reported to the user.
|
||||
*/
|
||||
isDevMode = false
|
||||
|
||||
const serverError = new ApiError(500, 'Internal Server Error', 'Server error')
|
||||
vi.mocked(apiClient.post).mockRejectedValue(serverError)
|
||||
|
||||
const { selectStarter, error } = useStarter()
|
||||
const result = await selectStarter('water')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.wasMocked).toBeUndefined()
|
||||
expect(error.value).toBe('Server error')
|
||||
})
|
||||
|
||||
it('creates mock deck with correct type', async () => {
|
||||
/**
|
||||
* Test mock deck generation.
|
||||
*
|
||||
* The mock deck should match the selected type so the UI
|
||||
* can display the correct deck name and theme.
|
||||
*/
|
||||
const notFoundError = new ApiError(404, 'Not Found')
|
||||
vi.mocked(apiClient.post).mockRejectedValue(notFoundError)
|
||||
|
||||
const { selectStarter } = useStarter()
|
||||
|
||||
const result = await selectStarter('lightning')
|
||||
|
||||
expect(result.deck?.name).toBe('Storm Riders')
|
||||
expect(result.deck?.id).toContain('lightning')
|
||||
})
|
||||
|
||||
it('logs warning when using mock', async () => {
|
||||
/**
|
||||
* Test console warning for mock usage.
|
||||
*
|
||||
* Developers should be warned when the mock is being used
|
||||
* so they know the real API isn't being hit.
|
||||
*/
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const notFoundError = new ApiError(404, 'Not Found')
|
||||
vi.mocked(apiClient.post).mockRejectedValue(notFoundError)
|
||||
|
||||
const { selectStarter } = useStarter()
|
||||
await selectStarter('psychic')
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('[useStarter] API not available, using mock response')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading states', () => {
|
||||
it('isLoading is true during selectStarter', async () => {
|
||||
/**
|
||||
* Test loading state during selection.
|
||||
*
|
||||
* Components should show loading indicators while the
|
||||
* selection is being processed.
|
||||
*/
|
||||
let resolveApi: (value: unknown) => void
|
||||
vi.mocked(apiClient.post).mockImplementation(
|
||||
() => new Promise((resolve) => { resolveApi = resolve })
|
||||
)
|
||||
|
||||
const { selectStarter, isLoading } = useStarter()
|
||||
|
||||
// Start selection (don't await)
|
||||
const promise = selectStarter('grass')
|
||||
|
||||
// Should be loading
|
||||
expect(isLoading.value).toBe(true)
|
||||
|
||||
// Resolve the API call
|
||||
resolveApi!({
|
||||
id: 'deck-1',
|
||||
name: 'Forest Guardians',
|
||||
cards: [],
|
||||
isValid: true,
|
||||
cardCount: 40,
|
||||
})
|
||||
|
||||
await promise
|
||||
|
||||
// Should no longer be loading
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('isLoading is false after error', async () => {
|
||||
/**
|
||||
* Test loading state cleared on error.
|
||||
*
|
||||
* Even if the operation fails, loading should be set to false
|
||||
* so the user can try again.
|
||||
*/
|
||||
// Disable dev mode so error isn't caught by mock
|
||||
isDevMode = false
|
||||
|
||||
vi.mocked(apiClient.post).mockRejectedValue(new Error('Failed'))
|
||||
|
||||
const { selectStarter, isLoading } = useStarter()
|
||||
await selectStarter('fire')
|
||||
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearError', () => {
|
||||
it('clears the error state', async () => {
|
||||
/**
|
||||
* Test error clearing.
|
||||
*
|
||||
* After displaying an error, users may want to dismiss it
|
||||
* before trying again.
|
||||
*/
|
||||
// Disable dev mode so error isn't caught by mock
|
||||
isDevMode = false
|
||||
|
||||
vi.mocked(apiClient.post).mockRejectedValue(new Error('Some error'))
|
||||
|
||||
const { selectStarter, error, clearError } = useStarter()
|
||||
await selectStarter('water')
|
||||
|
||||
expect(error.value).toBe('Some error')
|
||||
|
||||
clearError()
|
||||
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles user being null in auth store', async () => {
|
||||
/**
|
||||
* Test handling of missing user.
|
||||
*
|
||||
* If somehow the user is null (shouldn't happen on starter page),
|
||||
* the operation should still work without crashing.
|
||||
*/
|
||||
const authStore = useAuthStore()
|
||||
authStore.setUser(null as unknown as Parameters<typeof authStore.setUser>[0])
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({
|
||||
id: 'deck-1',
|
||||
name: 'Mind Masters',
|
||||
cards: [],
|
||||
isValid: true,
|
||||
cardCount: 40,
|
||||
})
|
||||
|
||||
const { selectStarter } = useStarter()
|
||||
const result = await selectStarter('psychic')
|
||||
|
||||
// Should succeed without throwing
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('all 5 starter types can be selected', async () => {
|
||||
/**
|
||||
* Test all starter types work.
|
||||
*
|
||||
* Each of the 5 starter types should be selectable without
|
||||
* any type errors or special handling issues.
|
||||
*/
|
||||
vi.mocked(apiClient.post).mockResolvedValue({
|
||||
id: 'deck-1',
|
||||
name: 'Test Deck',
|
||||
cards: [],
|
||||
isValid: true,
|
||||
cardCount: 40,
|
||||
})
|
||||
|
||||
const starterTypes: StarterType[] = ['grass', 'fire', 'water', 'psychic', 'lightning']
|
||||
|
||||
for (const type of starterTypes) {
|
||||
const { selectStarter } = useStarter()
|
||||
const result = await selectStarter(type)
|
||||
expect(result.success).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
254
frontend/src/composables/useStarter.ts
Normal file
254
frontend/src/composables/useStarter.ts
Normal file
@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Composable for starter deck selection.
|
||||
*
|
||||
* Provides state and actions for the starter deck selection flow that
|
||||
* new users must complete before accessing the main app.
|
||||
*
|
||||
* Includes a development mock fallback when the backend API is not yet
|
||||
* available, allowing the full UI flow to be tested.
|
||||
*/
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { ApiError } from '@/api/types'
|
||||
import { config } from '@/config'
|
||||
import type { CardType, Deck } from '@/types'
|
||||
|
||||
/** Starter deck type options */
|
||||
export type StarterType = Extract<CardType, 'grass' | 'fire' | 'water' | 'psychic' | 'lightning'>
|
||||
|
||||
/** Starter deck metadata for display */
|
||||
export interface StarterDeck {
|
||||
type: StarterType
|
||||
name: string
|
||||
description: string
|
||||
cardCount: number
|
||||
}
|
||||
|
||||
/** API request payload for selecting a starter deck */
|
||||
interface SelectStarterRequest {
|
||||
starter_type: StarterType
|
||||
}
|
||||
|
||||
/** Predefined starter deck options */
|
||||
export const STARTER_DECKS: StarterDeck[] = [
|
||||
{
|
||||
type: 'grass',
|
||||
name: 'Forest Guardians',
|
||||
description: 'Growth and healing focused deck',
|
||||
cardCount: 40,
|
||||
},
|
||||
{
|
||||
type: 'fire',
|
||||
name: 'Flame Warriors',
|
||||
description: 'Aggressive damage-focused deck',
|
||||
cardCount: 40,
|
||||
},
|
||||
{
|
||||
type: 'water',
|
||||
name: 'Tidal Force',
|
||||
description: 'Balanced control and damage',
|
||||
cardCount: 40,
|
||||
},
|
||||
{
|
||||
type: 'psychic',
|
||||
name: 'Mind Masters',
|
||||
description: 'Status effects and manipulation',
|
||||
cardCount: 40,
|
||||
},
|
||||
{
|
||||
type: 'lightning',
|
||||
name: 'Storm Riders',
|
||||
description: 'Fast, high-damage strikes',
|
||||
cardCount: 40,
|
||||
},
|
||||
]
|
||||
|
||||
/** Result of starter deck selection */
|
||||
export interface SelectStarterResult {
|
||||
success: boolean
|
||||
deck?: Deck
|
||||
error?: string
|
||||
wasMocked?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Development mock for starter deck selection.
|
||||
*
|
||||
* Simulates a successful API response when the backend endpoint
|
||||
* is not yet available. Only used when the real API fails.
|
||||
*/
|
||||
function createMockDeck(starterType: StarterType): Deck {
|
||||
const starter = STARTER_DECKS.find(s => s.type === starterType)
|
||||
return {
|
||||
id: `mock-starter-${starterType}`,
|
||||
name: starter?.name ?? `${starterType} Starter`,
|
||||
cards: [],
|
||||
isValid: true,
|
||||
cardCount: 40,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starter deck selection composable.
|
||||
*
|
||||
* Manages the state and API interactions for selecting a starter deck.
|
||||
* Includes loading/error states and handles the post-selection navigation.
|
||||
*
|
||||
* @example
|
||||
* ```vue
|
||||
* <script setup lang="ts">
|
||||
* import { useStarter, STARTER_DECKS } from '@/composables/useStarter'
|
||||
*
|
||||
* const { isLoading, error, selectStarter } = useStarter()
|
||||
*
|
||||
* async function handleSelect(type: StarterType) {
|
||||
* await selectStarter(type)
|
||||
* }
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
export function useStarter() {
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Local state
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const selectedType = ref<StarterType | null>(null)
|
||||
|
||||
// Computed
|
||||
const hasStarterDeck = computed(() => authStore.user?.hasStarterDeck ?? false)
|
||||
|
||||
/**
|
||||
* Select a starter deck.
|
||||
*
|
||||
* Calls the backend API to assign the selected starter deck to the user.
|
||||
* On success, updates the auth store and redirects to the dashboard.
|
||||
*
|
||||
* If the API is not available (404/500), falls back to a mock response
|
||||
* in development mode to allow testing the UI flow.
|
||||
*
|
||||
* @param starterType - The type of starter deck to select
|
||||
* @returns Result indicating success/failure
|
||||
*/
|
||||
async function selectStarter(starterType: StarterType): Promise<SelectStarterResult> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
selectedType.value = starterType
|
||||
|
||||
try {
|
||||
// Attempt real API call
|
||||
const deck = await apiClient.post<Deck>('/api/users/me/starter-deck', {
|
||||
starter_type: starterType,
|
||||
} satisfies SelectStarterRequest)
|
||||
|
||||
// Update auth store to reflect starter deck selection
|
||||
if (authStore.user) {
|
||||
authStore.setUser({
|
||||
...authStore.user,
|
||||
hasStarterDeck: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Redirect to dashboard
|
||||
router.push({ name: 'Dashboard' })
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deck,
|
||||
}
|
||||
} catch (e) {
|
||||
// Handle specific error cases
|
||||
if (e instanceof ApiError) {
|
||||
// Already has starter deck (backend returns 400 with detail message)
|
||||
const isAlreadySelected =
|
||||
e.status === 409 ||
|
||||
e.status === 400 && e.detail?.toLowerCase().includes('already selected') ||
|
||||
e.code === 'ALREADY_HAS_STARTER'
|
||||
|
||||
if (isAlreadySelected) {
|
||||
// Update local state and redirect anyway
|
||||
if (authStore.user) {
|
||||
authStore.setUser({
|
||||
...authStore.user,
|
||||
hasStarterDeck: true,
|
||||
})
|
||||
}
|
||||
router.push({ name: 'Dashboard' })
|
||||
return {
|
||||
success: true,
|
||||
error: 'You already have a starter deck.',
|
||||
}
|
||||
}
|
||||
|
||||
// API not available - use dev mock in non-production
|
||||
if ((e.isNotFound || e.isServerError) && config.isDev) {
|
||||
console.warn('[useStarter] API not available, using mock response')
|
||||
|
||||
const mockDeck = createMockDeck(starterType)
|
||||
|
||||
// Update auth store with mock data
|
||||
if (authStore.user) {
|
||||
authStore.setUser({
|
||||
...authStore.user,
|
||||
hasStarterDeck: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Redirect to dashboard
|
||||
router.push({ name: 'Dashboard' })
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deck: mockDeck,
|
||||
wasMocked: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Other API errors
|
||||
error.value = e.detail || e.message || 'Failed to select starter deck'
|
||||
return {
|
||||
success: false,
|
||||
error: error.value,
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown error
|
||||
const errorMessage = e instanceof Error ? e.message : 'An unexpected error occurred'
|
||||
error.value = errorMessage
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the error state.
|
||||
*/
|
||||
function clearError(): void {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State (readonly to prevent external mutation)
|
||||
isLoading: readonly(isLoading),
|
||||
error: readonly(error),
|
||||
selectedType: readonly(selectedType),
|
||||
hasStarterDeck,
|
||||
|
||||
// Constants
|
||||
starterDecks: STARTER_DECKS,
|
||||
|
||||
// Actions
|
||||
selectStarter,
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
|
||||
export type UseStarter = ReturnType<typeof useStarter>
|
||||
@ -3,7 +3,6 @@ import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import pinia from './stores'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
|
||||
import './assets/main.css'
|
||||
|
||||
@ -12,8 +11,7 @@ const app = createApp(App)
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
// Initialize auth state from localStorage
|
||||
const authStore = useAuthStore()
|
||||
authStore.init()
|
||||
// Note: Auth initialization is handled in App.vue to properly show loading state
|
||||
// and block navigation until auth is validated
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@ -3,40 +3,263 @@
|
||||
* Starter deck selection page.
|
||||
*
|
||||
* New users must select a starter deck before accessing the main app.
|
||||
* Displays 5 themed starter deck options to choose from.
|
||||
* Displays 5 themed starter deck options (grass, fire, water, psychic, lightning)
|
||||
* with visual previews and descriptions.
|
||||
*
|
||||
* Selection triggers an API call to assign the deck, with a mock fallback
|
||||
* in development mode if the backend isn't ready.
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useStarter, type StarterType, type StarterDeck } from '@/composables/useStarter'
|
||||
|
||||
const { isLoading, error, selectedType, starterDecks, selectStarter, clearError } = useStarter()
|
||||
|
||||
// Confirmation state
|
||||
const pendingSelection = ref<StarterType | null>(null)
|
||||
const showConfirmation = ref(false)
|
||||
|
||||
/**
|
||||
* Type-specific colors for card borders and accents.
|
||||
*/
|
||||
const typeColors: Record<StarterType, { border: string; bg: string; text: string }> = {
|
||||
grass: {
|
||||
border: 'border-green-500',
|
||||
bg: 'bg-green-500',
|
||||
text: 'text-green-500',
|
||||
},
|
||||
fire: {
|
||||
border: 'border-orange-500',
|
||||
bg: 'bg-orange-500',
|
||||
text: 'text-orange-500',
|
||||
},
|
||||
water: {
|
||||
border: 'border-blue-500',
|
||||
bg: 'bg-blue-500',
|
||||
text: 'text-blue-500',
|
||||
},
|
||||
psychic: {
|
||||
border: 'border-pink-500',
|
||||
bg: 'bg-pink-500',
|
||||
text: 'text-pink-500',
|
||||
},
|
||||
lightning: {
|
||||
border: 'border-yellow-500',
|
||||
bg: 'bg-yellow-500',
|
||||
text: 'text-yellow-500',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-specific icons (using emoji for now, can replace with custom icons).
|
||||
*/
|
||||
const typeIcons: Record<StarterType, string> = {
|
||||
grass: '\u{1F33F}', // Herb
|
||||
fire: '\u{1F525}', // Fire
|
||||
water: '\u{1F4A7}', // Droplet
|
||||
psychic: '\u{1F52E}', // Crystal ball
|
||||
lightning: '\u{26A1}', // Lightning
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle deck card click - show confirmation dialog.
|
||||
*/
|
||||
function handleDeckClick(deck: StarterDeck): void {
|
||||
if (isLoading.value) return
|
||||
pendingSelection.value = deck.type
|
||||
showConfirmation.value = true
|
||||
clearError()
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm selection and call API.
|
||||
*/
|
||||
async function confirmSelection(): Promise<void> {
|
||||
if (!pendingSelection.value) return
|
||||
showConfirmation.value = false
|
||||
await selectStarter(pendingSelection.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel selection confirmation.
|
||||
*/
|
||||
function cancelSelection(): void {
|
||||
showConfirmation.value = false
|
||||
pendingSelection.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pending deck for confirmation display.
|
||||
*/
|
||||
function getPendingDeck(): StarterDeck | undefined {
|
||||
return starterDecks.find(d => d.type === pendingSelection.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center p-4">
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-900 p-4">
|
||||
<div class="w-full max-w-4xl">
|
||||
<h1 class="mb-8 text-center text-2xl font-bold">
|
||||
Choose Your Starter Deck
|
||||
</h1>
|
||||
<!-- Header -->
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="mb-2 text-3xl font-bold text-white">
|
||||
Choose Your Starter Deck
|
||||
</h1>
|
||||
<p class="text-gray-400">
|
||||
Select a starter deck to begin your journey. You'll earn more cards
|
||||
and build custom decks as you progress.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="mb-8 text-center text-gray-600">
|
||||
Select a starter deck to begin your journey. You'll be able to earn more
|
||||
cards and build custom decks as you progress.
|
||||
</p>
|
||||
|
||||
<!-- TODO: Display 5 starter deck options with previews -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-5">
|
||||
<div
|
||||
v-for="i in 5"
|
||||
:key="i"
|
||||
class="cursor-pointer rounded-lg border-2 border-gray-200 p-4 text-center transition hover:border-blue-500"
|
||||
<!-- Error message -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="mb-6 rounded-lg bg-red-900/50 p-4 text-center text-red-200"
|
||||
role="alert"
|
||||
>
|
||||
<p>{{ error }}</p>
|
||||
<button
|
||||
class="mt-2 text-sm text-red-300 underline hover:text-red-100"
|
||||
@click="clearError"
|
||||
>
|
||||
<div class="mb-2 text-4xl">
|
||||
🃏
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Deck grid -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<button
|
||||
v-for="deck in starterDecks"
|
||||
:key="deck.type"
|
||||
class="group relative flex flex-col items-center rounded-xl border-2 bg-gray-800 p-6 transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="[
|
||||
typeColors[deck.type].border,
|
||||
isLoading && selectedType === deck.type
|
||||
? 'ring-2 ring-white'
|
||||
: 'hover:shadow-lg hover:shadow-current/20',
|
||||
]"
|
||||
:disabled="isLoading"
|
||||
@click="handleDeckClick(deck)"
|
||||
>
|
||||
<!-- Loading spinner overlay -->
|
||||
<div
|
||||
v-if="isLoading && selectedType === deck.type"
|
||||
class="absolute inset-0 flex items-center justify-center rounded-xl bg-gray-900/70"
|
||||
>
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-white border-t-transparent" />
|
||||
</div>
|
||||
<div class="font-medium">
|
||||
Starter Deck {{ i }}
|
||||
|
||||
<!-- Type icon -->
|
||||
<div
|
||||
class="mb-3 flex h-16 w-16 items-center justify-center rounded-full text-4xl"
|
||||
:class="typeColors[deck.type].bg"
|
||||
>
|
||||
{{ typeIcons[deck.type] }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
Coming soon
|
||||
|
||||
<!-- Deck name -->
|
||||
<h2 class="mb-1 text-lg font-semibold text-white">
|
||||
{{ deck.name }}
|
||||
</h2>
|
||||
|
||||
<!-- Type badge -->
|
||||
<span
|
||||
class="mb-2 rounded-full px-2 py-0.5 text-xs font-medium capitalize"
|
||||
:class="[typeColors[deck.type].bg, 'text-white']"
|
||||
>
|
||||
{{ deck.type }}
|
||||
</span>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-center text-sm text-gray-400">
|
||||
{{ deck.description }}
|
||||
</p>
|
||||
|
||||
<!-- Card count -->
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
{{ deck.cardCount }} cards
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Help text -->
|
||||
<p class="mt-8 text-center text-sm text-gray-500">
|
||||
This choice is permanent for your account, but you'll be able to build
|
||||
many more decks with cards you earn!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showConfirmation"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
|
||||
@click.self="cancelSelection"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md rounded-xl border-2 bg-gray-800 p-6 shadow-2xl"
|
||||
:class="pendingSelection ? typeColors[pendingSelection].border : ''"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
>
|
||||
<h2
|
||||
id="confirm-title"
|
||||
class="mb-4 text-xl font-bold text-white"
|
||||
>
|
||||
Confirm Selection
|
||||
</h2>
|
||||
|
||||
<div
|
||||
v-if="getPendingDeck()"
|
||||
class="mb-6"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full text-2xl"
|
||||
:class="pendingSelection ? typeColors[pendingSelection].bg : ''"
|
||||
>
|
||||
{{ pendingSelection ? typeIcons[pendingSelection] : '' }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-white">
|
||||
{{ getPendingDeck()?.name }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-400">
|
||||
{{ getPendingDeck()?.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mb-6 text-gray-300">
|
||||
Are you sure you want to choose the
|
||||
<strong
|
||||
class="capitalize"
|
||||
:class="pendingSelection ? typeColors[pendingSelection].text : ''"
|
||||
>{{ pendingSelection }}</strong>
|
||||
starter deck? This choice cannot be changed.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="flex-1 rounded-lg bg-gray-700 px-4 py-2 font-medium text-white transition hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
@click="cancelSelection"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 rounded-lg px-4 py-2 font-medium text-white transition focus:outline-none focus:ring-2"
|
||||
:class="[
|
||||
pendingSelection ? typeColors[pendingSelection].bg : 'bg-blue-500',
|
||||
'hover:opacity-90',
|
||||
]"
|
||||
@click="confirmSelection"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -3,6 +3,10 @@
|
||||
*
|
||||
* These guards control access to routes based on authentication state
|
||||
* and user profile status (e.g., starter deck selection).
|
||||
*
|
||||
* Important: These guards work in conjunction with App.vue's auth initialization.
|
||||
* App.vue blocks rendering until auth is initialized, so by the time these guards
|
||||
* run, the auth state is already validated.
|
||||
*/
|
||||
import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
@ -13,6 +17,9 @@ import { useAuthStore } from '@/stores/auth'
|
||||
*
|
||||
* Redirects unauthenticated users to /login with a redirect query param
|
||||
* so they can be sent back after logging in.
|
||||
*
|
||||
* Note: Auth initialization happens in App.vue before navigation guards run,
|
||||
* so isAuthenticated reflects the true auth state (tokens validated/refreshed).
|
||||
*/
|
||||
export function requireAuth(
|
||||
to: RouteLocationNormalized,
|
||||
@ -63,7 +70,7 @@ export function requireGuest(
|
||||
*/
|
||||
export function requireStarter(
|
||||
to: RouteLocationNormalized,
|
||||
_from: RouteLocationNormalized,
|
||||
from: RouteLocationNormalized,
|
||||
next: NavigationGuardNext
|
||||
): void {
|
||||
const auth = useAuthStore()
|
||||
@ -74,6 +81,13 @@ export function requireStarter(
|
||||
return
|
||||
}
|
||||
|
||||
// Allow navigation FROM starter page - user just completed selection
|
||||
// The store update may not have propagated yet, but the action succeeded
|
||||
if (from.path === '/starter') {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has selected starter deck
|
||||
// This will be populated after fetching user profile
|
||||
if (auth.user && !auth.user.hasStarterDeck) {
|
||||
|
||||
@ -169,12 +169,13 @@ describe('useAuthStore', () => {
|
||||
expect(store.isTokenExpired).toBe(true)
|
||||
})
|
||||
|
||||
it('getOAuthUrl returns correct provider URL', () => {
|
||||
it('getOAuthUrl returns correct provider URL with redirect_uri', () => {
|
||||
/**
|
||||
* Test that OAuth URLs are correctly generated.
|
||||
*
|
||||
* The login page uses these URLs to redirect users to
|
||||
* OAuth providers for authentication.
|
||||
* OAuth providers for authentication. The redirect_uri
|
||||
* tells the backend where to send the user after OAuth.
|
||||
*/
|
||||
const store = useAuthStore()
|
||||
|
||||
@ -182,6 +183,8 @@ describe('useAuthStore', () => {
|
||||
const discordUrl = store.getOAuthUrl('discord')
|
||||
|
||||
expect(googleUrl).toContain('/api/auth/google')
|
||||
expect(googleUrl).toContain('redirect_uri=')
|
||||
expect(discordUrl).toContain('/api/auth/discord')
|
||||
expect(discordUrl).toContain('redirect_uri=')
|
||||
})
|
||||
})
|
||||
|
||||
@ -166,9 +166,13 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
/**
|
||||
* Get OAuth login URL for a provider.
|
||||
*
|
||||
* Includes the redirect_uri so the backend knows where to send
|
||||
* the user after OAuth completes.
|
||||
*/
|
||||
function getOAuthUrl(provider: 'google' | 'discord'): string {
|
||||
return `${config.apiBaseUrl}/api/auth/${provider}`
|
||||
const redirectUri = encodeURIComponent(config.oauthRedirectUri)
|
||||
return `${config.apiBaseUrl}/api/auth/${provider}?redirect_uri=${redirectUri}`
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user