Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ jobs:
- name: Test ws-server
run: pnpm --filter ws-server run test

# The auth middleware tests sign real tokens and need no services.
- name: Test http-server
run: pnpm --filter http-server run test

- name: Test frontend
run: pnpm --filter frontend run test
# Who may join a video room, speak or moderate. Postgres and the
Expand Down
51 changes: 51 additions & 0 deletions apps/frontend/src/screens/QuizBuilder.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,57 @@ describe('QuizBuilder AI integration', () => {
)
);
});

async function fillManualQuestion(user: ReturnType<typeof renderBuilder>, secs: string) {
await screen.findByText('What is ATP?');
await user.click(screen.getByRole('button', { name: /add question/i }));
await user.type(screen.getByPlaceholderText('Add Question Here...'), 'Manual?');
for (const i of [1, 2, 3, 4]) {
await user.type(screen.getByPlaceholderText(`Answer ${i}`), `A${i}`);
}
await user.clear(screen.getByPlaceholderText('Secs'));
if (secs) await user.type(screen.getByPlaceholderText('Secs'), secs);
await user.click(screen.getByRole('button', { name: /save question/i }));
}

it.each(['-5', '0', '4', '301', '99999999999', '1.5', 'abc'])(
'does not send a question with %s seconds',
async (secs) => {
const user = renderBuilder();
await fillManualQuestion(user, secs);

expect(await screen.findByRole('alert')).toHaveTextContent(
'Seconds must be a whole number from 5 to 300'
);
expect(api.post).not.toHaveBeenCalled();
}
);

it('falls back to 15 seconds when the field is left empty', async () => {
const user = renderBuilder();
await fillManualQuestion(user, '');

await waitFor(() =>
expect(api.post).toHaveBeenCalledWith(
'/quizzes/quiz-1/questions',
expect.objectContaining({ timeLimit: 15 })
)
);
});

it('shows the server error in the form and keeps it open when saving fails', async () => {
vi.mocked(api.post).mockRejectedValueOnce({
response: { data: { error: 'timeLimit must be a whole number from 5 to 300' } },
});

const user = renderBuilder();
await fillManualQuestion(user, '20');

expect(await screen.findByRole('alert')).toHaveTextContent(
'timeLimit must be a whole number from 5 to 300'
);
expect(screen.getByPlaceholderText('Add Question Here...')).toHaveValue('Manual?');
});
});

describe('QuizBuilder question deletion', () => {
Expand Down
27 changes: 23 additions & 4 deletions apps/frontend/src/screens/QuizBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@

const DIFFICULTIES: Difficulty[] = ['Low', 'Medium', 'High'];

// Same bounds the http-server enforces for a question's time limit.
const MIN_TIME = 5;
const MAX_TIME = 300;


interface QuizQuestion {
id: string;
Expand Down Expand Up @@ -39,6 +43,7 @@
const [qText, setQText] = useState('');
const [qTime, setQTime] = useState('15');
const [qDifficulty, setQDifficulty] = useState<Difficulty>('Medium');
const [saveError, setSaveError] = useState<string>('');
const [answers, setAnswers] = useState([{ text: '', isCorrect: true }, { text: '', isCorrect: false }, { text: '', isCorrect: false }, { text: '', isCorrect: false }]);

const [inviteEmail, setInviteEmail] = useState('');
Expand All @@ -58,7 +63,7 @@
if (id) {
fetchQuiz();
}
}, [id]);

Check warning on line 66 in apps/frontend/src/screens/QuizBuilder.tsx

View workflow job for this annotation

GitHub Actions / Lint & type-check

React Hook useEffect has a missing dependency: 'fetchQuiz'. Either include it or remove the dependency array


const fetchQuiz = async () => {
Expand Down Expand Up @@ -150,10 +155,16 @@
if (!qText.trim() || answers.some(a => !a.text.trim())) return alert('Fill all text fields');
if (!answers.some(a => a.isCorrect)) return alert('Select at least one correct answer');

const seconds = qTime.trim() === '' ? 15 : Number(qTime);
if (!Number.isInteger(seconds) || seconds < MIN_TIME || seconds > MAX_TIME) {
return setSaveError(`Seconds must be a whole number from ${MIN_TIME} to ${MAX_TIME}`);
}

try {
setSaveError('');
await api.post(`/quizzes/${id}/questions`, {
text: qText,
timeLimit: parseInt(qTime) || 15,
timeLimit: seconds,
difficulty: qDifficulty,
answers
});
Expand All @@ -163,8 +174,10 @@
setQDifficulty('Medium');
setAnswers([{ text: '', isCorrect: true }, { text: '', isCorrect: false }, { text: '', isCorrect: false }, { text: '', isCorrect: false }]);
fetchQuiz();
} catch (err) {
console.error(err);
} catch (err: unknown) {
console.error('Failed to save question', err);
const resp = (err as { response?: { data?: { error?: string } } }).response;
setSaveError(resp?.data?.error || 'Failed to save question');
}
};

Expand Down Expand Up @@ -335,8 +348,14 @@
))}
</div>

