fix(share): use getShareUrl for correct production URLs
The share link API was using request.nextUrl.origin which returns localhost when behind a reverse proxy. Now uses getShareUrl helper which properly reads NEXT_PUBLIC_APP_URL on server-side. Also adds comprehensive unit tests for: - session-share.ts utilities (12 tests) - autoPauseCalculator.ts functions (20 tests) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
revokeSessionShare,
|
||||
type ShareDuration,
|
||||
} from '@/lib/session-share'
|
||||
import { getShareUrl } from '@/lib/share/urls'
|
||||
import { getDbUserId } from '@/lib/viewer'
|
||||
|
||||
interface RouteParams {
|
||||
@@ -52,9 +53,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
// Create the share
|
||||
const share = await createSessionShare(sessionId, session.playerId, userId, expiresIn)
|
||||
|
||||
// Build the full URL
|
||||
const baseUrl = request.nextUrl.origin
|
||||
const url = `${baseUrl}/observe/${share.id}`
|
||||
// Build the full URL using the share URL helper (handles env vars correctly)
|
||||
const url = getShareUrl('observe', share.id)
|
||||
|
||||
return NextResponse.json({
|
||||
token: share.id,
|
||||
|
||||
286
apps/web/src/components/practice/autoPauseCalculator.test.ts
Normal file
286
apps/web/src/components/practice/autoPauseCalculator.test.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Tests for auto-pause threshold calculation utilities
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SlotResult } from '@/db/schema/session-plans'
|
||||
import {
|
||||
calculateResponseTimeStats,
|
||||
calculateAutoPauseInfo,
|
||||
getAutoPauseExplanation,
|
||||
formatMs,
|
||||
DEFAULT_PAUSE_TIMEOUT_MS,
|
||||
MIN_SAMPLES_FOR_STATISTICS,
|
||||
MIN_PAUSE_THRESHOLD_MS,
|
||||
MAX_PAUSE_THRESHOLD_MS,
|
||||
} from './autoPauseCalculator'
|
||||
|
||||
// Helper to create mock SlotResult with only responseTimeMs
|
||||
function mockResult(responseTimeMs: number): SlotResult {
|
||||
return {
|
||||
partNumber: 1,
|
||||
slotIndex: 0,
|
||||
problem: { terms: [1, 2], answer: 3, skillsRequired: [] },
|
||||
studentAnswer: 3,
|
||||
isCorrect: true,
|
||||
responseTimeMs,
|
||||
skillsExercised: [],
|
||||
usedOnScreenAbacus: false,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('autoPauseCalculator', () => {
|
||||
describe('formatMs', () => {
|
||||
it('formats milliseconds less than 60s as seconds', () => {
|
||||
expect(formatMs(1000)).toBe('1.0s')
|
||||
expect(formatMs(1500)).toBe('1.5s')
|
||||
expect(formatMs(30000)).toBe('30.0s')
|
||||
expect(formatMs(59999)).toBe('60.0s') // Just under 60s
|
||||
})
|
||||
|
||||
it('formats milliseconds >= 60s as minutes', () => {
|
||||
expect(formatMs(60000)).toBe('1.0m')
|
||||
expect(formatMs(90000)).toBe('1.5m')
|
||||
expect(formatMs(120000)).toBe('2.0m')
|
||||
expect(formatMs(300000)).toBe('5.0m')
|
||||
})
|
||||
|
||||
it('handles edge cases', () => {
|
||||
expect(formatMs(0)).toBe('0.0s')
|
||||
expect(formatMs(100)).toBe('0.1s')
|
||||
expect(formatMs(59999)).toBe('60.0s')
|
||||
expect(formatMs(60000)).toBe('1.0m')
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateResponseTimeStats', () => {
|
||||
it('returns zeros for empty array', () => {
|
||||
const stats = calculateResponseTimeStats([])
|
||||
expect(stats).toEqual({ mean: 0, stdDev: 0, count: 0 })
|
||||
})
|
||||
|
||||
it('calculates mean correctly for single sample', () => {
|
||||
const stats = calculateResponseTimeStats([mockResult(5000)])
|
||||
expect(stats.mean).toBe(5000)
|
||||
expect(stats.stdDev).toBe(0)
|
||||
expect(stats.count).toBe(1)
|
||||
})
|
||||
|
||||
it('calculates mean correctly for multiple samples', () => {
|
||||
const results = [
|
||||
mockResult(1000),
|
||||
mockResult(2000),
|
||||
mockResult(3000),
|
||||
mockResult(4000),
|
||||
mockResult(5000),
|
||||
]
|
||||
const stats = calculateResponseTimeStats(results)
|
||||
expect(stats.mean).toBe(3000) // (1+2+3+4+5)/5 = 3
|
||||
expect(stats.count).toBe(5)
|
||||
})
|
||||
|
||||
it('calculates sample standard deviation correctly', () => {
|
||||
// Values: 2000, 4000, 4000, 4000, 5000, 5000, 7000, 9000
|
||||
// Mean: 5000
|
||||
// Sample variance: sum of (x-mean)^2 / (n-1)
|
||||
const results = [
|
||||
mockResult(2000),
|
||||
mockResult(4000),
|
||||
mockResult(4000),
|
||||
mockResult(4000),
|
||||
mockResult(5000),
|
||||
mockResult(5000),
|
||||
mockResult(7000),
|
||||
mockResult(9000),
|
||||
]
|
||||
const stats = calculateResponseTimeStats(results)
|
||||
expect(stats.mean).toBe(5000)
|
||||
expect(stats.count).toBe(8)
|
||||
// Expected stdDev: sqrt(32000000/7) ≈ 2138.09
|
||||
expect(stats.stdDev).toBeCloseTo(2138.09, 0)
|
||||
})
|
||||
|
||||
it('uses sample standard deviation (n-1 denominator)', () => {
|
||||
// Two samples: 0, 10000 - mean = 5000
|
||||
// Sample variance = (5000^2 + 5000^2) / 1 = 50000000
|
||||
// Sample stdDev = sqrt(50000000) ≈ 7071.07
|
||||
const results = [mockResult(0), mockResult(10000)]
|
||||
const stats = calculateResponseTimeStats(results)
|
||||
expect(stats.mean).toBe(5000)
|
||||
expect(stats.stdDev).toBeCloseTo(7071.07, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateAutoPauseInfo', () => {
|
||||
it('uses default timeout with fewer than 5 samples', () => {
|
||||
const results = [mockResult(3000), mockResult(4000), mockResult(5000), mockResult(6000)]
|
||||
const { threshold, stats } = calculateAutoPauseInfo(results)
|
||||
|
||||
expect(threshold).toBe(DEFAULT_PAUSE_TIMEOUT_MS)
|
||||
expect(stats.usedStatistics).toBe(false)
|
||||
expect(stats.sampleCount).toBe(4)
|
||||
})
|
||||
|
||||
it('uses default timeout with 0 samples', () => {
|
||||
const { threshold, stats } = calculateAutoPauseInfo([])
|
||||
|
||||
expect(threshold).toBe(DEFAULT_PAUSE_TIMEOUT_MS)
|
||||
expect(stats.usedStatistics).toBe(false)
|
||||
expect(stats.sampleCount).toBe(0)
|
||||
})
|
||||
|
||||
it('uses statistical calculation with 5+ samples', () => {
|
||||
const results = [
|
||||
mockResult(3000),
|
||||
mockResult(4000),
|
||||
mockResult(5000),
|
||||
mockResult(6000),
|
||||
mockResult(7000),
|
||||
]
|
||||
const { stats } = calculateAutoPauseInfo(results)
|
||||
|
||||
expect(stats.usedStatistics).toBe(true)
|
||||
expect(stats.sampleCount).toBe(5)
|
||||
})
|
||||
|
||||
it('calculates threshold as mean + 2*stdDev', () => {
|
||||
// Create samples where we know the expected stats
|
||||
// All same value = stdDev of 0, threshold = mean
|
||||
const results = Array(10)
|
||||
.fill(null)
|
||||
.map(() => mockResult(60000)) // All 60s
|
||||
|
||||
const { threshold, stats } = calculateAutoPauseInfo(results)
|
||||
|
||||
expect(stats.meanMs).toBe(60000)
|
||||
expect(stats.stdDevMs).toBe(0)
|
||||
// 60000 + 2*0 = 60000, but clamped to max 30s min
|
||||
expect(threshold).toBe(60000)
|
||||
})
|
||||
|
||||
it('clamps threshold to minimum 30 seconds', () => {
|
||||
// Very fast response times
|
||||
const results = Array(10)
|
||||
.fill(null)
|
||||
.map(() => mockResult(1000)) // All 1s
|
||||
|
||||
const { threshold, stats } = calculateAutoPauseInfo(results)
|
||||
|
||||
expect(stats.meanMs).toBe(1000)
|
||||
expect(stats.stdDevMs).toBe(0)
|
||||
// 1000 + 2*0 = 1000, clamped to 30000
|
||||
expect(threshold).toBe(MIN_PAUSE_THRESHOLD_MS)
|
||||
})
|
||||
|
||||
it('clamps threshold to maximum 5 minutes', () => {
|
||||
// Very slow and variable response times
|
||||
const results = [
|
||||
mockResult(200000), // 200s
|
||||
mockResult(250000), // 250s
|
||||
mockResult(300000), // 300s
|
||||
mockResult(350000), // 350s
|
||||
mockResult(400000), // 400s
|
||||
]
|
||||
|
||||
const { threshold, stats } = calculateAutoPauseInfo(results)
|
||||
|
||||
expect(stats.usedStatistics).toBe(true)
|
||||
// Raw threshold would be way over 5 min
|
||||
expect(threshold).toBe(MAX_PAUSE_THRESHOLD_MS)
|
||||
})
|
||||
|
||||
it('returns threshold within valid range for typical response times', () => {
|
||||
// Simulate typical student: 3-8 second responses
|
||||
const results = [
|
||||
mockResult(3000),
|
||||
mockResult(4500),
|
||||
mockResult(5000),
|
||||
mockResult(6000),
|
||||
mockResult(8000),
|
||||
mockResult(4000),
|
||||
mockResult(5500),
|
||||
]
|
||||
|
||||
const { threshold, stats } = calculateAutoPauseInfo(results)
|
||||
|
||||
expect(stats.usedStatistics).toBe(true)
|
||||
expect(threshold).toBeGreaterThanOrEqual(MIN_PAUSE_THRESHOLD_MS)
|
||||
expect(threshold).toBeLessThanOrEqual(MAX_PAUSE_THRESHOLD_MS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAutoPauseExplanation', () => {
|
||||
it('explains default timeout when statistics not used', () => {
|
||||
const stats = {
|
||||
meanMs: 5000,
|
||||
stdDevMs: 1000,
|
||||
thresholdMs: DEFAULT_PAUSE_TIMEOUT_MS,
|
||||
sampleCount: 3,
|
||||
usedStatistics: false,
|
||||
}
|
||||
|
||||
const explanation = getAutoPauseExplanation(stats)
|
||||
|
||||
expect(explanation).toContain('Default timeout')
|
||||
expect(explanation).toContain('5.0m')
|
||||
expect(explanation).toContain(`${MIN_SAMPLES_FOR_STATISTICS}+`)
|
||||
})
|
||||
|
||||
it('explains statistical calculation without clamping', () => {
|
||||
const stats = {
|
||||
meanMs: 30000,
|
||||
stdDevMs: 10000,
|
||||
thresholdMs: 50000, // mean + 2*stdDev = 50000
|
||||
sampleCount: 10,
|
||||
usedStatistics: true,
|
||||
}
|
||||
|
||||
const explanation = getAutoPauseExplanation(stats)
|
||||
|
||||
expect(explanation).toContain('mean (30.0s)')
|
||||
expect(explanation).toContain('2×stdDev (10.0s)')
|
||||
expect(explanation).toContain('50.0s')
|
||||
expect(explanation).not.toContain('clamped')
|
||||
})
|
||||
|
||||
it('explains when threshold is clamped to minimum', () => {
|
||||
const stats = {
|
||||
meanMs: 5000,
|
||||
stdDevMs: 1000,
|
||||
thresholdMs: MIN_PAUSE_THRESHOLD_MS, // Clamped to 30s
|
||||
sampleCount: 10,
|
||||
usedStatistics: true,
|
||||
}
|
||||
|
||||
const explanation = getAutoPauseExplanation(stats)
|
||||
|
||||
expect(explanation).toContain('clamped')
|
||||
expect(explanation).toContain('30.0s')
|
||||
})
|
||||
|
||||
it('explains when threshold is clamped to maximum', () => {
|
||||
// Raw threshold: 250000 + 2*100000 = 450000ms (7.5min) > 5min max
|
||||
const stats = {
|
||||
meanMs: 250000,
|
||||
stdDevMs: 100000,
|
||||
thresholdMs: MAX_PAUSE_THRESHOLD_MS, // Clamped to 5m
|
||||
sampleCount: 10,
|
||||
usedStatistics: true,
|
||||
}
|
||||
|
||||
const explanation = getAutoPauseExplanation(stats)
|
||||
|
||||
expect(explanation).toContain('clamped')
|
||||
expect(explanation).toContain('5.0m')
|
||||
})
|
||||
})
|
||||
|
||||
describe('constants', () => {
|
||||
it('has correct default values', () => {
|
||||
expect(DEFAULT_PAUSE_TIMEOUT_MS).toBe(5 * 60 * 1000) // 5 minutes
|
||||
expect(MIN_SAMPLES_FOR_STATISTICS).toBe(5)
|
||||
expect(MIN_PAUSE_THRESHOLD_MS).toBe(30_000) // 30 seconds
|
||||
expect(MAX_PAUSE_THRESHOLD_MS).toBe(DEFAULT_PAUSE_TIMEOUT_MS) // 5 minutes
|
||||
})
|
||||
})
|
||||
})
|
||||
132
apps/web/src/lib/session-share.test.ts
Normal file
132
apps/web/src/lib/session-share.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Tests for session share token utilities
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
generateShareToken,
|
||||
isValidShareToken,
|
||||
getExpirationTime,
|
||||
type ShareDuration,
|
||||
} from './session-share'
|
||||
|
||||
describe('session-share utilities', () => {
|
||||
describe('generateShareToken', () => {
|
||||
it('generates a 10-character token', () => {
|
||||
const token = generateShareToken()
|
||||
expect(token.length).toBe(10)
|
||||
})
|
||||
|
||||
it('generates only base62 characters', () => {
|
||||
const base62Pattern = /^[0-9a-zA-Z]+$/
|
||||
// Generate multiple tokens to increase confidence
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const token = generateShareToken()
|
||||
expect(token).toMatch(base62Pattern)
|
||||
}
|
||||
})
|
||||
|
||||
it('generates unique tokens', () => {
|
||||
const tokens = new Set<string>()
|
||||
const count = 1000
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
tokens.add(generateShareToken())
|
||||
}
|
||||
|
||||
// All tokens should be unique
|
||||
expect(tokens.size).toBe(count)
|
||||
})
|
||||
|
||||
it('uses cryptographically random values', () => {
|
||||
// Verify crypto.getRandomValues is called
|
||||
const mockGetRandomValues = vi.spyOn(crypto, 'getRandomValues')
|
||||
|
||||
generateShareToken()
|
||||
|
||||
expect(mockGetRandomValues).toHaveBeenCalledWith(expect.any(Uint8Array))
|
||||
mockGetRandomValues.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidShareToken', () => {
|
||||
it('returns true for valid 10-char base62 tokens', () => {
|
||||
expect(isValidShareToken('abcdef1234')).toBe(true)
|
||||
expect(isValidShareToken('ABCDEFGHIJ')).toBe(true)
|
||||
expect(isValidShareToken('0123456789')).toBe(true)
|
||||
expect(isValidShareToken('aA1bB2cC3d')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for tokens with wrong length', () => {
|
||||
expect(isValidShareToken('')).toBe(false)
|
||||
expect(isValidShareToken('abc')).toBe(false)
|
||||
expect(isValidShareToken('abcdef123')).toBe(false) // 9 chars
|
||||
expect(isValidShareToken('abcdef12345')).toBe(false) // 11 chars
|
||||
expect(isValidShareToken('abcdefghijklmnop')).toBe(false) // 16 chars
|
||||
})
|
||||
|
||||
it('returns false for tokens with invalid characters', () => {
|
||||
expect(isValidShareToken('abcde!@#$%')).toBe(false)
|
||||
expect(isValidShareToken('abc def123')).toBe(false) // space
|
||||
expect(isValidShareToken('abcdef123_')).toBe(false) // underscore
|
||||
expect(isValidShareToken('abcdef123-')).toBe(false) // hyphen
|
||||
expect(isValidShareToken('abcdef123.')).toBe(false) // period
|
||||
})
|
||||
|
||||
it('validates generated tokens as valid', () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const token = generateShareToken()
|
||||
expect(isValidShareToken(token)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getExpirationTime', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns 1 hour from now for "1h" duration', () => {
|
||||
const now = new Date('2025-01-15T12:00:00.000Z')
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const expiration = getExpirationTime('1h')
|
||||
|
||||
expect(expiration.getTime()).toBe(now.getTime() + 60 * 60 * 1000)
|
||||
expect(expiration.toISOString()).toBe('2025-01-15T13:00:00.000Z')
|
||||
})
|
||||
|
||||
it('returns 24 hours from now for "24h" duration', () => {
|
||||
const now = new Date('2025-01-15T12:00:00.000Z')
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const expiration = getExpirationTime('24h')
|
||||
|
||||
expect(expiration.getTime()).toBe(now.getTime() + 24 * 60 * 60 * 1000)
|
||||
expect(expiration.toISOString()).toBe('2025-01-16T12:00:00.000Z')
|
||||
})
|
||||
|
||||
it('handles edge cases around midnight', () => {
|
||||
const now = new Date('2025-01-15T23:30:00.000Z')
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const oneHour = getExpirationTime('1h')
|
||||
expect(oneHour.toISOString()).toBe('2025-01-16T00:30:00.000Z')
|
||||
|
||||
const twentyFourHours = getExpirationTime('24h')
|
||||
expect(twentyFourHours.toISOString()).toBe('2025-01-16T23:30:00.000Z')
|
||||
})
|
||||
|
||||
it('handles leap year dates', () => {
|
||||
// Feb 28, 2024 (leap year)
|
||||
const now = new Date('2024-02-28T12:00:00.000Z')
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const twentyFourHours = getExpirationTime('24h')
|
||||
expect(twentyFourHours.toISOString()).toBe('2024-02-29T12:00:00.000Z')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user