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
136 changes: 127 additions & 9 deletions middlewareNode/src/routes/puzzles.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
/**
* Puzzles Routes
*
*
* API endpoints for retrieving chess puzzles from the database.
* Puzzles are sourced from Lichess and stored in MongoDB.
*
*
* Features:
* - Get all puzzles
* - Get random selection of puzzles
* - Filter puzzles by difficulty and themes
*/

const express = require("express");
const passport = require("passport");
const router = express.Router();
const puzzles = require("../models/puzzles");
const { emitPuzzleSolved } = require("../services/currencyEvents");

/**
* GET /puzzles/list
*
*
* Retrieves all chess puzzles from the database.
* Returns array of puzzle objects without MongoDB _id field.
*
*
* @returns {Array} Array of all puzzles
*/
router.get("/list", async (req, res) => {
Expand All @@ -34,24 +36,140 @@ router.get("/list", async (req, res) => {

/**
* GET /puzzles/random
*
*
* Retrieves a random selection of chess puzzles.
* Useful for providing variety in puzzle practice sessions.
*
*
* Query Parameters:
* - limit: Number of random puzzles to return (default: 20)
*
*
* @returns {Array} Array of randomly selected puzzles
*/
router.get("/random", async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 20;
const puzzlesArray = await puzzles.aggregate([{ $sample: { size: limit } }]);
const puzzlesArray = await puzzles.aggregate([
{ $sample: { size: limit } },
]);
res.status(200).json(puzzlesArray);
} catch (error) {
console.error(error.message);
res.status(500).json("Server error");
}
});

module.exports = router;
/**
* True if a submitted move matches the puzzle's expected move for that
* position, using the exact same leniency Puzzles.tsx's handlePlayerMove
* already applies client-side:
*
* playerAttemptedMove === expectedPlayerMove ||
* playerAttemptedMove === expectedPlayerMove.substring(0, 4)
*
* i.e. a 4-character move (no promotion piece specified) is accepted even
* when the real solution is 5 characters (e.g. "e7e8q") — the client
* already lets a student complete a promotion puzzle without explicitly
* picking a piece, so the server has to accept that same shape or it
* would reject a move the client UI already treated as solved. Comparison
* is case-insensitive to match how the client builds its own move string.
*/
function moveMatches(submitted, expected) {
const s = (submitted || "").toLowerCase();
const e = (expected || "").toLowerCase();
return s === e || s === e.substring(0, 4);
}

/**
* Verifies a client-submitted move sequence against a puzzle's stored
* answer key (space-separated UCI moves). Returns true only if the
* sequences have the same length and every move matches positionally.
*
* This isn't a complete defense — a client that reads the puzzle's own
* `moves` field (already sent to the client to drive the interactive
* play-through) and submits it verbatim without actually playing will
* still pass. What it does stop is the cheaper, more likely attack this
* route originally allowed outright: looping over puzzleIds with no
* solve data at all. Raising the bar to "must know the real solution
* string," not "must have nothing."
*/
function movesMatch(submitted, expected) {
if (!Array.isArray(submitted)) return false;

const expectedMoves = (expected || "").trim().split(/\s+/).filter(Boolean);
if (submitted.length === 0 || submitted.length !== expectedMoves.length) {
return false;
}

return expectedMoves.every((expectedMove, i) => moveMatches(submitted[i], expectedMove));
}

/**
* POST /puzzles/solved
*
* Records a successfully completed puzzle as a currency-earning action —
* but only once the submitted move sequence is verified against the
* puzzle's stored answer key. A bare puzzleId is no longer sufficient:
* without this, any authenticated caller could loop over every puzzleId
* in the catalog and farm currency for puzzles never attempted. See the
* limitation noted on movesMatch() above for what this does and doesn't
* defend against.
*
* Body:
* - puzzleId: ID of the completed puzzle
* - moves: array of UCI move strings the player submitted, in order,
* matching the puzzle's own `moves` answer key exactly
*
* Only authenticated users can earn currency.
* Guests can still play puzzles, but there is no account to credit.
*/
router.post(
"/solved",
passport.authenticate("jwt", { session: false }),
async (req, res) => {
try {
const { puzzleId, moves } = req.body;

if (!puzzleId) {
return res.status(400).json({
error: "puzzleId is required",
});
}
if (!Array.isArray(moves) || moves.length === 0) {
return res.status(400).json({
error: "moves is required and must be a non-empty array of UCI move strings",
});
}

const puzzle = await puzzles.findOne({ puzzleId });

if (!puzzle) {
return res.status(404).json({
error: "Puzzle not found",
});
}

if (!movesMatch(moves, puzzle.moves)) {
return res.status(400).json({
error: "Submitted moves do not match the puzzle's solution",
});
}

const result = await emitPuzzleSolved({
userId: req.user._id,
puzzleId,
});

return res.status(200).json(result);
} catch (error) {
console.error(
"currencyEvents: failed to emit puzzle.solved:",
error.message,
);
return res.status(500).json({
error: "Failed to record puzzle completion",
});
}
},
);

module.exports = router;
25 changes: 24 additions & 1 deletion middlewareNode/src/services/currencyEvents.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@ const DUPLICATE_KEY_ERROR = 11000;
* fresh insert, { emitted: false, duplicate: true } for an already-seen
* eventId.
*/
async function emit({ eventId, actionKey, userId, metadata = {}, occurredAt = new Date() }) {
async function emit({
eventId,
actionKey,
userId,
metadata = {},
occurredAt = new Date(),
}) {
try {
await ActionEvent.create({
eventId,
Expand Down Expand Up @@ -79,7 +85,24 @@ async function emitLessonCompleted({ userId, piece, lessonNum, occurredAt }) {
});
}

/**
* Emits puzzle.solved after a student successfully completes a puzzle.
*
* eventId is `puzzle:<userId>:<puzzleId>` — deterministic per
* (student, puzzle) pair so a retry cannot award the same puzzle twice.
*/
async function emitPuzzleSolved({ userId, puzzleId, occurredAt }) {
return emit({
eventId: `puzzle:${userId}:${puzzleId}`,
actionKey: "puzzle.solved",
userId,
metadata: { puzzleId },
occurredAt,
});
}

module.exports = {
emit,
emitLessonCompleted,
emitPuzzleSolved,
};
74 changes: 62 additions & 12 deletions middlewareNode/tests/lessons.currencyEmit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ jest.setTimeout(60000);
let mongod;

beforeAll(async () => {
mongod = await MongoMemoryServer.create({ instance: { launchTimeout: 30000 } });
mongod = await MongoMemoryServer.create({
instance: { launchTimeout: 30000 },
});
await mongoose.connect(mongod.getUri() + "ystem");
});

Expand Down Expand Up @@ -65,7 +67,10 @@ app.use(express.json());
// simulates "this is the already-authenticated user" without a real JWT.
app.use((req, _res, next) => {
if (req.headers["x-test-user-id"]) {
req.__testUser = { _id: req.headers["x-test-user-id"], username: req.headers["x-test-username"] };
req.__testUser = {
_id: req.headers["x-test-user-id"],
username: req.headers["x-test-username"],
};
}
next();
});
Expand All @@ -80,9 +85,29 @@ async function seedUser({ username, piece, lessonNumber }) {
return result.insertedId;
}

async function waitForActionEvent(query, timeoutMs = 2000) {
const start = Date.now();

while (Date.now() - start < timeoutMs) {
const event = await ActionEvent.findOne(query);

if (event) {
return event;
}

await new Promise((resolve) => setTimeout(resolve, 10));
}

return null;
}

describe("GET /lessons/updateLessonCompletion — currency emit", () => {
it("emits lesson.completed when the write is genuine forward progress", async () => {
const userId = await seedUser({ username: "alice", piece: "The Fork", lessonNumber: 0 });
const userId = await seedUser({
username: "alice",
piece: "The Fork",
lessonNumber: 0,
});

const res = await request(app)
.get("/lessons/updateLessonCompletion")
Expand All @@ -96,7 +121,7 @@ describe("GET /lessons/updateLessonCompletion — currency emit", () => {
// — give it a tick to land before asserting.
await new Promise((r) => setImmediate(r));

const stored = await ActionEvent.findOne({ actionKey: "lesson.completed" });
const stored = await waitForActionEvent({ actionKey: "lesson.completed" });
expect(stored).not.toBeNull();
expect(stored.userId.toString()).toBe(userId.toString());
expect(stored.metadata).toEqual({ piece: "The Fork", lessonNum: 1 });
Expand All @@ -105,7 +130,11 @@ describe("GET /lessons/updateLessonCompletion — currency emit", () => {
it("does NOT emit when the request doesn't advance progress (304 branch)", async () => {
// lessonNumber already at 5; requesting lessonNum=2 (index of an
// earlier lesson) can't win the $lt comparison, so modifiedCount is 0.
const userId = await seedUser({ username: "bob", piece: "The Fork", lessonNumber: 5 });
const userId = await seedUser({
username: "bob",
piece: "The Fork",
lessonNumber: 5,
});

const res = await request(app)
.get("/lessons/updateLessonCompletion")
Expand All @@ -117,27 +146,48 @@ describe("GET /lessons/updateLessonCompletion — currency emit", () => {

await new Promise((r) => setImmediate(r));

expect(await ActionEvent.countDocuments({ actionKey: "lesson.completed" })).toBe(0);
expect(
await ActionEvent.countDocuments({ actionKey: "lesson.completed" }),
).toBe(0);
});

it("does NOT emit for a repeated request that already succeeded once (idempotent, not double-counted)", async () => {
const userId = await seedUser({ username: "carol", piece: "The Fork", lessonNumber: 0 });
const userId = await seedUser({
username: "carol",
piece: "The Fork",
lessonNumber: 0,
});

const query = { piece: "The Fork", lessonNum: "1" };
const headers = { "x-test-user-id": userId.toString(), "x-test-username": "carol" };
const headers = {
"x-test-user-id": userId.toString(),
"x-test-username": "carol",
};

const first = await request(app).get("/lessons/updateLessonCompletion").query(query).set(headers);
const first = await request(app)
.get("/lessons/updateLessonCompletion")
.query(query)
.set(headers);
expect(first.status).toBe(200);
await new Promise((r) => setImmediate(r));

const firstEvent = await waitForActionEvent({
actionKey: "lesson.completed",
});
expect(firstEvent).not.toBeNull();

// Re-sending the exact same request: the DB write itself is now a
// no-op (lessonNumber is already 1, not < 1), so this hits the 304
// branch and must not add a second event even if it somehow did.
const second = await request(app).get("/lessons/updateLessonCompletion").query(query).set(headers);
const second = await request(app)
.get("/lessons/updateLessonCompletion")
.query(query)
.set(headers);
expect(second.status).toBe(304);
await new Promise((r) => setImmediate(r));

expect(await ActionEvent.countDocuments({ actionKey: "lesson.completed" })).toBe(1);
expect(
await ActionEvent.countDocuments({ actionKey: "lesson.completed" }),
).toBe(1);
});

it("never emits for a guest (unauthenticated) request", async () => {
Expand Down
Loading