{saveError && (
<p className="text-sm text-rose-400 mt-6 font-secondary font-medium" role="alert">
{saveError}
</p>
)}

<div className="mt-10 flex justify-end gap-5">
<button onClick={() => setShowAddQ(false)} className="px-6 py-2 text-zinc-400 hover:text-zinc-300 bg-rose-800/10 hover:bg-rose-800/20 rounded-xl border active:scale-95 border-rose-600/40 cursor-pointer">Cancel</button>
<button onClick={() => { setShowAddQ(false); setSaveError(''); }} className="px-6 py-2 text-zinc-400 hover:text-zinc-300 bg-rose-800/10 hover:bg-rose-800/20 rounded-xl border active:scale-95 border-rose-600/40 cursor-pointer">Cancel</button>
<button onClick={handleSaveQuestion} className="btn-primary flex items-center gap-2 bg-green-600/10 hover:bg-green-600/20 border border-green-600/30 active:scale-95 text-gray-300 cursor-pointer px-4 rounded-xl py-2"><BiSave className="w-4 h-4" /> Save Question</button>
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions apps/http-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "vitest run",
"build": "tsc -b",
"dev": "tsc -b && node dist/index.js",
"start": "node dist/index.js"
Expand Down Expand Up @@ -38,6 +39,7 @@
"ts-node-dev": "^2.0.0",
"tsup": "^8.5.1",
"typescript": "^6.0.2",
"vitest": "^5.0.2",
"wait-on": "^9.0.4"
}
}
75 changes: 47 additions & 28 deletions apps/http-server/src/controllers/quizController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { Request, Response } from 'express';
import { errorResponse } from '../utils/error';
import { prisma } from '@repo/db';
import { generateCode } from '../utils/code';

const DIFFICULTIES = ['Low', 'Medium', 'High'];
import { parseCreateQuestion } from '../utils/questionInput';
import { renumberQuestions } from '../utils/questionOrder';

