Add sharing
This commit is contained in:
parent
240ce28692
commit
8a11275849
16 changed files with 1996 additions and 10 deletions
|
|
@ -108,6 +108,25 @@ const runMigrations = () => {
|
|||
db.exec("ALTER TABLE users ADD COLUMN gemini_model TEXT");
|
||||
console.log("Migration: Added gemini_model to users");
|
||||
}
|
||||
|
||||
const quizTableInfo = db.prepare("PRAGMA table_info(quizzes)").all() as { name: string }[];
|
||||
const hasShareToken = quizTableInfo.some(col => col.name === "share_token");
|
||||
if (!hasShareToken) {
|
||||
db.exec("ALTER TABLE quizzes ADD COLUMN share_token TEXT UNIQUE");
|
||||
console.log("Migration: Added share_token to quizzes");
|
||||
}
|
||||
|
||||
const hasIsShared = quizTableInfo.some(col => col.name === "is_shared");
|
||||
if (!hasIsShared) {
|
||||
db.exec("ALTER TABLE quizzes ADD COLUMN is_shared INTEGER DEFAULT 0");
|
||||
console.log("Migration: Added is_shared to quizzes");
|
||||
}
|
||||
|
||||
const shareTokenIndex = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_quizzes_share_token'").get();
|
||||
if (!shareTokenIndex) {
|
||||
db.exec("CREATE INDEX idx_quizzes_share_token ON quizzes(share_token)");
|
||||
console.log("Migration: Created index on quizzes.share_token");
|
||||
}
|
||||
};
|
||||
|
||||
runMigrations();
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS quizzes (
|
|||
source TEXT NOT NULL CHECK(source IN ('manual', 'ai_generated')),
|
||||
ai_topic TEXT,
|
||||
game_config TEXT,
|
||||
share_token TEXT UNIQUE,
|
||||
is_shared INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
|
|
@ -58,6 +60,7 @@ CREATE TABLE IF NOT EXISTS game_sessions (
|
|||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_quizzes_user ON quizzes(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_quizzes_share_token ON quizzes(share_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_questions_quiz ON questions(quiz_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_options_question ON answer_options(question_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_sessions_updated ON game_sessions(updated_at);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import usersRouter from './routes/users.js';
|
|||
import uploadRouter from './routes/upload.js';
|
||||
import gamesRouter from './routes/games.js';
|
||||
import generateRouter from './routes/generate.js';
|
||||
import sharedRouter from './routes/shared.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
|
@ -93,6 +94,7 @@ app.use('/api/users', usersRouter);
|
|||
app.use('/api/upload', uploadRouter);
|
||||
app.use('/api/games', gamesRouter);
|
||||
app.use('/api/generate', generateRouter);
|
||||
app.use('/api/shared', sharedRouter);
|
||||
|
||||
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||
console.error('Unhandled error:', err);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Router, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { db } from '../db/connection.js';
|
||||
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js';
|
||||
|
||||
|
|
@ -47,6 +48,8 @@ router.get('/', (req: AuthenticatedRequest, res: Response) => {
|
|||
q.title,
|
||||
q.source,
|
||||
q.ai_topic as aiTopic,
|
||||
q.share_token as shareToken,
|
||||
q.is_shared as isShared,
|
||||
q.created_at as createdAt,
|
||||
q.updated_at as updatedAt,
|
||||
(SELECT COUNT(*) FROM questions WHERE quiz_id = q.id) as questionCount
|
||||
|
|
@ -55,7 +58,7 @@ router.get('/', (req: AuthenticatedRequest, res: Response) => {
|
|||
ORDER BY q.updated_at DESC
|
||||
`).all(req.user!.sub);
|
||||
|
||||
res.json(quizzes);
|
||||
res.json(quizzes.map((q: any) => ({ ...q, isShared: Boolean(q.isShared) })));
|
||||
});
|
||||
|
||||
router.get('/:id', (req: AuthenticatedRequest, res: Response) => {
|
||||
|
|
@ -315,6 +318,48 @@ router.patch('/:id/config', (req: AuthenticatedRequest, res: Response) => {
|
|||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post('/:id/share', (req: AuthenticatedRequest, res: Response) => {
|
||||
const quizId = req.params.id;
|
||||
|
||||
const existing = db.prepare(`
|
||||
SELECT id, share_token as shareToken FROM quizzes WHERE id = ? AND user_id = ?
|
||||
`).get(quizId, req.user!.sub) as { id: string; shareToken: string | null } | undefined;
|
||||
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Quiz not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.shareToken) {
|
||||
res.json({ shareToken: existing.shareToken });
|
||||
return;
|
||||
}
|
||||
|
||||
const shareToken = randomBytes(16).toString('base64url');
|
||||
|
||||
db.prepare(`
|
||||
UPDATE quizzes SET share_token = ?, is_shared = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
||||
`).run(shareToken, quizId);
|
||||
|
||||
res.json({ shareToken });
|
||||
});
|
||||
|
||||
router.delete('/:id/share', (req: AuthenticatedRequest, res: Response) => {
|
||||
const quizId = req.params.id;
|
||||
|
||||
const result = db.prepare(`
|
||||
UPDATE quizzes SET share_token = NULL, is_shared = 0, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(quizId, req.user!.sub);
|
||||
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: 'Quiz not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/:id', (req: AuthenticatedRequest, res: Response) => {
|
||||
const result = db.prepare(`
|
||||
DELETE FROM quizzes WHERE id = ? AND user_id = ?
|
||||
|
|
|
|||
185
server/src/routes/shared.ts
Normal file
185
server/src/routes/shared.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { Router, Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { db } from '../db/connection.js';
|
||||
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
interface QuizRow {
|
||||
id: string;
|
||||
title: string;
|
||||
source: string;
|
||||
aiTopic: string | null;
|
||||
gameConfig: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface QuestionRow {
|
||||
id: string;
|
||||
text: string;
|
||||
timeLimit: number;
|
||||
orderIndex: number;
|
||||
}
|
||||
|
||||
interface OptionRow {
|
||||
id: string;
|
||||
text: string;
|
||||
isCorrect: number;
|
||||
shape: string;
|
||||
color: string;
|
||||
reason: string | null;
|
||||
orderIndex: number;
|
||||
}
|
||||
|
||||
router.get('/:token', (req: Request, res: Response) => {
|
||||
const { token } = req.params;
|
||||
|
||||
const quiz = db.prepare(`
|
||||
SELECT id, title, source, ai_topic as aiTopic, game_config as gameConfig, created_at as createdAt, updated_at as updatedAt
|
||||
FROM quizzes
|
||||
WHERE share_token = ? AND is_shared = 1
|
||||
`).get(token) as QuizRow | undefined;
|
||||
|
||||
if (!quiz) {
|
||||
res.status(404).json({ error: 'Shared quiz not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const questions = db.prepare(`
|
||||
SELECT id, text, time_limit as timeLimit, order_index as orderIndex
|
||||
FROM questions
|
||||
WHERE quiz_id = ?
|
||||
ORDER BY order_index
|
||||
`).all(quiz.id) as QuestionRow[];
|
||||
|
||||
const questionsWithOptions = questions.map((q) => {
|
||||
const options = db.prepare(`
|
||||
SELECT id, text, is_correct as isCorrect, shape, color, reason, order_index as orderIndex
|
||||
FROM answer_options
|
||||
WHERE question_id = ?
|
||||
ORDER BY order_index
|
||||
`).all(q.id) as OptionRow[];
|
||||
|
||||
return {
|
||||
...q,
|
||||
options: options.map((o) => ({
|
||||
...o,
|
||||
isCorrect: Boolean(o.isCorrect),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
let parsedConfig = null;
|
||||
if (quiz.gameConfig && typeof quiz.gameConfig === 'string') {
|
||||
try {
|
||||
parsedConfig = JSON.parse(quiz.gameConfig);
|
||||
} catch {
|
||||
parsedConfig = null;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
title: quiz.title,
|
||||
source: quiz.source,
|
||||
aiTopic: quiz.aiTopic,
|
||||
gameConfig: parsedConfig,
|
||||
questions: questionsWithOptions,
|
||||
questionCount: questions.length,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/:token/copy', requireAuth, (req: AuthenticatedRequest, res: Response) => {
|
||||
const { token } = req.params;
|
||||
|
||||
const sourceQuiz = db.prepare(`
|
||||
SELECT id, title, game_config as gameConfig
|
||||
FROM quizzes
|
||||
WHERE share_token = ? AND is_shared = 1
|
||||
`).get(token) as { id: string; title: string; gameConfig: string | null } | undefined;
|
||||
|
||||
if (!sourceQuiz) {
|
||||
res.status(404).json({ error: 'Shared quiz not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existingWithSameTitle = db.prepare(`
|
||||
SELECT id FROM quizzes WHERE user_id = ? AND title = ?
|
||||
`).get(req.user!.sub, sourceQuiz.title);
|
||||
|
||||
const newTitle = existingWithSameTitle ? `${sourceQuiz.title} (Copy)` : sourceQuiz.title;
|
||||
|
||||
const questions = db.prepare(`
|
||||
SELECT id, text, time_limit as timeLimit, order_index as orderIndex
|
||||
FROM questions
|
||||
WHERE quiz_id = ?
|
||||
ORDER BY order_index
|
||||
`).all(sourceQuiz.id) as QuestionRow[];
|
||||
|
||||
const newQuizId = uuidv4();
|
||||
|
||||
const upsertUser = db.prepare(`
|
||||
INSERT INTO users (id, username, email, display_name, last_login)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
last_login = CURRENT_TIMESTAMP,
|
||||
email = COALESCE(excluded.email, users.email),
|
||||
display_name = COALESCE(excluded.display_name, users.display_name)
|
||||
`);
|
||||
|
||||
const insertQuiz = db.prepare(`
|
||||
INSERT INTO quizzes (id, user_id, title, source, game_config)
|
||||
VALUES (?, ?, ?, 'manual', ?)
|
||||
`);
|
||||
|
||||
const insertQuestion = db.prepare(`
|
||||
INSERT INTO questions (id, quiz_id, text, time_limit, order_index)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const insertOption = db.prepare(`
|
||||
INSERT INTO answer_options (id, question_id, text, is_correct, shape, color, reason, order_index)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
upsertUser.run(
|
||||
req.user!.sub,
|
||||
req.user!.preferred_username,
|
||||
req.user!.email || null,
|
||||
req.user!.name || null
|
||||
);
|
||||
|
||||
insertQuiz.run(newQuizId, req.user!.sub, newTitle, sourceQuiz.gameConfig);
|
||||
|
||||
questions.forEach((q, qIdx) => {
|
||||
const newQuestionId = uuidv4();
|
||||
insertQuestion.run(newQuestionId, newQuizId, q.text, q.timeLimit, qIdx);
|
||||
|
||||
const options = db.prepare(`
|
||||
SELECT text, is_correct, shape, color, reason, order_index
|
||||
FROM answer_options
|
||||
WHERE question_id = ?
|
||||
ORDER BY order_index
|
||||
`).all(q.id) as { text: string; is_correct: number; shape: string; color: string; reason: string | null; order_index: number }[];
|
||||
|
||||
options.forEach((o, oIdx) => {
|
||||
insertOption.run(
|
||||
uuidv4(),
|
||||
newQuestionId,
|
||||
o.text,
|
||||
o.is_correct,
|
||||
o.shape,
|
||||
o.color,
|
||||
o.reason,
|
||||
oIdx
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
transaction();
|
||||
res.status(201).json({ id: newQuizId, title: newTitle });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -2159,6 +2159,376 @@ console.log('\n=== Game Session Tests ===');
|
|||
}
|
||||
});
|
||||
|
||||
console.log('\n=== Quiz Sharing Tests ===');
|
||||
|
||||
let shareTestQuizId: string | null = null;
|
||||
let shareTestToken: string | null = null;
|
||||
|
||||
console.log('\nShare Quiz Tests:');
|
||||
|
||||
await test('POST /api/quizzes creates quiz for sharing tests', async () => {
|
||||
const quiz = {
|
||||
title: 'Sharing Test Quiz',
|
||||
source: 'manual',
|
||||
gameConfig: {
|
||||
shuffleQuestions: true,
|
||||
shuffleAnswers: false,
|
||||
hostParticipates: true,
|
||||
streakBonusEnabled: false,
|
||||
streakThreshold: 3,
|
||||
streakMultiplier: 1.1,
|
||||
comebackBonusEnabled: false,
|
||||
comebackBonusPoints: 50,
|
||||
penaltyForWrongAnswer: false,
|
||||
penaltyPercent: 25,
|
||||
firstCorrectBonusEnabled: false,
|
||||
firstCorrectBonusPoints: 50,
|
||||
},
|
||||
questions: [
|
||||
{
|
||||
text: 'What is the capital of France?',
|
||||
timeLimit: 20,
|
||||
options: [
|
||||
{ text: 'London', isCorrect: false, shape: 'triangle', color: 'red', reason: 'London is in UK' },
|
||||
{ text: 'Paris', isCorrect: true, shape: 'diamond', color: 'blue', reason: 'Correct!' },
|
||||
{ text: 'Berlin', isCorrect: false, shape: 'circle', color: 'yellow' },
|
||||
{ text: 'Madrid', isCorrect: false, shape: 'square', color: 'green' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'What is 2 + 2?',
|
||||
timeLimit: 15,
|
||||
options: [
|
||||
{ text: '3', isCorrect: false, shape: 'triangle', color: 'red' },
|
||||
{ text: '4', isCorrect: true, shape: 'diamond', color: 'blue' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { data } = await request('POST', '/api/quizzes', quiz, 201);
|
||||
shareTestQuizId = (data as { id: string }).id;
|
||||
});
|
||||
|
||||
await test('POST /api/quizzes/:id/share generates share token', async () => {
|
||||
if (!shareTestQuizId) throw new Error('No quiz created');
|
||||
|
||||
const { data } = await request('POST', `/api/quizzes/${shareTestQuizId}/share`);
|
||||
const result = data as { shareToken: string };
|
||||
|
||||
if (!result.shareToken) throw new Error('Missing shareToken in response');
|
||||
if (result.shareToken.length < 10) throw new Error('shareToken too short');
|
||||
|
||||
shareTestToken = result.shareToken;
|
||||
});
|
||||
|
||||
await test('POST /api/quizzes/:id/share returns same token if already shared', async () => {
|
||||
if (!shareTestQuizId || !shareTestToken) throw new Error('No quiz or token');
|
||||
|
||||
const { data } = await request('POST', `/api/quizzes/${shareTestQuizId}/share`);
|
||||
const result = data as { shareToken: string };
|
||||
|
||||
if (result.shareToken !== shareTestToken) {
|
||||
throw new Error('Should return same token when already shared');
|
||||
}
|
||||
});
|
||||
|
||||
await test('GET /api/quizzes returns isShared and shareToken for shared quiz', async () => {
|
||||
if (!shareTestQuizId || !shareTestToken) throw new Error('No quiz or token');
|
||||
|
||||
const { data } = await request('GET', '/api/quizzes');
|
||||
const quizzes = data as Array<{ id: string; isShared: boolean; shareToken: string | null }>;
|
||||
const quiz = quizzes.find((q) => q.id === shareTestQuizId);
|
||||
|
||||
if (!quiz) throw new Error('Shared quiz not in list');
|
||||
if (quiz.isShared !== true) throw new Error('Expected isShared to be true');
|
||||
if (quiz.shareToken !== shareTestToken) throw new Error('Expected shareToken to match');
|
||||
});
|
||||
|
||||
await test('GET /api/quizzes/:id returns isShared and shareToken', async () => {
|
||||
if (!shareTestQuizId || !shareTestToken) throw new Error('No quiz or token');
|
||||
|
||||
const { data } = await request('GET', `/api/quizzes/${shareTestQuizId}`);
|
||||
const quiz = data as { isShared: boolean; shareToken: string | null };
|
||||
|
||||
if (quiz.isShared !== true) throw new Error('Expected isShared to be true');
|
||||
if (quiz.shareToken !== shareTestToken) throw new Error('Expected shareToken to match');
|
||||
});
|
||||
|
||||
await test('POST /api/quizzes/:id/share with non-existent ID returns 404', async () => {
|
||||
await request('POST', '/api/quizzes/non-existent-quiz-id/share', undefined, 404);
|
||||
});
|
||||
|
||||
await test('POST /api/quizzes/:id/share without auth returns 401', async () => {
|
||||
if (!shareTestQuizId) throw new Error('No quiz created');
|
||||
|
||||
const res = await fetch(`${API_URL}/api/quizzes/${shareTestQuizId}/share`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`);
|
||||
});
|
||||
|
||||
console.log('\nShared Quiz Public Access Tests:');
|
||||
|
||||
await test('GET /api/shared/:token returns quiz without auth', async () => {
|
||||
if (!shareTestToken) throw new Error('No share token');
|
||||
|
||||
const res = await fetch(`${API_URL}/api/shared/${shareTestToken}`);
|
||||
if (res.status !== 200) throw new Error(`Expected 200, got ${res.status}`);
|
||||
|
||||
const data = await res.json();
|
||||
if (data.title !== 'Sharing Test Quiz') throw new Error('Wrong title');
|
||||
if (data.source !== 'manual') throw new Error('Wrong source');
|
||||
if (!Array.isArray(data.questions)) throw new Error('Missing questions');
|
||||
if (data.questions.length !== 2) throw new Error(`Expected 2 questions, got ${data.questions.length}`);
|
||||
if (data.questionCount !== 2) throw new Error(`Expected questionCount 2, got ${data.questionCount}`);
|
||||
});
|
||||
|
||||
await test('GET /api/shared/:token returns gameConfig', async () => {
|
||||
if (!shareTestToken) throw new Error('No share token');
|
||||
|
||||
const res = await fetch(`${API_URL}/api/shared/${shareTestToken}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.gameConfig) throw new Error('Missing gameConfig');
|
||||
if (data.gameConfig.shuffleQuestions !== true) throw new Error('gameConfig not preserved');
|
||||
if (data.gameConfig.hostParticipates !== true) throw new Error('hostParticipates not preserved');
|
||||
});
|
||||
|
||||
await test('GET /api/shared/:token returns questions with all fields', async () => {
|
||||
if (!shareTestToken) throw new Error('No share token');
|
||||
|
||||
const res = await fetch(`${API_URL}/api/shared/${shareTestToken}`);
|
||||
const data = await res.json();
|
||||
|
||||
const q1 = data.questions[0];
|
||||
if (!q1.id) throw new Error('Missing question id');
|
||||
if (!q1.text) throw new Error('Missing question text');
|
||||
if (q1.timeLimit !== 20) throw new Error('Wrong timeLimit');
|
||||
if (!Array.isArray(q1.options)) throw new Error('Missing options');
|
||||
if (q1.options.length !== 4) throw new Error(`Expected 4 options, got ${q1.options.length}`);
|
||||
|
||||
const correctOption = q1.options.find((o: { isCorrect: boolean }) => o.isCorrect);
|
||||
if (!correctOption) throw new Error('No correct option found');
|
||||
if (correctOption.text !== 'Paris') throw new Error('Wrong correct answer');
|
||||
if (correctOption.reason !== 'Correct!') throw new Error('Reason not preserved');
|
||||
});
|
||||
|
||||
await test('GET /api/shared/:token with invalid token returns 404', async () => {
|
||||
const res = await fetch(`${API_URL}/api/shared/invalid-token-12345`);
|
||||
if (res.status !== 404) throw new Error(`Expected 404, got ${res.status}`);
|
||||
});
|
||||
|
||||
await test('GET /api/shared/:token with empty token returns 404', async () => {
|
||||
const res = await fetch(`${API_URL}/api/shared/`);
|
||||
if (res.status !== 404) throw new Error(`Expected 404, got ${res.status}`);
|
||||
});
|
||||
|
||||
console.log('\nCopy Shared Quiz Tests:');
|
||||
|
||||
await test('POST /api/shared/:token/copy without auth returns 401', async () => {
|
||||
if (!shareTestToken) throw new Error('No share token');
|
||||
|
||||
const res = await fetch(`${API_URL}/api/shared/${shareTestToken}/copy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`);
|
||||
});
|
||||
|
||||
await test('POST /api/shared/:token/copy with auth creates copy', async () => {
|
||||
if (!shareTestToken) throw new Error('No share token');
|
||||
|
||||
const { data } = await request('POST', `/api/shared/${shareTestToken}/copy`, undefined, 201);
|
||||
const result = data as { id: string; title: string };
|
||||
|
||||
if (!result.id) throw new Error('Missing id');
|
||||
if (!result.title) throw new Error('Missing title');
|
||||
// Title may have "(Copy)" appended if user already has quiz with same title
|
||||
if (!result.title.startsWith('Sharing Test Quiz')) throw new Error('Wrong title');
|
||||
|
||||
// Verify the copied quiz exists and has correct content
|
||||
const { data: copiedData } = await request('GET', `/api/quizzes/${result.id}`);
|
||||
const copiedQuiz = copiedData as { title: string; source: string; questions: unknown[]; gameConfig: unknown };
|
||||
|
||||
if (copiedQuiz.source !== 'manual') throw new Error('Source should be manual for copied quiz');
|
||||
if (!Array.isArray(copiedQuiz.questions)) throw new Error('Missing questions in copy');
|
||||
if (copiedQuiz.questions.length !== 2) throw new Error('Questions not copied correctly');
|
||||
if (!copiedQuiz.gameConfig) throw new Error('gameConfig not copied');
|
||||
|
||||
// Cleanup
|
||||
await request('DELETE', `/api/quizzes/${result.id}`, undefined, 204);
|
||||
});
|
||||
|
||||
await test('POST /api/shared/:token/copy copies all question data', async () => {
|
||||
if (!shareTestToken) throw new Error('No share token');
|
||||
|
||||
const { data } = await request('POST', `/api/shared/${shareTestToken}/copy`, undefined, 201);
|
||||
const result = data as { id: string };
|
||||
|
||||
const { data: copiedData } = await request('GET', `/api/quizzes/${result.id}`);
|
||||
const copiedQuiz = copiedData as {
|
||||
questions: Array<{
|
||||
text: string;
|
||||
timeLimit: number;
|
||||
options: Array<{ text: string; isCorrect: boolean; reason?: string }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
const q1 = copiedQuiz.questions[0];
|
||||
if (q1.text !== 'What is the capital of France?') throw new Error('Question text not copied');
|
||||
if (q1.timeLimit !== 20) throw new Error('timeLimit not copied');
|
||||
|
||||
const parisOption = q1.options.find((o) => o.text === 'Paris');
|
||||
if (!parisOption) throw new Error('Paris option not copied');
|
||||
if (!parisOption.isCorrect) throw new Error('isCorrect not copied');
|
||||
if (parisOption.reason !== 'Correct!') throw new Error('reason not copied');
|
||||
|
||||
await request('DELETE', `/api/quizzes/${result.id}`, undefined, 204);
|
||||
});
|
||||
|
||||
await test('POST /api/shared/:token/copy with invalid token returns 404', async () => {
|
||||
await request('POST', '/api/shared/invalid-token-xyz/copy', undefined, 404);
|
||||
});
|
||||
|
||||
console.log('\nUnshare Quiz Tests:');
|
||||
|
||||
await test('DELETE /api/quizzes/:id/share removes sharing', async () => {
|
||||
if (!shareTestQuizId) throw new Error('No quiz created');
|
||||
|
||||
const { data } = await request('DELETE', `/api/quizzes/${shareTestQuizId}/share`);
|
||||
const result = data as { success: boolean };
|
||||
|
||||
if (!result.success) throw new Error('Expected success: true');
|
||||
});
|
||||
|
||||
await test('GET /api/quizzes/:id shows isShared=false after unsharing', async () => {
|
||||
if (!shareTestQuizId) throw new Error('No quiz created');
|
||||
|
||||
const { data } = await request('GET', `/api/quizzes/${shareTestQuizId}`);
|
||||
const quiz = data as { isShared: boolean; shareToken: string | null };
|
||||
|
||||
if (quiz.isShared !== false) throw new Error('Expected isShared to be false');
|
||||
if (quiz.shareToken !== null) throw new Error('Expected shareToken to be null');
|
||||
});
|
||||
|
||||
await test('GET /api/shared/:token returns 404 after unsharing', async () => {
|
||||
if (!shareTestToken) throw new Error('No share token');
|
||||
|
||||
const res = await fetch(`${API_URL}/api/shared/${shareTestToken}`);
|
||||
if (res.status !== 404) throw new Error(`Expected 404, got ${res.status}`);
|
||||
});
|
||||
|
||||
await test('POST /api/shared/:token/copy returns 404 after unsharing', async () => {
|
||||
if (!shareTestToken) throw new Error('No share token');
|
||||
|
||||
await request('POST', `/api/shared/${shareTestToken}/copy`, undefined, 404);
|
||||
});
|
||||
|
||||
await test('DELETE /api/quizzes/:id/share with non-existent ID returns 404', async () => {
|
||||
await request('DELETE', '/api/quizzes/non-existent-quiz-id/share', undefined, 404);
|
||||
});
|
||||
|
||||
await test('DELETE /api/quizzes/:id/share without auth returns 401', async () => {
|
||||
if (!shareTestQuizId) throw new Error('No quiz created');
|
||||
|
||||
const res = await fetch(`${API_URL}/api/quizzes/${shareTestQuizId}/share`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (res.status !== 401) throw new Error(`Expected 401, got ${res.status}`);
|
||||
});
|
||||
|
||||
console.log('\nRe-share Quiz Tests:');
|
||||
|
||||
await test('POST /api/quizzes/:id/share generates new token after unshare', async () => {
|
||||
if (!shareTestQuizId) throw new Error('No quiz created');
|
||||
|
||||
const { data } = await request('POST', `/api/quizzes/${shareTestQuizId}/share`);
|
||||
const result = data as { shareToken: string };
|
||||
|
||||
if (!result.shareToken) throw new Error('Missing shareToken');
|
||||
if (result.shareToken === shareTestToken) throw new Error('Should generate new token');
|
||||
|
||||
// Update our reference to the new token
|
||||
shareTestToken = result.shareToken;
|
||||
});
|
||||
|
||||
await test('GET /api/shared/:token works with new token', async () => {
|
||||
if (!shareTestToken) throw new Error('No share token');
|
||||
|
||||
const res = await fetch(`${API_URL}/api/shared/${shareTestToken}`);
|
||||
if (res.status !== 200) throw new Error(`Expected 200, got ${res.status}`);
|
||||
});
|
||||
|
||||
console.log('\nShare Edge Cases:');
|
||||
|
||||
await test('Unshare and re-share does not affect quiz content', async () => {
|
||||
if (!shareTestQuizId || !shareTestToken) throw new Error('No quiz or token');
|
||||
|
||||
// Get original content
|
||||
const { data: original } = await request('GET', `/api/quizzes/${shareTestQuizId}`);
|
||||
const originalQuiz = original as { title: string; questions: unknown[] };
|
||||
|
||||
// Unshare
|
||||
await request('DELETE', `/api/quizzes/${shareTestQuizId}/share`);
|
||||
|
||||
// Re-share
|
||||
const { data: shareData } = await request('POST', `/api/quizzes/${shareTestQuizId}/share`);
|
||||
shareTestToken = (shareData as { shareToken: string }).shareToken;
|
||||
|
||||
// Verify content unchanged
|
||||
const { data: afterData } = await request('GET', `/api/quizzes/${shareTestQuizId}`);
|
||||
const afterQuiz = afterData as { title: string; questions: unknown[] };
|
||||
|
||||
if (originalQuiz.title !== afterQuiz.title) throw new Error('Title changed');
|
||||
if (JSON.stringify(originalQuiz.questions) !== JSON.stringify(afterQuiz.questions)) {
|
||||
throw new Error('Questions changed');
|
||||
}
|
||||
});
|
||||
|
||||
await test('DELETE quiz also removes from shared access', async () => {
|
||||
// Create a new quiz to share and delete
|
||||
const quiz = {
|
||||
title: 'Delete Shared Quiz Test',
|
||||
source: 'manual',
|
||||
questions: [
|
||||
{
|
||||
text: 'Test?',
|
||||
options: [
|
||||
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
|
||||
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { data: createData } = await request('POST', '/api/quizzes', quiz, 201);
|
||||
const quizId = (createData as { id: string }).id;
|
||||
|
||||
// Share it
|
||||
const { data: shareData } = await request('POST', `/api/quizzes/${quizId}/share`);
|
||||
const token = (shareData as { shareToken: string }).shareToken;
|
||||
|
||||
// Verify it's accessible
|
||||
let res = await fetch(`${API_URL}/api/shared/${token}`);
|
||||
if (res.status !== 200) throw new Error('Shared quiz should be accessible before delete');
|
||||
|
||||
// Delete the quiz
|
||||
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
|
||||
|
||||
// Verify shared link no longer works
|
||||
res = await fetch(`${API_URL}/api/shared/${token}`);
|
||||
if (res.status !== 404) throw new Error(`Expected 404 after delete, got ${res.status}`);
|
||||
});
|
||||
|
||||
await test('DELETE cleanup sharing test quiz', async () => {
|
||||
if (shareTestQuizId) {
|
||||
await request('DELETE', `/api/quizzes/${shareTestQuizId}`, undefined, 204);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n=== Results ===');
|
||||
const passed = results.filter((r) => r.passed).length;
|
||||
const failed = results.filter((r) => !r.passed).length;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue