mirror of
https://github.com/OHTU-German-learning-website/OHTU-German-learning-website.git
synced 2026-10-12 19:13:51 +03:00
fixed 2 bugs that was left for the new team
This commit is contained in:
@@ -3,10 +3,9 @@
|
|||||||
import { Column, Row } from "@/components/ui/layout/container";
|
import { Column, Row } from "@/components/ui/layout/container";
|
||||||
import useQuery from "@/shared/hooks/useQuery";
|
import useQuery from "@/shared/hooks/useQuery";
|
||||||
import { useRequest } from "@/shared/hooks/useRequest";
|
import { useRequest } from "@/shared/hooks/useRequest";
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useIsSuperAdmin, useUser } from "@/context/user.context";
|
import { useUser } from "@/context/user.context";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UsersPage
|
* UsersPage
|
||||||
@@ -19,30 +18,24 @@ import { useRouter } from "next/navigation";
|
|||||||
* - only superadmins may access it
|
* - only superadmins may access it
|
||||||
*/
|
*/
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const router = useRouter();
|
|
||||||
const { auth } = useUser();
|
const { auth } = useUser();
|
||||||
const isSuperAdmin = useIsSuperAdmin();
|
const canViewUsers =
|
||||||
|
auth.isAuthResolved && auth.isLoggedIn && auth.user?.is_superadmin;
|
||||||
|
|
||||||
const { data: users, error, isLoading, refetch } = useQuery("/admin/users");
|
const {
|
||||||
|
data: users,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
} = useQuery("/admin/users", undefined, {
|
||||||
|
enabled: canViewUsers,
|
||||||
|
});
|
||||||
|
|
||||||
const makeRequest = useRequest();
|
const makeRequest = useRequest();
|
||||||
const [updatingId, setUpdatingId] = useState(null);
|
const [updatingId, setUpdatingId] = useState(null);
|
||||||
const [deletingId, setDeletingId] = useState(null);
|
const [deletingId, setDeletingId] = useState(null);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
/**
|
|
||||||
* Redirect unauthorized users away from this page.
|
|
||||||
*
|
|
||||||
* The `useIsSuperAdmin` hook already performs route protection logic,
|
|
||||||
* but this extra redirect keeps the page behavior explicit and prevents
|
|
||||||
* the user from remaining on `/admin/users` after losing access.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSuperAdmin === false) {
|
|
||||||
router.replace("/");
|
|
||||||
}
|
|
||||||
}, [isSuperAdmin, router]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter users by username or email.
|
* Filter users by username or email.
|
||||||
*
|
*
|
||||||
@@ -62,11 +55,15 @@ export default function UsersPage() {
|
|||||||
});
|
});
|
||||||
}, [users, search]);
|
}, [users, search]);
|
||||||
|
|
||||||
// Wait until the superadmin check has resolved before rendering the page.
|
// Wait until the auth check has resolved before rendering the page.
|
||||||
if (isSuperAdmin === undefined) {
|
if (!auth.isAuthResolved) {
|
||||||
return <p>Wird geladen...</p>;
|
return <p>Wird geladen...</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!auth.isLoggedIn || !auth.user?.is_superadmin) {
|
||||||
|
return <p className="error">Sie haben keinen Zugriff auf diese Seite.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <p>Wird geladen...</p>;
|
return <p>Wird geladen...</p>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
|
||||||
export async function GET(request, { params }) {
|
export const GET = withAuth(async (request, { params }) => {
|
||||||
const { dnd_id } = await params;
|
const { dnd_id } = await params;
|
||||||
|
|
||||||
if (!dnd_id || isNaN(parseInt(dnd_id, 10))) {
|
if (!dnd_id || isNaN(parseInt(dnd_id, 10))) {
|
||||||
@@ -64,4 +65,4 @@ export async function GET(request, { params }) {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ describe("freeform answers", () => {
|
|||||||
expect(result.similarity).toBe(1);
|
expect(result.similarity).toBe(1);
|
||||||
expect(result.perfectAnswer).toBe(true);
|
expect(result.perfectAnswer).toBe(true);
|
||||||
expect(result.feedback).toBe("Great job!");
|
expect(result.feedback).toBe("Great job!");
|
||||||
|
expect(result.answer).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should evaluate similar but not exact answers", async () => {
|
it("should evaluate similar but not exact answers", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { POST } from "./route";
|
||||||
|
|
||||||
|
vi.mock("@/backend/auth/session", () => ({
|
||||||
|
checkSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/backend/db", () => ({
|
||||||
|
DB: {
|
||||||
|
pool: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { checkSession } from "@/backend/auth/session";
|
||||||
|
import { DB } from "@/backend/db";
|
||||||
|
|
||||||
|
function createRequest(body) {
|
||||||
|
return {
|
||||||
|
url: "http://localhost/api/exercises/freeform/answers",
|
||||||
|
headers: new Headers({ "content-type": "application/json" }),
|
||||||
|
method: "POST",
|
||||||
|
cache: "default",
|
||||||
|
credentials: "same-origin",
|
||||||
|
integrity: "",
|
||||||
|
mode: "cors",
|
||||||
|
redirect: "follow",
|
||||||
|
json: async () => body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("freeform answer response shape", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not echo the canonical answer", async () => {
|
||||||
|
checkSession.mockResolvedValue({ id: 7, is_admin: false });
|
||||||
|
DB.pool
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
answer: "This is the correct answer",
|
||||||
|
is_correct: true,
|
||||||
|
feedback: "Great job!",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ rows: [] });
|
||||||
|
|
||||||
|
const response = await POST(
|
||||||
|
createRequest({
|
||||||
|
freeFormExerciseId: "12",
|
||||||
|
freeFormQuestionId: "34",
|
||||||
|
answer: "This is the correct answer",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
expect(result.answer).toBeUndefined();
|
||||||
|
expect(result.is_correct).toBe(true);
|
||||||
|
expect(result.perfectAnswer).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -260,7 +260,6 @@ export const POST = withAuth(
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
is_correct: false,
|
is_correct: false,
|
||||||
answer: "",
|
|
||||||
feedback:
|
feedback:
|
||||||
"Your answer doesn't match any of the expected answers. Please try again.",
|
"Your answer doesn't match any of the expected answers. Please try again.",
|
||||||
similarity: bestMatch.similarity,
|
similarity: bestMatch.similarity,
|
||||||
@@ -307,7 +306,6 @@ export const POST = withAuth(
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
is_correct: isAnswerCorrect,
|
is_correct: isAnswerCorrect,
|
||||||
answer: bestMatch.answer,
|
|
||||||
similarity: bestMatch.similarity,
|
similarity: bestMatch.similarity,
|
||||||
perfectAnswer,
|
perfectAnswer,
|
||||||
comparisonDetails,
|
comparisonDetails,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { jumbledSentenceExerciseSchema } from "@/shared/schemas/jumbled-sentence.schemas";
|
import { jumbledSentenceExerciseSchema } from "@/shared/schemas/jumbled-sentence.schemas";
|
||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
|
||||||
export async function GET(request, { params }) {
|
export const GET = withAuth(async (request, { params }) => {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
try {
|
try {
|
||||||
const exRes = await DB.pool(
|
const exRes = await DB.pool(
|
||||||
@@ -29,65 +30,71 @@ export async function GET(request, { params }) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
export async function PUT(request, { params }) {
|
export const PUT = withAuth(
|
||||||
const { id } = await params;
|
async (request, { params }) => {
|
||||||
const body = await request.json();
|
const { id } = await params;
|
||||||
const parseResult = jumbledSentenceExerciseSchema.safeParse(body);
|
const body = await request.json();
|
||||||
if (!parseResult.success) {
|
const parseResult = jumbledSentenceExerciseSchema.safeParse(body);
|
||||||
return NextResponse.json(
|
if (!parseResult.success) {
|
||||||
{ error: parseResult.error.errors },
|
return NextResponse.json(
|
||||||
{ status: 400 }
|
{ error: parseResult.error.errors },
|
||||||
);
|
{ status: 400 }
|
||||||
}
|
|
||||||
const { title, sentences } = body;
|
|
||||||
try {
|
|
||||||
await DB.transaction(async (client) => {
|
|
||||||
await client.query(
|
|
||||||
`UPDATE jumbled_sentence_exercises SET title = $1, updated_at = NOW() WHERE id = $2`,
|
|
||||||
[title, id]
|
|
||||||
);
|
);
|
||||||
await client.query(
|
}
|
||||||
`DELETE FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1`,
|
const { title, sentences } = body;
|
||||||
[id]
|
try {
|
||||||
);
|
await DB.transaction(async (client) => {
|
||||||
for (const s of sentences) {
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO jumbled_sentence_sentences (jumbled_exercise_id, sentence, alternates, correct_feedback, alternate_feedbacks, incorrect_feedbacks, incorrect_alternates) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
`UPDATE jumbled_sentence_exercises SET title = $1, updated_at = NOW() WHERE id = $2`,
|
||||||
[
|
[title, id]
|
||||||
id,
|
|
||||||
s.sentence,
|
|
||||||
s.alternates?.filter(Boolean) || [],
|
|
||||||
s.correctSentenceFeedback || null,
|
|
||||||
s.alternateFeedbacks || [],
|
|
||||||
s.incorrectFeedbacks || [],
|
|
||||||
s.incorrectAlternates || [],
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
}
|
await client.query(
|
||||||
});
|
`DELETE FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1`,
|
||||||
return NextResponse.json({ success: true });
|
[id]
|
||||||
} catch (err) {
|
);
|
||||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
for (const s of sentences) {
|
||||||
}
|
await client.query(
|
||||||
}
|
`INSERT INTO jumbled_sentence_sentences (jumbled_exercise_id, sentence, alternates, correct_feedback, alternate_feedbacks, incorrect_feedbacks, incorrect_alternates) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
s.sentence,
|
||||||
|
s.alternates?.filter(Boolean) || [],
|
||||||
|
s.correctSentenceFeedback || null,
|
||||||
|
s.alternateFeedbacks || [],
|
||||||
|
s.incorrectFeedbacks || [],
|
||||||
|
s.incorrectAlternates || [],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ requireAdmin: true }
|
||||||
|
);
|
||||||
|
|
||||||
export async function DELETE(request, { params }) {
|
export const DELETE = withAuth(
|
||||||
const { id } = await params;
|
async (request, { params }) => {
|
||||||
try {
|
const { id } = await params;
|
||||||
await DB.transaction(async (client) => {
|
try {
|
||||||
await client.query(
|
await DB.transaction(async (client) => {
|
||||||
`DELETE FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1`,
|
await client.query(
|
||||||
[id]
|
`DELETE FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1`,
|
||||||
);
|
[id]
|
||||||
await client.query(
|
);
|
||||||
`DELETE FROM jumbled_sentence_exercises WHERE id = $1`,
|
await client.query(
|
||||||
[id]
|
`DELETE FROM jumbled_sentence_exercises WHERE id = $1`,
|
||||||
);
|
[id]
|
||||||
});
|
);
|
||||||
return NextResponse.json({ success: true });
|
});
|
||||||
} catch (err) {
|
return NextResponse.json({ success: true });
|
||||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
} catch (err) {
|
||||||
}
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{ requireAdmin: true }
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
|
||||||
export async function GET(request, { params }) {
|
export const GET = withAuth(async (request, { params }) => {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
if (!id || Number.isNaN(Number(id))) {
|
if (!id || Number.isNaN(Number(id))) {
|
||||||
@@ -48,4 +49,4 @@ export async function GET(request, { params }) {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { DB } from "@/backend/db";
|
||||||
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
|
||||||
|
export const POST = withAuth(async (request, { params }) => {
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const answers = Array.isArray(body?.answers) ? body.answers : [];
|
||||||
|
|
||||||
|
if (!id || Number.isNaN(Number(id))) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid exercise ID." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (answers.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Answers are required." },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows: contentRows } = await DB.pool(
|
||||||
|
`SELECT id, content_type, correct_answer
|
||||||
|
FROM multichoice_content
|
||||||
|
WHERE multichoice_exercise_id = $1
|
||||||
|
ORDER BY content_order`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (contentRows.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Exercise not found." },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const answerMap = new Map(
|
||||||
|
answers
|
||||||
|
.filter((entry) => entry && entry.contentId != null)
|
||||||
|
.map((entry) => [String(entry.contentId), String(entry.answer || "")])
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = contentRows
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.content_type === "multichoice" || item.content_type === "gap"
|
||||||
|
)
|
||||||
|
.map((item) => {
|
||||||
|
const submitted = (answerMap.get(String(item.id)) || "").trim();
|
||||||
|
const expected = String(item.correct_answer || "").trim();
|
||||||
|
const isCorrect =
|
||||||
|
submitted.length > 0 &&
|
||||||
|
submitted.toLowerCase() === expected.toLowerCase();
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentId: item.id,
|
||||||
|
isCorrect,
|
||||||
|
answered: submitted.length > 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasMissing = results.some((entry) => !entry.answered);
|
||||||
|
const allCorrect =
|
||||||
|
results.length > 0 && results.every((entry) => entry.isCorrect);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
results,
|
||||||
|
hasMissing,
|
||||||
|
allCorrect,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error validating multichoice answers:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
|
||||||
export async function GET(request, { params }) {
|
export const GET = withAuth(async (request, { params }) => {
|
||||||
// --- FIX IS HERE ---
|
|
||||||
// In Next.js 15, params is a Promise, so you must await it.
|
// In Next.js 15, params is a Promise, so you must await it.
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
@@ -23,7 +23,15 @@ export async function GET(request, { params }) {
|
|||||||
|
|
||||||
// Fetch exercise content
|
// Fetch exercise content
|
||||||
const contentResult = await DB.pool(
|
const contentResult = await DB.pool(
|
||||||
"SELECT * FROM multichoice_content WHERE multichoice_exercise_id = $1 ORDER BY content_order",
|
`SELECT
|
||||||
|
id,
|
||||||
|
multichoice_exercise_id,
|
||||||
|
content_type,
|
||||||
|
content_value,
|
||||||
|
content_order
|
||||||
|
FROM multichoice_content
|
||||||
|
WHERE multichoice_exercise_id = $1
|
||||||
|
ORDER BY content_order`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
const content = contentResult.rows;
|
const content = contentResult.rows;
|
||||||
@@ -61,4 +69,4 @@ export async function GET(request, { params }) {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
|
|
||||||
export async function GET() {
|
export const GET = withAuth(async () => {
|
||||||
try {
|
try {
|
||||||
// Fetch multichoice exercises
|
// Fetch multichoice exercises
|
||||||
const { rows: exercises } = await DB.pool(`
|
const { rows: exercises } = await DB.pool(`
|
||||||
@@ -30,8 +31,7 @@ export async function GET() {
|
|||||||
mc.multichoice_exercise_id,
|
mc.multichoice_exercise_id,
|
||||||
mc.content_type,
|
mc.content_type,
|
||||||
mc.content_value,
|
mc.content_value,
|
||||||
mc.content_order,
|
mc.content_order
|
||||||
mc.correct_answer
|
|
||||||
FROM
|
FROM
|
||||||
multichoice_content mc
|
multichoice_content mc
|
||||||
WHERE
|
WHERE
|
||||||
@@ -83,4 +83,4 @@ export async function GET() {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { checkSession } from "@/backend/auth/session";
|
||||||
|
import { GET as getClick } from "./click/route";
|
||||||
|
import { GET as getDragdrop } from "./dragdrop/[dnd_id]/route";
|
||||||
|
import { GET as getMultichoiceList } from "./multichoice/route";
|
||||||
|
import { GET as getMultichoiceDetail } from "./multichoice/[id]/route";
|
||||||
|
import { GET as getMemoryGame } from "./memory-game/[id]/route";
|
||||||
|
import {
|
||||||
|
GET as getJumbledSentence,
|
||||||
|
PUT as putJumbledSentence,
|
||||||
|
DELETE as deleteJumbledSentence,
|
||||||
|
} from "./jumbled-sentence/[id]/route";
|
||||||
|
|
||||||
|
vi.mock("@/backend/auth/session", () => ({
|
||||||
|
checkSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("exercise API auth guards", () => {
|
||||||
|
it("rejects unauthenticated access to answer-bearing routes", async () => {
|
||||||
|
checkSession.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
[getClick, {}],
|
||||||
|
[getDragdrop, { params: Promise.resolve({ dnd_id: "1" }) }],
|
||||||
|
[getMultichoiceList, {}],
|
||||||
|
[getMultichoiceDetail, { params: Promise.resolve({ id: "1" }) }],
|
||||||
|
[getMemoryGame, { params: Promise.resolve({ id: "1" }) }],
|
||||||
|
[getJumbledSentence, { params: Promise.resolve({ id: "1" }) }],
|
||||||
|
[putJumbledSentence, { params: Promise.resolve({ id: "1" }) }],
|
||||||
|
[deleteJumbledSentence, { params: Promise.resolve({ id: "1" }) }],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [handler, context] of cases) {
|
||||||
|
const response = await handler({}, context);
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { Row } from "../layout/container";
|
|||||||
import "./multichoice.css";
|
import "./multichoice.css";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import useQuery from "@/shared/hooks/useQuery";
|
import useQuery from "@/shared/hooks/useQuery";
|
||||||
|
import { useRequest } from "@/shared/hooks/useRequest";
|
||||||
|
|
||||||
function shuffleOptions(array) {
|
function shuffleOptions(array) {
|
||||||
return array
|
return array
|
||||||
@@ -15,6 +16,7 @@ function shuffleOptions(array) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MultichoicePage({ exerciseId }) {
|
export default function MultichoicePage({ exerciseId }) {
|
||||||
|
const makeRequest = useRequest();
|
||||||
// Fetch exercise data using the useQuery hook
|
// Fetch exercise data using the useQuery hook
|
||||||
const {
|
const {
|
||||||
data: exerciseData,
|
data: exerciseData,
|
||||||
@@ -75,37 +77,65 @@ export default function MultichoicePage({ exerciseId }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const hasMissingAnswers = userAnswers.some((answer) => answer === "");
|
(async () => {
|
||||||
const newCheckedAnswers = shuffledExerciseData.content.map(
|
try {
|
||||||
(item, index) => {
|
const answerEntries = shuffledExerciseData.content
|
||||||
if (
|
.map((item, index) => ({ item, index }))
|
||||||
item.content_type === "multichoice" ||
|
.filter(
|
||||||
item.content_type === "gap"
|
({ item }) =>
|
||||||
) {
|
item.content_type === "multichoice" || item.content_type === "gap"
|
||||||
return (
|
)
|
||||||
userAnswers[index]?.trim().toLowerCase() ===
|
.map(({ item, index }) => ({
|
||||||
item.correct_answer.toLowerCase()
|
contentId: item.id,
|
||||||
);
|
answer: userAnswers[index] || "",
|
||||||
}
|
}));
|
||||||
return true;
|
|
||||||
|
const response = await makeRequest(
|
||||||
|
`/exercises/multichoice/${exerciseId}/answers`,
|
||||||
|
{
|
||||||
|
answers: answerEntries,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const resultByContentId = new Map(
|
||||||
|
(response.data?.results || []).map((entry) => [
|
||||||
|
String(entry.contentId),
|
||||||
|
entry,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const newCheckedAnswers = shuffledExerciseData.content.map((item) => {
|
||||||
|
if (
|
||||||
|
item.content_type === "multichoice" ||
|
||||||
|
item.content_type === "gap"
|
||||||
|
) {
|
||||||
|
return !!resultByContentId.get(String(item.id))?.isCorrect;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasMissingAnswers = !!response.data?.hasMissing;
|
||||||
|
const hasIncorrectAnswers = newCheckedAnswers.some(
|
||||||
|
(isCorrect, index) => {
|
||||||
|
const contentType =
|
||||||
|
shuffledExerciseData.content[index].content_type;
|
||||||
|
const isAnswerField =
|
||||||
|
contentType === "multichoice" || contentType === "gap";
|
||||||
|
|
||||||
|
return isAnswerField && !isCorrect;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setHasErrors(hasMissingAnswers || hasIncorrectAnswers);
|
||||||
|
setCheckedAnswers(newCheckedAnswers);
|
||||||
|
setIsSubmitted(true);
|
||||||
|
setDropdownSubmittedStates(newCheckedAnswers.map(() => true));
|
||||||
|
} catch (submitError) {
|
||||||
|
console.error("Error validating multichoice answers:", submitError);
|
||||||
|
setHasErrors(true);
|
||||||
|
setIsSubmitted(true);
|
||||||
}
|
}
|
||||||
);
|
})();
|
||||||
|
|
||||||
const hasIncorrectAnswers = newCheckedAnswers.some((isCorrect, index) => {
|
|
||||||
const contentType = shuffledExerciseData.content[index].content_type;
|
|
||||||
const isAnswerField =
|
|
||||||
contentType === "multichoice" || contentType === "gap";
|
|
||||||
|
|
||||||
return isAnswerField && !isCorrect;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set error state if there are missing or incorrect answers
|
|
||||||
setHasErrors(hasMissingAnswers || hasIncorrectAnswers);
|
|
||||||
setCheckedAnswers(newCheckedAnswers);
|
|
||||||
setIsSubmitted(true);
|
|
||||||
|
|
||||||
// Mark all dropdowns as submitted
|
|
||||||
setDropdownSubmittedStates(newCheckedAnswers.map(() => true));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
|
|||||||
@@ -183,6 +183,8 @@ export function useIsAdmin() {
|
|||||||
const { auth, actAs } = useUser();
|
const { auth, actAs } = useUser();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!auth.isAuthResolved) return;
|
||||||
|
|
||||||
if (!auth.user?.id) return;
|
if (!auth.user?.id) return;
|
||||||
|
|
||||||
const isAllowedAdminView =
|
const isAllowedAdminView =
|
||||||
@@ -194,7 +196,9 @@ export function useIsAdmin() {
|
|||||||
}
|
}
|
||||||
}, [auth, router, pathname]);
|
}, [auth, router, pathname]);
|
||||||
|
|
||||||
return auth?.user?.id ? auth.user.is_admin : undefined;
|
if (!auth.isAuthResolved) return undefined;
|
||||||
|
|
||||||
|
return auth.isLoggedIn && !!auth.user?.is_admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { AUTH_COOKIE_NAME } from "@/shared/const";
|
|||||||
const authRequired = ["/grammar/exercises", "/edit_info", "/contact"];
|
const authRequired = ["/grammar/exercises", "/edit_info", "/contact"];
|
||||||
|
|
||||||
// Protected paths that should send logged-out users to registration first.
|
// Protected paths that should send logged-out users to registration first.
|
||||||
const registerFirstPaths = ["/contact"];
|
const registerFirstPaths = ["/contact", "/grammar/exercises"];
|
||||||
|
|
||||||
// Restricted paths logged-in users cannot access
|
// Restricted paths logged-in users cannot access
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { middleware } from "./middleware";
|
||||||
|
|
||||||
|
function createRequest(pathname, hasCookie = false) {
|
||||||
|
return {
|
||||||
|
url: `http://localhost/gradesa${pathname}`,
|
||||||
|
nextUrl: {
|
||||||
|
pathname: `/gradesa${pathname}`,
|
||||||
|
},
|
||||||
|
cookies: {
|
||||||
|
get: () => (hasCookie ? { value: "session-cookie" } : undefined),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("middleware", () => {
|
||||||
|
it("redirects logged-out users from exercises to registration", async () => {
|
||||||
|
const response = await middleware(createRequest("/grammar/exercises"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(307);
|
||||||
|
expect(response.headers.get("location")).toContain("/auth/register");
|
||||||
|
expect(response.headers.get("location")).toContain(
|
||||||
|
"redirect=%2Fgrammar%2Fexercises"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects logged-out users from contact to registration", async () => {
|
||||||
|
const response = await middleware(createRequest("/contact"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(307);
|
||||||
|
expect(response.headers.get("location")).toContain("/auth/register");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects other protected paths to login", async () => {
|
||||||
|
const response = await middleware(createRequest("/edit_info"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(307);
|
||||||
|
expect(response.headers.get("location")).toContain("/auth/login");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,8 +18,6 @@ For the future:
|
|||||||
- The following things could be improved in the GitHub Actions pipeline:
|
- The following things could be improved in the GitHub Actions pipeline:
|
||||||
- Make the tests faster by for example making e2e-tests run at the same time as other tests.
|
- Make the tests faster by for example making e2e-tests run at the same time as other tests.
|
||||||
- Modify the tests so that the e2e-tests test the built container, not just the code. This way the tests catch if there are problems with for example dependencies marked only as dev-dependencies (since they are removed when the image is built).
|
- Modify the tests so that the e2e-tests test the built container, not just the code. This way the tests catch if there are problems with for example dependencies marked only as dev-dependencies (since they are removed when the image is built).
|
||||||
- Fix this bug: If you go to the front page and select 'Übungen' from the 'Grammatik' box while you are not logged in you are brought to the login page. If you input valid credentials there and try to log in, you will see that nothing happens.
|
|
||||||
- Fix this: If you want to see the correct answers to some exercises, you can get the correct answers straight from the API by for example opening https://gradesa-ohtuprojekti-staging.ext.ocp-prod-0.k8s.it.helsinki.fi/api/exercises/dragdrop/1 in your browser. You do not even have to be logged in.
|
|
||||||
|
|
||||||
!!! ADHERE TO THE DEFINITION OF DONE !!!
|
!!! ADHERE TO THE DEFINITION OF DONE !!!
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user