export const createQuizController = async (req: Request, res: Response) => {
try {
Expand Down Expand Up @@ -224,11 +224,11 @@ export const generateAccessCodeController = async (req: Request, res: Response)
export const createQuestionController = async (req: Request, res: Response) => {
try {
const { quizId } = req.params;
const { text, timeLimit, answers, difficulty = "Medium" } = req.body;

if (!text || !timeLimit || !answers) return errorResponse(res, 400, "Text, time limit and answers are required")
const parsed = parseCreateQuestion(req.body);
if (!parsed.ok) return errorResponse(res, 400, parsed.error);

if (!DIFFICULTIES.includes(difficulty)) return errorResponse(res, 400, "Difficulty must be Low, Medium or High")
const { text, timeLimit, answers, difficulty } = parsed.data;

const isOrganizer = await prisma.quizOrganizer.findFirst({
where: {
Expand All @@ -240,30 +240,34 @@ export const createQuestionController = async (req: Request, res: Response) => {

if (!isOrganizer) return errorResponse(res, 403, "You don't have access to this quiz");

const totalQuestions = await prisma.question.count({
where: {
quizId: quizId as string
}
});
// The quiz row is locked so two requests adding (or deleting) at the
// same time cannot both read the same max(order).
const question = await prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT "id" FROM "Quiz" WHERE "id" = ${quizId as string} FOR UPDATE`;

const last = await tx.question.aggregate({
where: { quizId: quizId as string },
_max: { order: true }
});

const question = await prisma.question.create({
data: {
text,
timeLimit: timeLimit || 15,
order: totalQuestions + 1,
difficulty,
quizId: quizId as string,
answers: {
create: answers.map((answer: any) => ({
text: answer.text,
isCorrect: answer.isCorrect
}))
return tx.question.create({
data: {
text,
timeLimit,
order: (last._max.order ?? 0) + 1,
difficulty,
quizId: quizId as string,
answers: {
create: answers.map((answer) => ({
text: answer.text,
isCorrect: answer.isCorrect
}))
}
},
include: {
answers: true
}
},
include: {
answers: true
}
});
});

return res.status(201).json(question);
Expand Down Expand Up @@ -403,8 +407,23 @@ export const deleteQuestionController = async (req: Request, res: Response) => {
return errorResponse(res, 403, "You don't have access to this quiz");
}

await prisma.question.delete({
where: { id: questionId as string }
// Same quiz lock as createQuestionController, then close the gap so
// the orders stay 1..n and the next question does not tie with one.
await prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT "id" FROM "Quiz" WHERE "id" = ${question.quizId} FOR UPDATE`;

await tx.question.delete({
where: { id: questionId as string }
});

const remaining = await tx.question.findMany({
where: { quizId: question.quizId },
select: { id: true, order: true }
});

for (const { id, order } of renumberQuestions(remaining)) {
await tx.question.update({ where: { id }, data: { order } });
}
});

return res.status(200).json({
Expand Down
78 changes: 78 additions & 0 deletions apps/http-server/src/utils/questionInput.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import { parseCreateQuestion } from './questionInput';

const answers = [
{ text: 'a', isCorrect: true },
{ text: 'b', isCorrect: false },
];

const valid = { text: 'Capital of France?', timeLimit: 15, answers };

const errorFor = (body: unknown) => {
const result = parseCreateQuestion(body);
return result.ok ? null : result.error;
};

describe('parseCreateQuestion', () => {
it('accepts a normal question and defaults difficulty to Medium', () => {
const result = parseCreateQuestion(valid);
expect(result.ok).toBe(true);
if (result.ok) expect(result.data.difficulty).toBe('Medium');
});

it('accepts what the form and the AI generator send', () => {
const four = ['a', 'b', 'c', 'd'].map((text, i) => ({ text, isCorrect: i === 2 }));
expect(parseCreateQuestion({ ...valid, difficulty: 'High', answers: four }).ok).toBe(true);
});

it('accepts the time limit bounds and six answers', () => {
const six = ['a', 'b', 'c', 'd', 'e', 'f'].map((text, i) => ({ text, isCorrect: i === 0 }));
expect(parseCreateQuestion({ ...valid, timeLimit: 5, answers: six }).ok).toBe(true);
expect(parseCreateQuestion({ ...valid, timeLimit: 300 }).ok).toBe(true);
});

it('rejects an empty or non-string text', () => {
expect(errorFor({ ...valid, text: ' ' })).toMatch(/^text /);
expect(errorFor({ ...valid, text: 42 })).toMatch(/^text /);
expect(errorFor({ timeLimit: 15, answers })).toMatch(/^text /);
});

it.each([-5, 0, 4, 301, 99999999999, 10.5, '10', null])('rejects timeLimit %j', (timeLimit) => {
expect(errorFor({ ...valid, timeLimit })).toMatch(/^timeLimit must be a whole number from 5 to 300/);
});

it('rejects answers that are missing, not an array, empty or too few', () => {
expect(errorFor({ text: 'x', timeLimit: 10 })).toMatch(/^answers /);
expect(errorFor({ ...valid, answers: 'nope' })).toMatch(/^answers /);
expect(errorFor({ ...valid, answers: [] })).toMatch(/^answers /);
expect(errorFor({ ...valid, answers: [answers[0]] })).toMatch(/^answers /);
});

it('rejects more than six answers', () => {
const seven = Array.from({ length: 7 }, (_, i) => ({ text: `a${i}`, isCorrect: i === 0 }));
expect(errorFor({ ...valid, answers: seven })).toMatch(/^answers /);
});

it('rejects answers with no correct option', () => {
const none = answers.map((a) => ({ ...a, isCorrect: false }));
expect(errorFor({ ...valid, answers: none })).toBe('answers needs at least one correct option');
});

it('allows more than one correct option', () => {
const both = answers.map((a) => ({ ...a, isCorrect: true }));
expect(parseCreateQuestion({ ...valid, answers: both }).ok).toBe(true);
});

it('names the answer that is malformed', () => {
expect(errorFor({ ...valid, answers: [answers[0], { text: ' ', isCorrect: false }] })).toMatch(/^answers\.1\.text /);
expect(errorFor({ ...valid, answers: [answers[0], { text: 'b', isCorrect: 'no' }] })).toMatch(/^answers\.1\.isCorrect /);
});

it('rejects an unknown difficulty', () => {
expect(errorFor({ ...valid, difficulty: 'Extreme' })).toMatch(/^difficulty /);
});

it('rejects a missing body', () => {
expect(parseCreateQuestion(undefined).ok).toBe(false);
});
});
50 changes: 50 additions & 0 deletions apps/http-server/src/utils/questionInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { z } from 'zod';

export const DIFFICULTIES = ['Low', 'Medium', 'High'] as const;

// Same bounds the AI question generator already enforces on the frontend.
export const MIN_TIME_LIMIT = 5;
export const MAX_TIME_LIMIT = 300;
export const MIN_ANSWERS = 2;
export const MAX_ANSWERS = 6;

const nonEmptyString = z
.string('must be a non-empty string')
.refine((value) => value.trim().length > 0, 'must be a non-empty string');

const timeLimitMessage = `must be a whole number from ${MIN_TIME_LIMIT} to ${MAX_TIME_LIMIT}`;
const answersMessage = `must be an array of ${MIN_ANSWERS} to ${MAX_ANSWERS} options`;

const answerSchema = z.object({
text: nonEmptyString,
isCorrect: z.boolean('must be true or false'),
});

export const createQuestionSchema = z.object({
text: nonEmptyString,
timeLimit: z
.number(timeLimitMessage)
.int(timeLimitMessage)
.min(MIN_TIME_LIMIT, timeLimitMessage)
.max(MAX_TIME_LIMIT, timeLimitMessage),
difficulty: z.enum(DIFFICULTIES, 'must be Low, Medium or High').default('Medium'),
answers: z
.array(answerSchema, answersMessage)
.min(MIN_ANSWERS, answersMessage)
.max(MAX_ANSWERS, answersMessage)
.refine((answers) => answers.some((answer) => answer.isCorrect), 'needs at least one correct option'),
});

export type CreateQuestionInput = z.infer<typeof createQuestionSchema>;

// One message for the 400 response, starting with the field that failed.
export function parseCreateQuestion(body: unknown):
| { ok: true; data: CreateQuestionInput }
| { ok: false; error: string } {
const result = createQuestionSchema.safeParse(body);
if (result.success) return { ok: true, data: result.data };

const issue = result.error.issues[0];
const field = issue?.path.join('.');
return { ok: false, error: field ? `${field} ${issue?.message}` : (issue?.message ?? 'Invalid question') };
}
Loading
Loading