diff --git a/apps/api/package.json b/apps/api/package.json index 586004e..600b52a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "tsx watch src/server.ts", "build": "tsc", - "start": "node dist/server.js" + "start": "node dist/server.js", + "test": "vitest" }, "dependencies": { "@glossa/db": "workspace:*", @@ -15,6 +16,8 @@ }, "devDependencies": { "@types/express": "^5.0.6", + "@types/supertest": "^7.2.0", + "supertest": "^7.2.2", "tsx": "^4.21.0" } } diff --git a/apps/api/src/controllers/gameController.test.ts b/apps/api/src/controllers/gameController.test.ts new file mode 100644 index 0000000..dd3823a --- /dev/null +++ b/apps/api/src/controllers/gameController.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +vi.mock("@glossa/db", () => ({ + getGameTerms: vi.fn(), + getDistractors: vi.fn(), +})); + +import { getGameTerms, getDistractors } from "@glossa/db"; +import { createApp } from "../app.js"; + +const app = createApp(); +const mockGetGameTerms = vi.mocked(getGameTerms); +const mockGetDistractors = vi.mocked(getDistractors); + +const validBody = { + source_language: "en", + target_language: "it", + pos: "noun", + difficulty: "easy", + rounds: "3", +}; + +const fakeTerms = [ + { termId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null }, + { termId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null }, + { termId: "t3", sourceText: "house", targetText: "casa", sourceGloss: null }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + mockGetGameTerms.mockResolvedValue(fakeTerms); + mockGetDistractors.mockResolvedValue(["wrong1", "wrong2", "wrong3"]); +}); + +describe("POST /api/v1/game/start", () => { + it("returns 200 with a valid game session", async () => { + const res = await request(app).post("/api/v1/game/start").send(validBody); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.sessionId).toBeDefined(); + expect(res.body.data.questions).toHaveLength(3); + }); + + it("returns 400 when the body is empty", async () => { + const res = await request(app).post("/api/v1/game/start").send({}); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toBeDefined(); + }); + + it("returns 400 when required fields are missing", async () => { + const res = await request(app) + .post("/api/v1/game/start") + .send({ source_language: "en" }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("returns 400 when a field has an invalid value", async () => { + const res = await request(app) + .post("/api/v1/game/start") + .send({ ...validBody, difficulty: "impossible" }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); +}); + +describe("POST /api/v1/game/answer", () => { + it("returns 200 with an answer result for a valid submission", async () => { + // Start a game first + const startRes = await request(app) + .post("/api/v1/game/start") + .send(validBody); + + const { sessionId, questions } = startRes.body.data; + const question = questions[0]; + + const res = await request(app) + .post("/api/v1/game/answer") + .send({ + sessionId, + questionId: question.questionId, + selectedOptionId: 0, + }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.questionId).toBe(question.questionId); + expect(typeof res.body.data.isCorrect).toBe("boolean"); + expect(typeof res.body.data.correctOptionId).toBe("number"); + expect(res.body.data.selectedOptionId).toBe(0); + }); + + it("returns 400 when the body is empty", async () => { + const res = await request(app).post("/api/v1/game/answer").send({}); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("returns 404 when the session does not exist", async () => { + const res = await request(app) + .post("/api/v1/game/answer") + .send({ + sessionId: "00000000-0000-0000-0000-000000000000", + questionId: "00000000-0000-0000-0000-000000000000", + selectedOptionId: 0, + }); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain("Game session not found"); + }); + + it("returns 404 when the question does not exist in the session", async () => { + const startRes = await request(app) + .post("/api/v1/game/start") + .send(validBody); + + const { sessionId } = startRes.body.data; + + const res = await request(app) + .post("/api/v1/game/answer") + .send({ + sessionId, + questionId: "00000000-0000-0000-0000-000000000000", + selectedOptionId: 0, + }); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain("Question not found"); + }); +}); diff --git a/apps/api/src/services/gameService.test.ts b/apps/api/src/services/gameService.test.ts new file mode 100644 index 0000000..fae7159 --- /dev/null +++ b/apps/api/src/services/gameService.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { GameRequest, AnswerSubmission } from "@glossa/shared"; + +vi.mock("@glossa/db", () => ({ + getGameTerms: vi.fn(), + getDistractors: vi.fn(), +})); + +import { getGameTerms, getDistractors } from "@glossa/db"; +import { createGameSession, evaluateAnswer } from "./gameService.js"; + +const mockGetGameTerms = vi.mocked(getGameTerms); +const mockGetDistractors = vi.mocked(getDistractors); + +const validRequest: GameRequest = { + source_language: "en", + target_language: "it", + pos: "noun", + difficulty: "easy", + rounds: "3", +}; + +const fakeTerms = [ + { termId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null }, + { termId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null }, + { + termId: "t3", + sourceText: "house", + targetText: "casa", + sourceGloss: "a building for living in", + }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + mockGetGameTerms.mockResolvedValue(fakeTerms); + mockGetDistractors.mockResolvedValue(["wrong1", "wrong2", "wrong3"]); +}); + +describe("createGameSession", () => { + it("returns a session with the correct number of questions", async () => { + const session = await createGameSession(validRequest); + + expect(session.sessionId).toBeDefined(); + expect(session.questions).toHaveLength(3); + }); + + it("each question has exactly 4 options", async () => { + const session = await createGameSession(validRequest); + + for (const question of session.questions) { + expect(question.options).toHaveLength(4); + } + }); + + it("each question has a unique questionId", async () => { + const session = await createGameSession(validRequest); + const ids = session.questions.map((q) => q.questionId); + + expect(new Set(ids).size).toBe(ids.length); + }); + + it("options have sequential optionIds 0-3", async () => { + const session = await createGameSession(validRequest); + + for (const question of session.questions) { + const optionIds = question.options.map((o) => o.optionId); + expect(optionIds).toEqual([0, 1, 2, 3]); + } + }); + + it("the correct answer is always among the options", async () => { + const session = await createGameSession(validRequest); + + for (let i = 0; i < session.questions.length; i++) { + const question = session.questions[i]!; + const correctText = fakeTerms[i]!.targetText; + const optionTexts = question.options.map((o) => o.text); + + expect(optionTexts).toContain(correctText); + } + }); + + it("distractors are never the correct answer", async () => { + const session = await createGameSession(validRequest); + + for (let i = 0; i < session.questions.length; i++) { + const question = session.questions[i]!; + const correctText = fakeTerms[i]!.targetText; + const distractorTexts = question.options + .map((o) => o.text) + .filter((t) => t !== correctText); + + for (const text of distractorTexts) { + expect(text).not.toBe(correctText); + } + } + }); + + it("sets the prompt from the source text", async () => { + const session = await createGameSession(validRequest); + + expect(session.questions[0]!.prompt).toBe("dog"); + expect(session.questions[1]!.prompt).toBe("cat"); + expect(session.questions[2]!.prompt).toBe("house"); + }); + + it("passes gloss through (null or string)", async () => { + const session = await createGameSession(validRequest); + + expect(session.questions[0]!.gloss).toBeNull(); + expect(session.questions[2]!.gloss).toBe("a building for living in"); + }); + + it("calls getGameTerms with the correct arguments", async () => { + await createGameSession(validRequest); + + expect(mockGetGameTerms).toHaveBeenCalledWith( + "en", + "it", + "noun", + "easy", + 3, + ); + }); + + it("calls getDistractors once per question", async () => { + await createGameSession(validRequest); + + expect(mockGetDistractors).toHaveBeenCalledTimes(3); + }); +}); + +describe("evaluateAnswer", () => { + it("returns isCorrect: true when the correct option is selected", async () => { + const session = await createGameSession(validRequest); + const question = session.questions[0]!; + const correctText = fakeTerms[0]!.targetText; + const correctOption = question.options.find((o) => o.text === correctText)!; + + const result = await evaluateAnswer({ + sessionId: session.sessionId, + questionId: question.questionId, + selectedOptionId: correctOption.optionId, + }); + + expect(result.isCorrect).toBe(true); + expect(result.correctOptionId).toBe(correctOption.optionId); + expect(result.selectedOptionId).toBe(correctOption.optionId); + }); + + it("returns isCorrect: false when a wrong option is selected", async () => { + const session = await createGameSession(validRequest); + const question = session.questions[0]!; + const correctText = fakeTerms[0]!.targetText; + const correctOption = question.options.find((o) => o.text === correctText)!; + const wrongOption = question.options.find((o) => o.text !== correctText)!; + + const result = await evaluateAnswer({ + sessionId: session.sessionId, + questionId: question.questionId, + selectedOptionId: wrongOption.optionId, + }); + + expect(result.isCorrect).toBe(false); + expect(result.correctOptionId).toBe(correctOption.optionId); + expect(result.selectedOptionId).toBe(wrongOption.optionId); + }); + + it("throws NotFoundError for a non-existent session", async () => { + const submission: AnswerSubmission = { + sessionId: "00000000-0000-0000-0000-000000000000", + questionId: "00000000-0000-0000-0000-000000000000", + selectedOptionId: 0, + }; + + await expect(evaluateAnswer(submission)).rejects.toThrow( + "Game session not found", + ); + }); + + it("throws NotFoundError for a non-existent question", async () => { + const session = await createGameSession(validRequest); + + const submission: AnswerSubmission = { + sessionId: session.sessionId, + questionId: "00000000-0000-0000-0000-000000000000", + selectedOptionId: 0, + }; + + await expect(evaluateAnswer(submission)).rejects.toThrow( + "Question not found", + ); + }); +}); diff --git a/documentation/api-development.md b/documentation/api-development.md index ac02d89..4611cf7 100644 --- a/documentation/api-development.md +++ b/documentation/api-development.md @@ -313,7 +313,7 @@ Required before: implementing the double join for source language prompt. - Central error middleware in `app.ts` - Remove temporary `safeParse` error handling from controllers -### Step 7 — Tests +### Step 7 — Tests - done - Unit tests for `QuizService` — correct POS filtering, distractor never equals correct answer - Unit tests for `evaluateAnswer` — correct and incorrect cases @@ -339,3 +339,10 @@ Required before: implementing the double join for source language prompt. word in the definition text (e.g. "Padre" appearing in the English gloss for "father"). Address during the post-MVP data enrichment pass — either clean the glosses, replace them with custom definitions, or filter at the service layer. => resolved + +-  WARN  2 deprecated subdependencies found: @esbuild-kit/core-utils@3.3.2, @esbuild-kit/esm-loader@2.6.5 +../.. | Progress: resolved 556, reused 0, downloaded 0, added 0, done + WARN  Issues with peer dependencies found +. +└─┬ eslint-plugin-react-hooks 7.0.1 + └── ✕ unmet peer eslint@"^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0": found 10.0.3 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 427f164..469b672 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: version: 1.161.6(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^4.1.0 - version: 4.1.0(vitest@4.1.0(@types/node@25.5.0)(jsdom@29.0.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))) + version: 4.1.0(vitest@4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))) concurrently: specifier: ^9.2.1 version: 9.2.1 @@ -43,7 +43,7 @@ importers: version: 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) vitest: specifier: ^4.1.0 - version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) + version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) apps/api: dependencies: @@ -60,6 +60,12 @@ importers: '@types/express': specifier: ^5.0.6 version: 5.0.6 + '@types/supertest': + specifier: ^7.2.0 + version: 7.2.0 + supertest: + specifier: ^7.2.2 + version: 7.2.2 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -105,7 +111,7 @@ importers: version: 6.0.1(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) jsdom: specifier: ^29.0.1 - version: 29.0.1 + version: 29.0.1(@noble/hashes@1.8.0) vite: specifier: ^8.0.1 version: 8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) @@ -828,9 +834,16 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@oxc-project/types@0.120.0': resolution: {integrity: sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==} + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@rolldown/binding-android-arm64@1.0.0-rc.10': resolution: {integrity: sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1128,6 +1141,9 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1149,6 +1165,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/node@24.12.0': resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} @@ -1178,6 +1197,12 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/superagent@8.1.9': + resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + + '@types/supertest@7.2.0': + resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==} + '@typescript-eslint/eslint-plugin@8.57.1': resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1325,6 +1350,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1336,6 +1364,9 @@ packages: ast-v8-to-istanbul@1.0.0: resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} @@ -1425,6 +1456,13 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + concurrently@9.2.1: resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} engines: {node: '>=18'} @@ -1452,6 +1490,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -1487,6 +1528,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -1495,6 +1540,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -1639,6 +1687,10 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} @@ -1753,6 +1805,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1785,6 +1840,14 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1852,6 +1915,10 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -2096,14 +2163,31 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -2423,6 +2507,14 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -3178,7 +3270,9 @@ snapshots: '@eslint/core': 1.1.1 levn: 0.4.1 - '@exodus/bytes@1.15.0': {} + '@exodus/bytes@1.15.0(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 '@humanfs/core@0.19.1': {} @@ -3217,8 +3311,14 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@noble/hashes@1.8.0': {} + '@oxc-project/types@0.120.0': {} + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + '@rolldown/binding-android-arm64@1.0.0-rc.10': optional: true @@ -3468,6 +3568,8 @@ snapshots: dependencies: '@types/node': 24.12.0 + '@types/cookiejar@2.1.5': {} + '@types/deep-eql@4.0.2': {} '@types/esrecurse@4.3.1': {} @@ -3491,6 +3593,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/methods@1.1.4': {} + '@types/node@24.12.0': dependencies: undici-types: 7.16.0 @@ -3527,6 +3631,18 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 24.12.0 + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 24.12.0 + form-data: 4.0.5 + + '@types/supertest@7.2.0': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3623,7 +3739,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: 8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) - '@vitest/coverage-v8@4.1.0(vitest@4.1.0(@types/node@25.5.0)(jsdom@29.0.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))': + '@vitest/coverage-v8@4.1.0(vitest@4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.1.0 @@ -3635,7 +3751,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) + vitest: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) '@vitest/expect@4.1.0': dependencies: @@ -3711,6 +3827,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + asap@2.0.6: {} + assertion-error@2.0.1: {} ast-types@0.16.1: @@ -3723,6 +3841,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + asynckit@0.4.0: {} + babel-dead-code-elimination@1.0.12: dependencies: '@babel/core': 7.29.0 @@ -3828,6 +3948,12 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + component-emitter@1.3.1: {} + concurrently@9.2.1: dependencies: chalk: 4.1.2 @@ -3849,6 +3975,8 @@ snapshots: cookie@0.7.2: {} + cookiejar@2.1.4: {} + crc-32@1.2.2: {} cross-spawn@7.0.6: @@ -3864,10 +3992,10 @@ snapshots: csstype@3.2.3: {} - data-urls@7.0.0: + data-urls@7.0.0(@noble/hashes@1.8.0): dependencies: whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) transitivePeerDependencies: - '@noble/hashes' @@ -3879,10 +4007,17 @@ snapshots: deep-is@0.1.4: {} + delayed-stream@1.0.0: {} + depd@2.0.0: {} detect-libc@2.1.2: {} + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + diff@8.0.3: {} dotenv@17.3.1: {} @@ -3930,6 +4065,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + esbuild@0.18.20: optionalDependencies: '@esbuild/android-arm': 0.18.20 @@ -4153,6 +4295,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -4188,6 +4332,20 @@ snapshots: flatted@3.4.2: {} + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + forwarded@0.2.0: {} frac@1.1.2: {} @@ -4245,6 +4403,10 @@ snapshots: has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -4255,9 +4417,9 @@ snapshots: dependencies: hermes-estree: 0.25.1 - html-encoding-sniffer@6.0.0: + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): dependencies: - '@exodus/bytes': 1.15.0 + '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) transitivePeerDependencies: - '@noble/hashes' @@ -4326,17 +4488,17 @@ snapshots: js-tokens@4.0.0: {} - jsdom@29.0.1: + jsdom@29.0.1(@noble/hashes@1.8.0): dependencies: '@asamuzakjp/css-color': 5.0.1 '@asamuzakjp/dom-selector': 7.0.4 '@bramus/specificity': 2.4.2 '@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1) - '@exodus/bytes': 1.15.0 + '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) css-tree: 3.2.1 - data-urls: 7.0.0 + data-urls: 7.0.0(@noble/hashes@1.8.0) decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) is-potential-custom-element-name: 1.0.1 lru-cache: 11.2.7 parse5: 8.0.0 @@ -4347,7 +4509,7 @@ snapshots: w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -4452,12 +4614,22 @@ snapshots: merge-descriptors@2.0.0: {} + methods@1.1.2: {} + + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.2: dependencies: mime-db: 1.54.0 + mime@2.6.0: {} + minimatch@10.2.4: dependencies: brace-expansion: 5.0.4 @@ -4774,6 +4946,28 @@ snapshots: dependencies: ansi-regex: 5.0.1 + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.0 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -4921,7 +5115,7 @@ snapshots: jiti: 2.6.1 tsx: 4.21.0 - vitest@4.1.0(@types/node@25.5.0)(jsdom@29.0.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)): + vitest@4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)): dependencies: '@vitest/expect': 4.1.0 '@vitest/mocker': 4.1.0(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) @@ -4945,7 +5139,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.5.0 - jsdom: 29.0.1 + jsdom: 29.0.1(@noble/hashes@1.8.0) transitivePeerDependencies: - msw @@ -4959,9 +5153,9 @@ snapshots: whatwg-mimetype@5.0.0: {} - whatwg-url@16.0.1: + whatwg-url@16.0.1(@noble/hashes@1.8.0): dependencies: - '@exodus/bytes': 1.15.0 + '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) tr46: 6.0.0 webidl-conversions: 8.0.1 transitivePeerDependencies: