diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts new file mode 100644 index 000000000..fd135371c --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts @@ -0,0 +1,356 @@ +import dm from "deepmerge"; +import db from "external/mongo/db"; +import t from "tap"; +import { CreateFakeAuthCookie } from "test-utils/fake-auth"; +import mockApi from "test-utils/mock-api"; +import ResetDBState from "test-utils/resets"; +import { + HC511Goal, + HC511UserGoal, + Testing511SPA, + TestingIIDXSPMilestone, +} from "test-utils/test-data"; + +// @ts-expect-error Not sure why the types break here, but they do. +const dupedGoal = dm({}, HC511Goal); +// @ts-expect-error Not sure why the types break here, but they do. +const dupedGoalSub = dm({}, HC511UserGoal); + +t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/goals", (t) => { + t.beforeEach(ResetDBState); + + t.test("Should retrieve this user's goal subscriptions.", async (t) => { + await db.goals.insert(dupedGoal); + await db["goal-subs"].insert(dupedGoalSub); + + const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/targets/goals"); + + t.equal(res.statusCode, 200); + + t.strictSame(res.body.body, { + goals: [HC511Goal], + goalSubs: [HC511UserGoal], + }); + + t.end(); + }); + + t.end(); +}); + +t.test("POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal", async (t) => { + t.beforeEach(ResetDBState); + + const cookie = await CreateFakeAuthCookie(mockApi); + + const baseInput = { + criteria: { + key: "scoreData.percent", + value: 1, + mode: "single", + }, + charts: { + type: "single", + data: Testing511SPA.chartID, + }, + }; + + function mkInput(caseName: string, merge: any) { + return { + caseName, + content: dm(baseInput, merge), + }; + } + + t.test("Should set valid goals.", async (t) => { + const validInput = [ + mkInput("Base Case", baseInput), + mkInput("Score:Single Case", { + criteria: { + key: "scoreData.score", + value: 1, + mode: "", + }, + }), + ]; + + t.end(); + }); + + t.test("Should reject invalid goals.", async (t) => { + const invalidInput = [ + mkInput("negative percent", { + criteria: { + value: -1, + }, + }), + mkInput("percent of 0 is a non-goal", { + criteria: { + value: 0, + }, + }), + mkInput("percent greater than 100", { + criteria: { + value: 100.1, + }, + }), + mkInput("abs without countNum", { + criteria: { + mode: "abs", + }, + }), + mkInput("proportion without countNum", { + criteria: { + mode: "proportion", + }, + }), + mkInput("single with countNum", { + criteria: { + mode: "single", + countNum: 123, + }, + }), + mkInput("nonsense mode", { + criteria: { + mode: "nonsense", + }, + }), + mkInput("abs with countNum but charts.type == single", { + criteria: { + mode: "abs", + countNum: 1, + }, + charts: { + type: "single", + }, + }), + mkInput("nonsense charts.type", { + charts: { + type: "nonsense", + }, + }), + mkInput("charts.data when type == any", { + charts: { + type: "any", + data: "foo", + }, + }), + mkInput("charts.data array when type == single", { + charts: { + type: "single", + data: [Testing511SPA.chartID, Testing511SPA.chartID], + }, + }), + mkInput("charts.data array of identical chartIDs", { + charts: { + type: "multi", + data: [Testing511SPA.chartID, Testing511SPA.chartID], + }, + }), + mkInput("charts.data array of single chartID", { + charts: { + type: "multi", + data: [Testing511SPA.chartID], + }, + }), + mkInput("charts.data array of chartIDs that don't exist", { + charts: { + type: "multi", + data: [Testing511SPA.chartID, "not_exist"], + }, + }), + mkInput("charts.data folder refers to folder that doesn't exist", { + charts: { + type: "folder", + data: "fake-folder", + }, + }), + mkInput("nonsense charts.data", { + charts: { + data: "nonsense", + }, + }), + ]; + + for (const input of invalidInput) { + // eslint-disable-next-line no-await-in-loop + const res = await mockApi + .post("/api/v1/users/1/games/iidx/SP/targets/goals/add-goal") + .set("Cookie", cookie) + .send(input.content); + + t.equal(res.statusCode, 400); + + t.matchSnapshot(res.body.description, `Invalid Goal: ${input.caseName}`); + } + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/goals/:goalID", (t) => { + t.beforeEach(ResetDBState); + + t.test("Should retrieve this user's goal subscription.", async (t) => { + await db.goals.insert(dupedGoal); + await db["goal-subs"].insert(dupedGoalSub); + + const res = await mockApi.get( + `/api/v1/users/1/games/iidx/SP/targets/goals/${HC511Goal.goalID}` + ); + + t.equal(res.statusCode, 200); + + t.hasStrict(res.body.body, { + goal: HC511Goal, + goalSub: HC511UserGoal, + milestones: [], + user: { + id: 1, + }, + }); + + t.end(); + }); + + t.test("Should return parent milestones if goal has any.", async (t) => { + await db.goals.insert(dupedGoal); + await db["goal-subs"].insert( + // @ts-expect-error Not sure why the types break here, but they do. + dm(HC511UserGoal, { + parentMilestones: [TestingIIDXSPMilestone.milestoneID], + }) + ); + await db.milestones.insert(dm(TestingIIDXSPMilestone, {})); + + const res = await mockApi.get( + `/api/v1/users/1/games/iidx/SP/targets/goals/${HC511Goal.goalID}` + ); + + t.equal(res.statusCode, 200); + + t.hasStrict(res.body.body, { + goal: HC511Goal, + goalSub: HC511UserGoal, + milestones: [TestingIIDXSPMilestone], + user: { + id: 1, + }, + }); + + t.end(); + }); + + t.test("Should panic if goal refers to a parent milestone that doesn't exist.", async (t) => { + await db.goals.insert(dupedGoal); + await db["goal-subs"].insert( + // @ts-expect-error Not sure why the types break here, but they do. + dm(HC511UserGoal, { + parentMilestones: [TestingIIDXSPMilestone.milestoneID], + }) + ); + + const res = await mockApi.get( + `/api/v1/users/1/games/iidx/SP/targets/goals/${HC511Goal.goalID}` + ); + + t.equal(res.statusCode, 500); + + t.end(); + }); + + t.test("Should return 404 if the user is not subscribed to this goal ID.", async (t) => { + const res = await mockApi.get(`/api/v1/users/1/games/iidx/SP/targets/goals/INVALID_GOAL`); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.end(); +}); + +t.test("DELETE /api/v1/users/:userID/games/:game/:playtype/targets/goals/:goalID", async (t) => { + t.beforeEach(ResetDBState); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should delete a goal subscription if subscribed.", async (t) => { + await db.goals.insert(dupedGoal); + await db["goal-subs"].insert(dupedGoalSub); + + const res = await mockApi + .delete(`/api/v1/users/1/games/iidx/SP/targets/goals/${dupedGoalSub.goalID}`) + .set("Cookie", cookie); + + t.equal(res.statusCode, 200); + + const dbRes = await db["goal-subs"].findOne({ + userID: 1, + goalID: dupedGoal.goalID, + }); + + t.equal(dbRes, null, "Should delete the goal sub from the database."); + + t.end(); + }); + + t.test("Should reject a goal deletion if goal has parent milestones.", async (t) => { + await db.goals.insert(dupedGoal); + await db["goal-subs"].insert( + // @ts-expect-error deepmerge type error + dm(dupedGoalSub, { + parentMilestones: [TestingIIDXSPMilestone.milestoneID], + }) + ); + + const res = await mockApi + .delete(`/api/v1/users/1/games/iidx/SP/targets/goals/${dupedGoalSub.goalID}`) + .set("Cookie", cookie); + + t.equal(res.statusCode, 400); + t.equal( + res.body.description, + "This goal is part of a milestone you are subscribed to. It can only be removed by unsubscribing from the relevant milestones." + ); + + const dbRes = await db["goal-subs"].findOne({ + userID: 1, + goalID: dupedGoal.goalID, + }); + + t.not(dbRes, null, "Should NOT delete the goal sub from the database."); + + t.end(); + }); + + t.test("Should reject a goal deletion if not subscribed.", async (t) => { + await db.goals.insert(dupedGoal); + await db["goal-subs"].insert(dupedGoalSub); + + const res = await mockApi + .delete(`/api/v1/users/1/games/iidx/SP/targets/goals/INVALID_GOAL`) + .set("Cookie", cookie); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.test("Should reject a goal deletion if not authed.", async (t) => { + await db.goals.insert(dupedGoal); + await db["goal-subs"].insert(dupedGoalSub); + + const res = await mockApi.delete( + `/api/v1/users/1/games/iidx/SP/targets/goals/${dupedGoalSub.goalID}` + ); + + t.equal(res.statusCode, 401); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts index 2c9e34268..438a9dfb5 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts @@ -9,7 +9,7 @@ import p from "prudence"; import { RequirePermissions } from "server/middleware/auth"; import prValidate from "server/middleware/prudence-validate"; import { GoalDocument, MilestoneDocument } from "tachi-common"; -import { GetGoalForIDGuaranteed, GetMilestoneForIDGuaranteed } from "utils/db"; +import { GetGoalForIDGuaranteed } from "utils/db"; import { AssignToReqTachiData, GetUGPT } from "utils/req-tachi-data"; import { RequireAuthedAsUser } from "../../../../../middleware"; @@ -88,7 +88,7 @@ router.post( ); } - // proper validation later. + // proper valid/ation later. return p.gte(0)(self); }, }, @@ -120,6 +120,8 @@ router.post( ); } + // technically impossible, so let's ignore it. + /* istanbul ignore next */ return "Unknown charts.type."; }, }, @@ -218,9 +220,19 @@ router.get("/:goalID", GetGoalSubscription, async (req, res) => { let milestones: MilestoneDocument[] = []; if (goalSub.parentMilestones.length !== 0) { - milestones = await Promise.all( - goalSub.parentMilestones.map((e) => GetMilestoneForIDGuaranteed(e)) - ); + milestones = await db.milestones.find({ + milestoneID: { $in: goalSub.parentMilestones }, + }); + + if (milestones.length !== goalSub.parentMilestones.length) { + logger.error( + `Goal '${goalSub.goalID} (uid:${goalSub.userID})' refers to parent milestones that don't exist?` + ); + return res.status(500).json({ + success: false, + description: `Something went wrong when fetching parent milestones. This has been reported.`, + }); + } } const goal = await GetGoalForIDGuaranteed(goalSub.goalID); @@ -247,7 +259,6 @@ router.delete( RequireAuthedAsUser, GetGoalSubscription, RequirePermissions("manage_targets"), - prValidate({ goalID: "string" }), async (req, res) => { const goalID = req.params.goalID; const { user, game, playtype } = GetUGPT(req); @@ -260,7 +271,7 @@ router.delete( }); if (!goalSub) { - return res.status(400).json({ + return res.status(404).json({ success: false, description: `You aren't subscribed to this goal.`, }); diff --git a/server/tap-snapshots/src/test-utils/single-process-tap.ts.test.cjs b/server/tap-snapshots/src/test-utils/single-process-tap.ts.test.cjs new file mode 100644 index 000000000..e64f5ce19 --- /dev/null +++ b/server/tap-snapshots/src/test-utils/single-process-tap.ts.test.cjs @@ -0,0 +1,135 @@ + +/* IMPORTANT + * This snapshot file is auto-generated, but designed for humans. + * It should be checked into source control and tracked carefully. + * Re-generate by setting TAP_SNAPSHOT=1 and running tests. + * Make sure to inspect the output below. Do not ignore changes! + */ +'use strict' +exports[`src/test-utils/single-process-tap.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: abs with countNum but charts.type == single 1`] = ` +Invalid countNum for goal with criteria.mode of 'abs'. Expected a whole number less than the total amount of charts available and greater than 1. +` + +exports[`src/test-utils/single-process-tap.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: abs without countNum 1`] = ` +[criteria.countNum] Expected number to be greater than or equal to 0. (Received nothing) +` + +exports[`src/test-utils/single-process-tap.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: charts.data array of chartIDs that don't exist 1`] = ` +Expected charts.data to match 2 charts. Instead, it only matched 1. Are all of these chartIDs valid? +` + +exports[`src/test-utils/single-process-tap.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: charts.data array of identical chartIDs 1`] = ` +Expected charts.data to match 2 charts. Instead, it only matched 1. Are all of these chartIDs valid? +` + +exports[`src/test-utils/single-process-tap.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: charts.data array of single chartID 1`] = ` +[charts.data] Expected an array of 2 to 5 strings in charts.data due to charts.type being 'multi'. (Received c2311194e3897ddb5745b1760d2c0141f933e683) +` + +exports[`src/test-utils/single-process-tap.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: charts.data array when type == single 1`] = ` +[charts.data] Expected a string in charts.data due to charts.type being 'single'. (Received c2311194e3897ddb5745b1760d2c0141f933e683,c2311194e3897ddb5745b1760d2c0141f933e683) +` + +exports[`src/test-utils/single-process-tap.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: charts.data folder refers to folder that doesn't exist 1`] = ` +A folder with id fake-folder does not exist for iidx:SP. +` + +exports[`src/test-utils/single-process-tap.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: charts.data when type == any 1`] = ` +[charts.data] Invalid charts.data for type 'any'. Must not have any data! (Received foo) +` + +exports[`src/test-utils/single-process-tap.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: negative percent 1`] = ` +[criteria.value] Expected number to be greater than or equal to 0. (Received -1) +` + +exports[`src/test-utils/single-process-tap.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: nonsense charts.data 1`] = ` +A chart with id nonsense does not exist for iidx:SP. +` + +exports[`src/test-utils/single-process-tap.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: nonsense charts.type 1`] = ` +[charts.type] Expected any of single, multi, folder, any. (Received nonsense) +` + +exports[`src/test-utils/single-process-tap.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: nonsense mode 1`] = ` +[criteria.mode] Expected any of single, abs, proportion. (Received nonsense) +` + +exports[`src/test-utils/single-process-tap.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: percent greater than 100 1`] = ` +Invalid value of 100.1 for percent goal. Percents must be between 0 and 100. +` + +exports[`src/test-utils/single-process-tap.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: percent of 0 is a non-goal 1`] = ` +Invalid value of 0 for percent goal. Percents must be between 0 and 100. +` + +exports[`src/test-utils/single-process-tap.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: proportion without countNum 1`] = ` +[criteria.countNum] Expected number to be greater than or equal to 0. (Received nothing) +` + +exports[`src/test-utils/single-process-tap.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: single with countNum 1`] = ` +[criteria.countNum] Invalid countNum for mode 'single'. Must not have one! (Received 123) +` + +exports[`src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: abs with countNum but charts.type == single 1`] = ` +Invalid countNum for goal with criteria.mode of 'abs'. Expected a whole number less than the total amount of charts available and greater than 1. +` + +exports[`src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: abs without countNum 1`] = ` +[criteria.countNum] Expected number to be greater than or equal to 0. (Received nothing) +` + +exports[`src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: charts.data array of chartIDs that don't exist 1`] = ` +Expected charts.data to match 2 charts. Instead, it only matched 1. Are all of these chartIDs valid? +` + +exports[`src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: charts.data array of identical chartIDs 1`] = ` +Expected charts.data to match 2 charts. Instead, it only matched 1. Are all of these chartIDs valid? +` + +exports[`src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: charts.data array of single chartID 1`] = ` +[charts.data] Expected an array of 2 to 5 strings in charts.data due to charts.type being 'multi'. (Received c2311194e3897ddb5745b1760d2c0141f933e683) +` + +exports[`src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: charts.data array when type == single 1`] = ` +[charts.data] Expected a string in charts.data due to charts.type being 'single'. (Received c2311194e3897ddb5745b1760d2c0141f933e683,c2311194e3897ddb5745b1760d2c0141f933e683) +` + +exports[`src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: charts.data folder refers to folder that doesn't exist 1`] = ` +A folder with id fake-folder does not exist for iidx:SP. +` + +exports[`src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: charts.data when type == any 1`] = ` +[charts.data] Invalid charts.data for type 'any'. Must not have any data! (Received foo) +` + +exports[`src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: negative percent 1`] = ` +[criteria.value] Expected number to be greater than or equal to 0. (Received -1) +` + +exports[`src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: nonsense charts.data 1`] = ` +A chart with id nonsense does not exist for iidx:SP. +` + +exports[`src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: nonsense charts.type 1`] = ` +[charts.type] Expected any of single, multi, folder, any. (Received nonsense) +` + +exports[`src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: nonsense mode 1`] = ` +[criteria.mode] Expected any of single, abs, proportion. (Received nonsense) +` + +exports[`src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: percent greater than 100 1`] = ` +Invalid value of 100.1 for percent goal. Percents must be between 0 and 100. +` + +exports[`src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: percent of 0 is a non-goal 1`] = ` +Invalid value of 0 for percent goal. Percents must be between 0 and 100. +` + +exports[`src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: proportion without countNum 1`] = ` +[criteria.countNum] Expected number to be greater than or equal to 0. (Received nothing) +` + +exports[`src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts TAP POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal Should reject invalid goals. > Invalid Goal: single with countNum 1`] = ` +[criteria.countNum] Invalid countNum for mode 'single'. Must not have one! (Received 123) +`