Add more tests
This commit is contained in:
parent
62281e1124
commit
5974134ad8
7 changed files with 1418 additions and 231 deletions
|
|
@ -29,6 +29,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const lastOperationRef = useRef<(() => Promise<void>) | null>(null);
|
const lastOperationRef = useRef<(() => Promise<void>) | null>(null);
|
||||||
|
const savingRef = useRef(false);
|
||||||
|
|
||||||
const fetchQuizzes = useCallback(async () => {
|
const fetchQuizzes = useCallback(async () => {
|
||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
|
|
@ -88,7 +89,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
||||||
source: QuizSource,
|
source: QuizSource,
|
||||||
aiTopic?: string
|
aiTopic?: string
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
if (saving) {
|
if (savingRef.current) {
|
||||||
toast.error('Save already in progress');
|
toast.error('Save already in progress');
|
||||||
throw new Error('Save already in progress');
|
throw new Error('Save already in progress');
|
||||||
}
|
}
|
||||||
|
|
@ -117,6 +118,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
savingRef.current = true;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,7 @@ describe('DefaultConfigModal', () => {
|
||||||
shuffleQuestions: true,
|
shuffleQuestions: true,
|
||||||
shuffleAnswers: true,
|
shuffleAnswers: true,
|
||||||
hostParticipates: true,
|
hostParticipates: true,
|
||||||
|
randomNamesEnabled: false,
|
||||||
streakBonusEnabled: true,
|
streakBonusEnabled: true,
|
||||||
streakThreshold: 3,
|
streakThreshold: 3,
|
||||||
streakMultiplier: 1.2,
|
streakMultiplier: 1.2,
|
||||||
|
|
|
||||||
|
|
@ -405,6 +405,7 @@ describe('GameConfigPanel', () => {
|
||||||
shuffleQuestions: true,
|
shuffleQuestions: true,
|
||||||
shuffleAnswers: true,
|
shuffleAnswers: true,
|
||||||
hostParticipates: true,
|
hostParticipates: true,
|
||||||
|
randomNamesEnabled: false,
|
||||||
streakBonusEnabled: true,
|
streakBonusEnabled: true,
|
||||||
streakThreshold: 3,
|
streakThreshold: 3,
|
||||||
streakMultiplier: 1.2,
|
streakMultiplier: 1.2,
|
||||||
|
|
@ -428,6 +429,7 @@ describe('GameConfigPanel', () => {
|
||||||
shuffleQuestions: false,
|
shuffleQuestions: false,
|
||||||
shuffleAnswers: false,
|
shuffleAnswers: false,
|
||||||
hostParticipates: false,
|
hostParticipates: false,
|
||||||
|
randomNamesEnabled: false,
|
||||||
streakBonusEnabled: false,
|
streakBonusEnabled: false,
|
||||||
streakThreshold: 3,
|
streakThreshold: 3,
|
||||||
streakMultiplier: 1.1,
|
streakMultiplier: 1.1,
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,7 @@ describe('QuizEditor - Game Config Integration', () => {
|
||||||
shuffleQuestions: true,
|
shuffleQuestions: true,
|
||||||
shuffleAnswers: true,
|
shuffleAnswers: true,
|
||||||
hostParticipates: true,
|
hostParticipates: true,
|
||||||
|
randomNamesEnabled: false,
|
||||||
streakBonusEnabled: true,
|
streakBonusEnabled: true,
|
||||||
streakThreshold: 5,
|
streakThreshold: 5,
|
||||||
streakMultiplier: 1.5,
|
streakMultiplier: 1.5,
|
||||||
|
|
|
||||||
539
tests/constants.test.ts
Normal file
539
tests/constants.test.ts
Normal file
|
|
@ -0,0 +1,539 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
calculateBasePoints,
|
||||||
|
calculatePointsWithBreakdown,
|
||||||
|
getPlayerRank,
|
||||||
|
POINTS_PER_QUESTION,
|
||||||
|
QUESTION_TIME_MS,
|
||||||
|
} from '../constants';
|
||||||
|
import { DEFAULT_GAME_CONFIG } from '../types';
|
||||||
|
import type { Player, GameConfig } from '../types';
|
||||||
|
|
||||||
|
describe('calculateBasePoints', () => {
|
||||||
|
const questionTimeMs = QUESTION_TIME_MS;
|
||||||
|
const maxPoints = POINTS_PER_QUESTION;
|
||||||
|
|
||||||
|
describe('happy path - time-based scoring', () => {
|
||||||
|
it('returns max points for instant answer (< 0.5s)', () => {
|
||||||
|
const timeLeftMs = questionTimeMs - 100; // 0.1s response time
|
||||||
|
const points = calculateBasePoints(timeLeftMs, questionTimeMs);
|
||||||
|
expect(points).toBe(maxPoints);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns max points for exactly 0.5s response', () => {
|
||||||
|
const timeLeftMs = questionTimeMs - 499;
|
||||||
|
const points = calculateBasePoints(timeLeftMs, questionTimeMs);
|
||||||
|
expect(points).toBe(maxPoints);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns reduced points for slower responses', () => {
|
||||||
|
const timeLeftMs = questionTimeMs / 2; // Half time remaining
|
||||||
|
const points = calculateBasePoints(timeLeftMs, questionTimeMs);
|
||||||
|
expect(points).toBeLessThan(maxPoints);
|
||||||
|
expect(points).toBeGreaterThan(maxPoints / 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns reduced points for last-second answer', () => {
|
||||||
|
const timeLeftMs = 100;
|
||||||
|
const points = calculateBasePoints(timeLeftMs, questionTimeMs);
|
||||||
|
expect(points).toBeGreaterThan(0);
|
||||||
|
expect(points).toBeLessThanOrEqual(510);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates points correctly at exactly half time', () => {
|
||||||
|
const timeLeftMs = questionTimeMs / 2;
|
||||||
|
const points = calculateBasePoints(timeLeftMs, questionTimeMs);
|
||||||
|
// At half time, response time is 10s out of 20s
|
||||||
|
// (1 - 10/20 / 2) * 1000 = (1 - 0.25) * 1000 = 750
|
||||||
|
expect(points).toBe(750);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('handles zero time left gracefully', () => {
|
||||||
|
const points = calculateBasePoints(0, questionTimeMs);
|
||||||
|
expect(points).toBe(500); // (1 - 20/20/2) * 1000 = 500
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles full time remaining (answered immediately)', () => {
|
||||||
|
const points = calculateBasePoints(questionTimeMs, questionTimeMs);
|
||||||
|
expect(points).toBe(maxPoints);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles negative time left (should not happen but be safe)', () => {
|
||||||
|
const points = calculateBasePoints(-1000, questionTimeMs);
|
||||||
|
expect(points).toBeLessThan(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects custom maxPoints parameter', () => {
|
||||||
|
const customMax = 500;
|
||||||
|
const points = calculateBasePoints(questionTimeMs, questionTimeMs, customMax);
|
||||||
|
expect(points).toBe(customMax);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles very short question times', () => {
|
||||||
|
const shortQuestionTime = 5000; // 5 seconds
|
||||||
|
const points = calculateBasePoints(shortQuestionTime, shortQuestionTime);
|
||||||
|
expect(points).toBe(maxPoints);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('points calculation formula verification', () => {
|
||||||
|
it('follows linear decay after 0.5s threshold', () => {
|
||||||
|
// Points at 5s response time
|
||||||
|
const points5s = calculateBasePoints(questionTimeMs - 5000, questionTimeMs);
|
||||||
|
// Points at 10s response time
|
||||||
|
const points10s = calculateBasePoints(questionTimeMs - 10000, questionTimeMs);
|
||||||
|
|
||||||
|
// The decay should be linear
|
||||||
|
const diff1 = maxPoints - points5s;
|
||||||
|
const diff2 = points5s - points10s;
|
||||||
|
expect(Math.abs(diff1 - diff2)).toBeLessThan(10); // Allow small rounding difference
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculatePointsWithBreakdown', () => {
|
||||||
|
const baseParams = {
|
||||||
|
isCorrect: true,
|
||||||
|
timeLeftMs: QUESTION_TIME_MS,
|
||||||
|
questionTimeMs: QUESTION_TIME_MS,
|
||||||
|
streak: 0,
|
||||||
|
playerRank: 1,
|
||||||
|
isFirstCorrect: false,
|
||||||
|
config: { ...DEFAULT_GAME_CONFIG },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('correct answers - happy path', () => {
|
||||||
|
it('returns base points for correct answer with no bonuses', () => {
|
||||||
|
const result = calculatePointsWithBreakdown(baseParams);
|
||||||
|
|
||||||
|
expect(result.basePoints).toBe(POINTS_PER_QUESTION);
|
||||||
|
expect(result.streakBonus).toBe(0);
|
||||||
|
expect(result.comebackBonus).toBe(0);
|
||||||
|
expect(result.firstCorrectBonus).toBe(0);
|
||||||
|
expect(result.penalty).toBe(0);
|
||||||
|
expect(result.total).toBe(POINTS_PER_QUESTION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates time-based points correctly', () => {
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
timeLeftMs: QUESTION_TIME_MS / 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.basePoints).toBe(750);
|
||||||
|
expect(result.total).toBe(750);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('incorrect answers', () => {
|
||||||
|
it('returns zero points for incorrect answer with no penalty', () => {
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
isCorrect: false,
|
||||||
|
config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.basePoints).toBe(0);
|
||||||
|
expect(result.penalty).toBe(0);
|
||||||
|
expect(result.total).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies penalty for incorrect answer when enabled', () => {
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
isCorrect: false,
|
||||||
|
config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true, penaltyPercent: 25 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.basePoints).toBe(0);
|
||||||
|
expect(result.penalty).toBe(250);
|
||||||
|
expect(result.total).toBe(-250);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates penalty based on penaltyPercent', () => {
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
isCorrect: false,
|
||||||
|
config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true, penaltyPercent: 50 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.penalty).toBe(500);
|
||||||
|
expect(result.total).toBe(-500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('streak bonus', () => {
|
||||||
|
it('applies no streak bonus when disabled', () => {
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
streak: 5,
|
||||||
|
config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.streakBonus).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies no streak bonus below threshold', () => {
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
streak: 2, // Below default threshold of 3
|
||||||
|
config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.streakBonus).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies streak bonus at exactly threshold', () => {
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
streak: 3,
|
||||||
|
config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 3, streakMultiplier: 1.2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// At threshold, streakCount = 0, so multiplier = 1.2 + (0 * 0.2) = 1.2
|
||||||
|
// 1000 * 1.2 = 1200, bonus = 200
|
||||||
|
expect(result.streakBonus).toBe(200);
|
||||||
|
expect(result.total).toBe(1200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increases streak bonus above threshold', () => {
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
streak: 5,
|
||||||
|
config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 3, streakMultiplier: 1.2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// streakCount = 5 - 3 = 2
|
||||||
|
// multiplier = 1.2 + (2 * 0.2) = 1.6
|
||||||
|
// 1000 * 1.6 = 1600, bonus = 600
|
||||||
|
expect(result.streakBonus).toBe(600);
|
||||||
|
expect(result.total).toBe(1600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compounds streak multiplier correctly', () => {
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
streak: 10,
|
||||||
|
config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 3, streakMultiplier: 1.1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// streakCount = 10 - 3 = 7
|
||||||
|
// multiplier = 1.1 + (7 * 0.1) = 1.8
|
||||||
|
expect(result.basePoints).toBe(1000);
|
||||||
|
expect(result.total).toBe(1800);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('comeback bonus', () => {
|
||||||
|
it('applies no comeback bonus when disabled', () => {
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
playerRank: 5,
|
||||||
|
config: { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.comebackBonus).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies no comeback bonus for top 3 players', () => {
|
||||||
|
[1, 2, 3].forEach(rank => {
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
playerRank: rank,
|
||||||
|
config: { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true, comebackBonusPoints: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.comebackBonus).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies comeback bonus for 4th place and below', () => {
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
playerRank: 4,
|
||||||
|
config: { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true, comebackBonusPoints: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.comebackBonus).toBe(100);
|
||||||
|
expect(result.total).toBe(1100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies same comeback bonus for any rank below 4', () => {
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
playerRank: 10,
|
||||||
|
config: { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true, comebackBonusPoints: 150 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.comebackBonus).toBe(150);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('first correct bonus', () => {
|
||||||
|
it('applies no first correct bonus when disabled', () => {
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
isFirstCorrect: true,
|
||||||
|
config: { ...DEFAULT_GAME_CONFIG, firstCorrectBonusEnabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.firstCorrectBonus).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies first correct bonus when enabled and is first', () => {
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
isFirstCorrect: true,
|
||||||
|
config: { ...DEFAULT_GAME_CONFIG, firstCorrectBonusEnabled: true, firstCorrectBonusPoints: 50 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.firstCorrectBonus).toBe(50);
|
||||||
|
expect(result.total).toBe(1050);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies no first correct bonus when not first', () => {
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
isFirstCorrect: false,
|
||||||
|
config: { ...DEFAULT_GAME_CONFIG, firstCorrectBonusEnabled: true, firstCorrectBonusPoints: 50 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.firstCorrectBonus).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('combined bonuses', () => {
|
||||||
|
it('stacks all bonuses correctly', () => {
|
||||||
|
const config: GameConfig = {
|
||||||
|
...DEFAULT_GAME_CONFIG,
|
||||||
|
streakBonusEnabled: true,
|
||||||
|
streakThreshold: 3,
|
||||||
|
streakMultiplier: 1.2,
|
||||||
|
comebackBonusEnabled: true,
|
||||||
|
comebackBonusPoints: 100,
|
||||||
|
firstCorrectBonusEnabled: true,
|
||||||
|
firstCorrectBonusPoints: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
streak: 4, // Above threshold
|
||||||
|
playerRank: 5, // Qualifies for comeback
|
||||||
|
isFirstCorrect: true,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.basePoints).toBe(1000);
|
||||||
|
expect(result.streakBonus).toBeGreaterThan(0);
|
||||||
|
expect(result.comebackBonus).toBe(100);
|
||||||
|
expect(result.firstCorrectBonus).toBe(50);
|
||||||
|
expect(result.total).toBe(result.basePoints + result.streakBonus + result.comebackBonus + result.firstCorrectBonus);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not apply bonuses to incorrect answers', () => {
|
||||||
|
const config: GameConfig = {
|
||||||
|
...DEFAULT_GAME_CONFIG,
|
||||||
|
streakBonusEnabled: true,
|
||||||
|
streakThreshold: 3,
|
||||||
|
comebackBonusEnabled: true,
|
||||||
|
comebackBonusPoints: 100,
|
||||||
|
firstCorrectBonusEnabled: true,
|
||||||
|
firstCorrectBonusPoints: 50,
|
||||||
|
penaltyForWrongAnswer: true,
|
||||||
|
penaltyPercent: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
isCorrect: false,
|
||||||
|
streak: 5,
|
||||||
|
playerRank: 5,
|
||||||
|
isFirstCorrect: true,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.basePoints).toBe(0);
|
||||||
|
expect(result.streakBonus).toBe(0);
|
||||||
|
expect(result.comebackBonus).toBe(0);
|
||||||
|
expect(result.firstCorrectBonus).toBe(0);
|
||||||
|
expect(result.penalty).toBe(250);
|
||||||
|
expect(result.total).toBe(-250);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('handles zero streak threshold', () => {
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
streak: 1,
|
||||||
|
config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 0, streakMultiplier: 1.5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// streakCount = 1 - 0 = 1
|
||||||
|
// multiplier = 1.5 + (1 * 0.5) = 2.0
|
||||||
|
expect(result.total).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles 100% penalty', () => {
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
isCorrect: false,
|
||||||
|
config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true, penaltyPercent: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.penalty).toBe(1000);
|
||||||
|
expect(result.total).toBe(-1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles 0% penalty (same as disabled)', () => {
|
||||||
|
const result = calculatePointsWithBreakdown({
|
||||||
|
...baseParams,
|
||||||
|
isCorrect: false,
|
||||||
|
config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true, penaltyPercent: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.penalty).toBe(0);
|
||||||
|
expect(result.total === 0 || Object.is(result.total, -0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPlayerRank', () => {
|
||||||
|
const createMockPlayer = (id: string, score: number): Player => ({
|
||||||
|
id,
|
||||||
|
name: `Player ${id}`,
|
||||||
|
score,
|
||||||
|
previousScore: 0,
|
||||||
|
streak: 0,
|
||||||
|
lastAnswerCorrect: null,
|
||||||
|
selectedShape: null,
|
||||||
|
pointsBreakdown: null,
|
||||||
|
isBot: false,
|
||||||
|
avatarSeed: 0.5,
|
||||||
|
color: '#000000',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('happy path', () => {
|
||||||
|
it('returns 1 for the player with highest score', () => {
|
||||||
|
const players = [
|
||||||
|
createMockPlayer('a', 100),
|
||||||
|
createMockPlayer('b', 200),
|
||||||
|
createMockPlayer('c', 150),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getPlayerRank('b', players)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct rank for each player', () => {
|
||||||
|
const players = [
|
||||||
|
createMockPlayer('a', 100),
|
||||||
|
createMockPlayer('b', 200),
|
||||||
|
createMockPlayer('c', 150),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getPlayerRank('b', players)).toBe(1);
|
||||||
|
expect(getPlayerRank('c', players)).toBe(2);
|
||||||
|
expect(getPlayerRank('a', players)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles single player', () => {
|
||||||
|
const players = [createMockPlayer('a', 100)];
|
||||||
|
|
||||||
|
expect(getPlayerRank('a', players)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tie handling', () => {
|
||||||
|
it('gives same rank position to tied players based on array order', () => {
|
||||||
|
const players = [
|
||||||
|
createMockPlayer('a', 100),
|
||||||
|
createMockPlayer('b', 100),
|
||||||
|
createMockPlayer('c', 100),
|
||||||
|
];
|
||||||
|
|
||||||
|
// All have same score, order depends on sort stability and original order
|
||||||
|
const rankA = getPlayerRank('a', players);
|
||||||
|
const rankB = getPlayerRank('b', players);
|
||||||
|
const rankC = getPlayerRank('c', players);
|
||||||
|
|
||||||
|
// All should be valid ranks 1-3
|
||||||
|
expect([rankA, rankB, rankC].sort()).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ranks correctly with some ties', () => {
|
||||||
|
const players = [
|
||||||
|
createMockPlayer('a', 200),
|
||||||
|
createMockPlayer('b', 100),
|
||||||
|
createMockPlayer('c', 200),
|
||||||
|
createMockPlayer('d', 50),
|
||||||
|
];
|
||||||
|
|
||||||
|
// a and c are tied for first
|
||||||
|
const rankA = getPlayerRank('a', players);
|
||||||
|
const rankC = getPlayerRank('c', players);
|
||||||
|
|
||||||
|
expect(rankA).toBeLessThanOrEqual(2);
|
||||||
|
expect(rankC).toBeLessThanOrEqual(2);
|
||||||
|
expect(getPlayerRank('b', players)).toBe(3);
|
||||||
|
expect(getPlayerRank('d', players)).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('returns 0 for non-existent player', () => {
|
||||||
|
const players = [
|
||||||
|
createMockPlayer('a', 100),
|
||||||
|
createMockPlayer('b', 200),
|
||||||
|
];
|
||||||
|
|
||||||
|
// findIndex returns -1 for not found, +1 makes it 0
|
||||||
|
expect(getPlayerRank('nonexistent', players)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty players array', () => {
|
||||||
|
expect(getPlayerRank('any', [])).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles zero scores', () => {
|
||||||
|
const players = [
|
||||||
|
createMockPlayer('a', 0),
|
||||||
|
createMockPlayer('b', 0),
|
||||||
|
createMockPlayer('c', 100),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getPlayerRank('c', players)).toBe(1);
|
||||||
|
// a and b tied at 0
|
||||||
|
const rankA = getPlayerRank('a', players);
|
||||||
|
const rankB = getPlayerRank('b', players);
|
||||||
|
expect(Math.min(rankA, rankB)).toBe(2);
|
||||||
|
expect(Math.max(rankA, rankB)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles negative scores (from penalties)', () => {
|
||||||
|
const players = [
|
||||||
|
createMockPlayer('a', -100),
|
||||||
|
createMockPlayer('b', 50),
|
||||||
|
createMockPlayer('c', -50),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getPlayerRank('b', players)).toBe(1);
|
||||||
|
expect(getPlayerRank('c', players)).toBe(2);
|
||||||
|
expect(getPlayerRank('a', players)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate original array', () => {
|
||||||
|
const players = [
|
||||||
|
createMockPlayer('a', 100),
|
||||||
|
createMockPlayer('b', 200),
|
||||||
|
createMockPlayer('c', 150),
|
||||||
|
];
|
||||||
|
|
||||||
|
const originalOrder = players.map(p => p.id);
|
||||||
|
getPlayerRank('b', players);
|
||||||
|
|
||||||
|
expect(players.map(p => p.id)).toEqual(originalOrder);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
395
tests/hooks/useAuthenticatedFetch.test.tsx
Normal file
395
tests/hooks/useAuthenticatedFetch.test.tsx
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
const mockSigninSilent = vi.fn();
|
||||||
|
const mockSigninRedirect = vi.fn();
|
||||||
|
const mockAuth = {
|
||||||
|
user: {
|
||||||
|
access_token: 'valid-token',
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
},
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
signinSilent: mockSigninSilent,
|
||||||
|
signinRedirect: mockSigninRedirect,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('react-oidc-context', () => ({
|
||||||
|
useAuth: () => mockAuth,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
describe('useAuthenticatedFetch', () => {
|
||||||
|
let useAuthenticatedFetch: typeof import('../../hooks/useAuthenticatedFetch').useAuthenticatedFetch;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
const module = await import('../../hooks/useAuthenticatedFetch');
|
||||||
|
useAuthenticatedFetch = module.useAuthenticatedFetch;
|
||||||
|
|
||||||
|
mockAuth.user = {
|
||||||
|
access_token: 'valid-token',
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
};
|
||||||
|
mockAuth.isAuthenticated = true;
|
||||||
|
mockAuth.isLoading = false;
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
Object.defineProperty(navigator, 'onLine', { value: true, writable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('authFetch - happy path', () => {
|
||||||
|
it('makes authenticated request with Bearer token', async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({ data: 'test' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuthenticatedFetch());
|
||||||
|
|
||||||
|
let response;
|
||||||
|
await act(async () => {
|
||||||
|
response = await result.current.authFetch('/api/test');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:3001/api/test',
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: 'Bearer valid-token',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses full URL when provided', async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuthenticatedFetch());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.authFetch('https://other-api.com/endpoint');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'https://other-api.com/endpoint',
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through additional options', async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuthenticatedFetch());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.authFetch('/api/test', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ data: 'test' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ data: 'test' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('token expiration handling', () => {
|
||||||
|
it('refreshes token silently when expired', async () => {
|
||||||
|
mockAuth.user = {
|
||||||
|
access_token: 'expired-token',
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) - 100,
|
||||||
|
};
|
||||||
|
mockSigninSilent.mockResolvedValueOnce({ access_token: 'new-token' });
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuthenticatedFetch());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.authFetch('/api/test');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSigninSilent).toHaveBeenCalled();
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: 'Bearer new-token',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes token with buffer before actual expiration', async () => {
|
||||||
|
mockAuth.user = {
|
||||||
|
access_token: 'almost-expired-token',
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 30,
|
||||||
|
};
|
||||||
|
mockSigninSilent.mockResolvedValueOnce({ access_token: 'refreshed-token' });
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuthenticatedFetch());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.authFetch('/api/test');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSigninSilent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('redirect scenarios', () => {
|
||||||
|
it('throws error when silent refresh fails on expired token', async () => {
|
||||||
|
mockAuth.user = {
|
||||||
|
access_token: 'expired-token',
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) - 100,
|
||||||
|
};
|
||||||
|
mockSigninSilent.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuthenticatedFetch());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
await result.current.authFetch('/api/test');
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Session expired, redirecting to login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error when 401 and refresh fails', async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
mockSigninSilent.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuthenticatedFetch());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
await result.current.authFetch('/api/test');
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Session expired, redirecting to login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('401 handling', () => {
|
||||||
|
it('retries with refreshed token on 401', async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
mockSigninSilent.mockResolvedValueOnce({ access_token: 'new-token' });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuthenticatedFetch());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.authFetch('/api/test');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSigninSilent).toHaveBeenCalled();
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('throws on network error', async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network failed'));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuthenticatedFetch());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
await result.current.authFetch('/api/test');
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Network error. Please try again.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when offline before request', async () => {
|
||||||
|
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuthenticatedFetch());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
await result.current.authFetch('/api/test');
|
||||||
|
})
|
||||||
|
).rejects.toThrow('You appear to be offline. Please check your connection.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws server error for 5xx responses', async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuthenticatedFetch());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
await result.current.authFetch('/api/test');
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Server error. Please try again later.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when not authenticated', async () => {
|
||||||
|
mockAuth.user = null;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuthenticatedFetch());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
await result.current.authFetch('/api/test');
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Not authenticated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('concurrent requests', () => {
|
||||||
|
it('reuses pending silent refresh for concurrent requests', async () => {
|
||||||
|
mockAuth.user = {
|
||||||
|
access_token: 'expired-token',
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) - 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolveRefresh: (value: unknown) => void;
|
||||||
|
const refreshPromise = new Promise((resolve) => {
|
||||||
|
resolveRefresh = resolve;
|
||||||
|
});
|
||||||
|
mockSigninSilent.mockReturnValue(refreshPromise);
|
||||||
|
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuthenticatedFetch());
|
||||||
|
|
||||||
|
let promise1: Promise<Response>;
|
||||||
|
let promise2: Promise<Response>;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
promise1 = result.current.authFetch('/api/test1');
|
||||||
|
promise2 = result.current.authFetch('/api/test2');
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveRefresh!({ access_token: 'new-token' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await promise1!;
|
||||||
|
await promise2!;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSigninSilent).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAuthenticated and user properties', () => {
|
||||||
|
it('exposes isAuthenticated from auth context', () => {
|
||||||
|
mockAuth.isAuthenticated = true;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuthenticatedFetch());
|
||||||
|
|
||||||
|
expect(result.current.isAuthenticated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes isLoading from auth context', () => {
|
||||||
|
mockAuth.isLoading = true;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuthenticatedFetch());
|
||||||
|
|
||||||
|
expect(result.current.isLoading).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes user from auth context', () => {
|
||||||
|
const { result } = renderHook(() => useAuthenticatedFetch());
|
||||||
|
|
||||||
|
expect(result.current.user).toEqual(mockAuth.user);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('handles missing expires_at gracefully', async () => {
|
||||||
|
mockAuth.user = {
|
||||||
|
access_token: 'token-without-expiry',
|
||||||
|
expires_at: undefined,
|
||||||
|
};
|
||||||
|
mockSigninSilent.mockResolvedValueOnce({ access_token: 'new-token' });
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuthenticatedFetch());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.authFetch('/api/test');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSigninSilent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles fetch throwing TypeError for network issues', async () => {
|
||||||
|
const typeError = new TypeError('Failed to fetch');
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(typeError);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuthenticatedFetch());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
await result.current.authFetch('/api/test');
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Network error. Please try again.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns response for 4xx errors (non-401)', async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
statusText: 'Not Found',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuthenticatedFetch());
|
||||||
|
|
||||||
|
let response;
|
||||||
|
await act(async () => {
|
||||||
|
response = await result.current.authFetch('/api/test');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
import React from 'react';
|
|
||||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { useQuizLibrary } from '../../hooks/useQuizLibrary';
|
import { useQuizLibrary } from '../../hooks/useQuizLibrary';
|
||||||
import type { Quiz } from '../../types';
|
import type { Quiz } from '../../types';
|
||||||
|
|
||||||
const mockAuthFetch = vi.fn();
|
const mockAuthFetch = vi.fn();
|
||||||
const mockIsAuthenticated = vi.fn(() => true);
|
|
||||||
|
|
||||||
vi.mock('../../hooks/useAuthenticatedFetch', () => ({
|
vi.mock('../../hooks/useAuthenticatedFetch', () => ({
|
||||||
useAuthenticatedFetch: () => ({
|
useAuthenticatedFetch: () => ({
|
||||||
authFetch: mockAuthFetch,
|
authFetch: mockAuthFetch,
|
||||||
isAuthenticated: mockIsAuthenticated(),
|
isAuthenticated: true,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -39,38 +37,35 @@ const createMockQuiz = (overrides?: Partial<Quiz>): Quiz => ({
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('useQuizLibrary - updateQuiz', () => {
|
describe('useQuizLibrary', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockIsAuthenticated.mockReturnValue(true);
|
mockAuthFetch.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
describe('fetchQuizzes', () => {
|
||||||
vi.resetAllMocks();
|
it('fetches and stores quizzes', async () => {
|
||||||
});
|
const mockQuizzes = [
|
||||||
|
{ id: '1', title: 'Quiz 1', source: 'manual', questionCount: 5, createdAt: '2024-01-01', updatedAt: '2024-01-01' },
|
||||||
describe('happy path', () => {
|
{ id: '2', title: 'Quiz 2', source: 'ai_generated', questionCount: 10, createdAt: '2024-01-02', updatedAt: '2024-01-02' },
|
||||||
it('successfully updates a quiz', async () => {
|
];
|
||||||
mockAuthFetch.mockResolvedValueOnce({
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({ id: 'quiz-123' }),
|
json: () => Promise.resolve(mockQuizzes),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useQuizLibrary());
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
const quiz = createMockQuiz({ title: 'Updated Quiz' });
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.updateQuiz('quiz-123', quiz);
|
await result.current.fetchQuizzes();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-123', {
|
expect(result.current.quizzes).toEqual(mockQuizzes);
|
||||||
method: 'PUT',
|
expect(result.current.loading).toBe(false);
|
||||||
body: expect.stringContaining('Updated Quiz'),
|
expect(result.current.error).toBeNull();
|
||||||
});
|
|
||||||
expect(result.current.saving).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets saving to true during update', async () => {
|
it('sets loading to true during fetch', async () => {
|
||||||
let resolvePromise: (value: unknown) => void;
|
let resolvePromise: (value: unknown) => void;
|
||||||
const pendingPromise = new Promise((resolve) => {
|
const pendingPromise = new Promise((resolve) => {
|
||||||
resolvePromise = resolve;
|
resolvePromise = resolve;
|
||||||
|
|
@ -78,14 +73,97 @@ describe('useQuizLibrary - updateQuiz', () => {
|
||||||
mockAuthFetch.mockReturnValueOnce(pendingPromise);
|
mockAuthFetch.mockReturnValueOnce(pendingPromise);
|
||||||
|
|
||||||
const { result } = renderHook(() => useQuizLibrary());
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
const quiz = createMockQuiz();
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.updateQuiz('quiz-123', quiz);
|
result.current.fetchQuizzes();
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.saving).toBe(true);
|
expect(result.current.loading).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolvePromise!({ ok: true, json: () => Promise.resolve([]) });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles 500 server error', async () => {
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.fetchQuizzes();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.error).toBe('Server error. Please try again.');
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles generic error', async () => {
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.fetchQuizzes();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.error).toBe('Failed to load your quizzes.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadQuiz', () => {
|
||||||
|
it('loads and returns a quiz', async () => {
|
||||||
|
const mockQuiz = {
|
||||||
|
id: 'quiz-123',
|
||||||
|
title: 'Loaded Quiz',
|
||||||
|
source: 'manual',
|
||||||
|
questions: [],
|
||||||
|
createdAt: '2024-01-01',
|
||||||
|
updatedAt: '2024-01-01',
|
||||||
|
};
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockQuiz),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
|
||||||
|
let loadedQuiz;
|
||||||
|
await act(async () => {
|
||||||
|
loadedQuiz = await result.current.loadQuiz('quiz-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(loadedQuiz).toEqual(mockQuiz);
|
||||||
|
expect(mockAuthFetch).toHaveBeenCalledWith('/api/quizzes/quiz-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets and clears loadingQuizId', async () => {
|
||||||
|
let resolvePromise: (value: unknown) => void;
|
||||||
|
const pendingPromise = new Promise((resolve) => {
|
||||||
|
resolvePromise = resolve;
|
||||||
|
});
|
||||||
|
mockAuthFetch.mockReturnValueOnce(pendingPromise);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.loadQuiz('quiz-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.loadingQuizId).toBe('quiz-123');
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|
@ -93,105 +171,315 @@ describe('useQuizLibrary - updateQuiz', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.saving).toBe(false);
|
expect(result.current.loadingQuizId).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends correct request body structure', async () => {
|
it('handles 404 not found', async () => {
|
||||||
mockAuthFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useQuizLibrary());
|
|
||||||
const quiz = createMockQuiz({
|
|
||||||
title: 'My Quiz',
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
id: 'q1',
|
|
||||||
text: 'Question 1',
|
|
||||||
timeLimit: 30,
|
|
||||||
options: [
|
|
||||||
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red', reason: 'Correct!' },
|
|
||||||
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.updateQuiz('quiz-456', quiz);
|
|
||||||
});
|
|
||||||
|
|
||||||
const [, options] = mockAuthFetch.mock.calls[0];
|
|
||||||
const body = JSON.parse(options.body);
|
|
||||||
|
|
||||||
expect(body.title).toBe('My Quiz');
|
|
||||||
expect(body.questions).toHaveLength(1);
|
|
||||||
expect(body.questions[0].text).toBe('Question 1');
|
|
||||||
expect(body.questions[0].timeLimit).toBe(30);
|
|
||||||
expect(body.questions[0].options[0].reason).toBe('Correct!');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles quiz with multiple questions', async () => {
|
|
||||||
mockAuthFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useQuizLibrary());
|
|
||||||
const quiz = createMockQuiz({
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
id: 'q1',
|
|
||||||
text: 'Q1',
|
|
||||||
timeLimit: 20,
|
|
||||||
options: [
|
|
||||||
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
|
||||||
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'q2',
|
|
||||||
text: 'Q2',
|
|
||||||
timeLimit: 25,
|
|
||||||
options: [
|
|
||||||
{ text: 'C', isCorrect: false, shape: 'circle', color: 'yellow' },
|
|
||||||
{ text: 'D', isCorrect: true, shape: 'square', color: 'green' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.updateQuiz('quiz-789', quiz);
|
|
||||||
});
|
|
||||||
|
|
||||||
const [, options] = mockAuthFetch.mock.calls[0];
|
|
||||||
const body = JSON.parse(options.body);
|
|
||||||
expect(body.questions).toHaveLength(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('unhappy path - API errors', () => {
|
|
||||||
it('handles 404 not found error', async () => {
|
|
||||||
mockAuthFetch.mockResolvedValueOnce({
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 404,
|
status: 404,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.loadQuiz('non-existent');
|
||||||
|
});
|
||||||
|
expect.fail('Should have thrown');
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as Error).message).toBe('Quiz not found. It may have been deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(result.current.loadingQuizId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles generic error', async () => {
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.loadQuiz('quiz-123');
|
||||||
|
});
|
||||||
|
expect.fail('Should have thrown');
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as Error).message).toBe('Failed to load quiz.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saveQuiz', () => {
|
||||||
|
it('saves a quiz and returns the ID', async () => {
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ id: 'new-quiz-id' }),
|
||||||
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useQuizLibrary());
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
const quiz = createMockQuiz();
|
const quiz = createMockQuiz();
|
||||||
|
|
||||||
await expect(
|
let savedId;
|
||||||
act(async () => {
|
await act(async () => {
|
||||||
await result.current.updateQuiz('non-existent', quiz);
|
savedId = await result.current.saveQuiz(quiz, 'manual');
|
||||||
})
|
});
|
||||||
).rejects.toThrow('Quiz not found');
|
|
||||||
|
expect(savedId).toBe('new-quiz-id');
|
||||||
|
expect(result.current.saving).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends correct request body for manual quiz', async () => {
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ id: 'id' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz({ title: 'Manual Quiz' });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveQuiz(quiz, 'manual');
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, options] = mockAuthFetch.mock.calls[0];
|
||||||
|
const body = JSON.parse(options.body);
|
||||||
|
expect(body.title).toBe('Manual Quiz');
|
||||||
|
expect(body.source).toBe('manual');
|
||||||
|
expect(body.aiTopic).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends aiTopic for AI generated quiz', async () => {
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ id: 'id' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveQuiz(quiz, 'ai_generated', 'Science');
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, options] = mockAuthFetch.mock.calls[0];
|
||||||
|
const body = JSON.parse(options.body);
|
||||||
|
expect(body.source).toBe('ai_generated');
|
||||||
|
expect(body.aiTopic).toBe('Science');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes gameConfig when present', async () => {
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ id: 'id' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz({
|
||||||
|
config: {
|
||||||
|
shuffleQuestions: true,
|
||||||
|
shuffleAnswers: false,
|
||||||
|
hostParticipates: true,
|
||||||
|
randomNamesEnabled: false,
|
||||||
|
streakBonusEnabled: false,
|
||||||
|
streakThreshold: 3,
|
||||||
|
streakMultiplier: 1.1,
|
||||||
|
comebackBonusEnabled: false,
|
||||||
|
comebackBonusPoints: 50,
|
||||||
|
penaltyForWrongAnswer: false,
|
||||||
|
penaltyPercent: 25,
|
||||||
|
firstCorrectBonusEnabled: false,
|
||||||
|
firstCorrectBonusPoints: 50,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveQuiz(quiz, 'manual');
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, options] = mockAuthFetch.mock.calls[0];
|
||||||
|
const body = JSON.parse(options.body);
|
||||||
|
expect(body.gameConfig.shuffleQuestions).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects quiz without title', async () => {
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz({ title: '' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveQuiz(quiz, 'manual');
|
||||||
|
});
|
||||||
|
expect.fail('Should have thrown');
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as Error).message).toBe('Quiz must have a title');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockAuthFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects quiz with whitespace-only title', async () => {
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz({ title: ' ' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveQuiz(quiz, 'manual');
|
||||||
|
});
|
||||||
|
expect.fail('Should have thrown');
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as Error).message).toBe('Quiz must have a title');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects quiz without questions', async () => {
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz({ questions: [] });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveQuiz(quiz, 'manual');
|
||||||
|
});
|
||||||
|
expect.fail('Should have thrown');
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as Error).message).toBe('Quiz must have at least one question');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects quiz with question without text', async () => {
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz({
|
||||||
|
questions: [{
|
||||||
|
id: 'q1',
|
||||||
|
text: '',
|
||||||
|
timeLimit: 20,
|
||||||
|
options: [
|
||||||
|
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||||
|
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveQuiz(quiz, 'manual');
|
||||||
|
});
|
||||||
|
expect.fail('Should have thrown');
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as Error).message).toBe('All questions must have text');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects question with less than 2 options', async () => {
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz({
|
||||||
|
questions: [{
|
||||||
|
id: 'q1',
|
||||||
|
text: 'Question?',
|
||||||
|
timeLimit: 20,
|
||||||
|
options: [
|
||||||
|
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveQuiz(quiz, 'manual');
|
||||||
|
});
|
||||||
|
expect.fail('Should have thrown');
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as Error).message).toBe('Each question must have at least 2 options');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects question without correct answer', async () => {
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz({
|
||||||
|
questions: [{
|
||||||
|
id: 'q1',
|
||||||
|
text: 'Question?',
|
||||||
|
timeLimit: 20,
|
||||||
|
options: [
|
||||||
|
{ text: 'A', isCorrect: false, shape: 'triangle', color: 'red' },
|
||||||
|
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveQuiz(quiz, 'manual');
|
||||||
|
});
|
||||||
|
expect.fail('Should have thrown');
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as Error).message).toBe('Each question must have a correct answer');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents double save when already saving', async () => {
|
||||||
|
let resolveFirstSave: (value: unknown) => void;
|
||||||
|
const firstSavePromise = new Promise((resolve) => {
|
||||||
|
resolveFirstSave = resolve;
|
||||||
|
});
|
||||||
|
mockAuthFetch.mockReturnValueOnce(firstSavePromise);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz();
|
||||||
|
|
||||||
|
let firstSaveResult: Promise<string>;
|
||||||
|
act(() => {
|
||||||
|
firstSaveResult = result.current.saveQuiz(quiz, 'manual');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.saving).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
let secondSaveError: Error | null = null;
|
||||||
|
await act(async () => {
|
||||||
|
try {
|
||||||
|
await result.current.saveQuiz(quiz, 'manual');
|
||||||
|
} catch (e) {
|
||||||
|
secondSaveError = e as Error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(secondSaveError).not.toBeNull();
|
||||||
|
expect(secondSaveError!.message).toBe('Save already in progress');
|
||||||
|
expect(mockAuthFetch).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveFirstSave!({ ok: true, json: () => Promise.resolve({ id: 'test-id' }) });
|
||||||
|
await firstSaveResult!;
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.saving).toBe(false);
|
expect(result.current.saving).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles 400 validation error', async () => {
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
const quiz = createMockQuiz();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveQuiz(quiz, 'manual');
|
||||||
|
});
|
||||||
|
expect.fail('Should have thrown');
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as Error).message).toBe('Invalid quiz data. Please check and try again.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('handles generic server error', async () => {
|
it('handles generic server error', async () => {
|
||||||
mockAuthFetch.mockResolvedValueOnce({
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|
@ -201,49 +489,81 @@ describe('useQuizLibrary - updateQuiz', () => {
|
||||||
const { result } = renderHook(() => useQuizLibrary());
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
const quiz = createMockQuiz();
|
const quiz = createMockQuiz();
|
||||||
|
|
||||||
await expect(
|
try {
|
||||||
act(async () => {
|
await act(async () => {
|
||||||
await result.current.updateQuiz('quiz-123', quiz);
|
await result.current.saveQuiz(quiz, 'manual');
|
||||||
})
|
});
|
||||||
).rejects.toThrow('Failed to update quiz');
|
expect.fail('Should have thrown');
|
||||||
});
|
} catch (e) {
|
||||||
|
expect((e as Error).message).toBe('Failed to save quiz.');
|
||||||
it('handles network error', async () => {
|
}
|
||||||
mockAuthFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useQuizLibrary());
|
|
||||||
const quiz = createMockQuiz();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
act(async () => {
|
|
||||||
await result.current.updateQuiz('quiz-123', quiz);
|
|
||||||
})
|
|
||||||
).rejects.toThrow('Network error');
|
|
||||||
|
|
||||||
expect(result.current.saving).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles timeout/abort error', async () => {
|
|
||||||
const abortError = new Error('The operation was aborted');
|
|
||||||
abortError.name = 'AbortError';
|
|
||||||
mockAuthFetch.mockRejectedValueOnce(abortError);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useQuizLibrary());
|
|
||||||
const quiz = createMockQuiz();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
act(async () => {
|
|
||||||
await result.current.updateQuiz('quiz-123', quiz);
|
|
||||||
})
|
|
||||||
).rejects.toThrow();
|
|
||||||
|
|
||||||
expect(result.current.saving).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('unhappy path - edge cases', () => {
|
describe('deleteQuiz', () => {
|
||||||
it('resets saving state even on error', async () => {
|
it('handles 404 not found', async () => {
|
||||||
mockAuthFetch.mockRejectedValueOnce(new Error('Server error'));
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.deleteQuiz('non-existent');
|
||||||
|
});
|
||||||
|
expect.fail('Should have thrown');
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as Error).message).toBe('Quiz not found.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles generic server error', async () => {
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.deleteQuiz('quiz-123');
|
||||||
|
});
|
||||||
|
expect.fail('Should have thrown');
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as Error).message).toBe('Failed to delete quiz.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateQuizConfig', () => {
|
||||||
|
it('throws error on failure', async () => {
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.updateQuizConfig('quiz-123', {} as any);
|
||||||
|
});
|
||||||
|
expect.fail('Should have thrown');
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as Error).message).toBe('Failed to update config');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateQuiz', () => {
|
||||||
|
it('handles generic server error', async () => {
|
||||||
|
mockAuthFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useQuizLibrary());
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
const quiz = createMockQuiz();
|
const quiz = createMockQuiz();
|
||||||
|
|
@ -252,11 +572,10 @@ describe('useQuizLibrary - updateQuiz', () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.updateQuiz('quiz-123', quiz);
|
await result.current.updateQuiz('quiz-123', quiz);
|
||||||
});
|
});
|
||||||
} catch {
|
expect.fail('Should have thrown');
|
||||||
// Expected to throw
|
} catch (e) {
|
||||||
|
expect((e as Error).message).toBe('Failed to update quiz.');
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(result.current.saving).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles empty quiz ID', async () => {
|
it('handles empty quiz ID', async () => {
|
||||||
|
|
@ -268,86 +587,14 @@ describe('useQuizLibrary - updateQuiz', () => {
|
||||||
const { result } = renderHook(() => useQuizLibrary());
|
const { result } = renderHook(() => useQuizLibrary());
|
||||||
const quiz = createMockQuiz();
|
const quiz = createMockQuiz();
|
||||||
|
|
||||||
await expect(
|
try {
|
||||||
act(async () => {
|
await act(async () => {
|
||||||
await result.current.updateQuiz('', quiz);
|
await result.current.updateQuiz('', quiz);
|
||||||
})
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles quiz with empty title', async () => {
|
|
||||||
mockAuthFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useQuizLibrary());
|
|
||||||
const quiz = createMockQuiz({ title: '' });
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.updateQuiz('quiz-123', quiz);
|
|
||||||
});
|
|
||||||
|
|
||||||
const [, options] = mockAuthFetch.mock.calls[0];
|
|
||||||
const body = JSON.parse(options.body);
|
|
||||||
expect(body.title).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('strips undefined reason fields to null', async () => {
|
|
||||||
mockAuthFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useQuizLibrary());
|
|
||||||
const quiz = createMockQuiz({
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
id: 'q1',
|
|
||||||
text: 'Q',
|
|
||||||
timeLimit: 20,
|
|
||||||
options: [
|
|
||||||
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red', reason: undefined },
|
|
||||||
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.updateQuiz('quiz-123', quiz);
|
|
||||||
});
|
|
||||||
|
|
||||||
const [, options] = mockAuthFetch.mock.calls[0];
|
|
||||||
const body = JSON.parse(options.body);
|
|
||||||
expect(body.questions[0].options[0]).not.toHaveProperty('reason');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('concurrent operations', () => {
|
|
||||||
it('allows update after save completes', async () => {
|
|
||||||
mockAuthFetch
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({ id: 'new-quiz' }),
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({}),
|
|
||||||
});
|
});
|
||||||
|
expect.fail('Should have thrown');
|
||||||
const { result } = renderHook(() => useQuizLibrary());
|
} catch (e) {
|
||||||
const quiz = createMockQuiz();
|
expect((e as Error).message).toBe('Quiz not found.');
|
||||||
|
}
|
||||||
await act(async () => {
|
|
||||||
await result.current.saveQuiz(quiz, 'manual');
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.updateQuiz('new-quiz', { ...quiz, title: 'Updated' });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockAuthFetch).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue