diff --git a/server/src/lib/targets/goal-utils.ts b/server/src/lib/targets/goal-utils.ts index fa4823e7f..f19a1e5e4 100644 --- a/server/src/lib/targets/goal-utils.ts +++ b/server/src/lib/targets/goal-utils.ts @@ -159,7 +159,8 @@ export async function ValidateGoalChartsAndCriteria( } chartCount = multiCharts.length; - } else if (charts.type === "any") { + } else { + // (charts.type === "any") chartCount = await db.charts[game].count({ playtype }); } 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 index 3c8c1e561..e4c28471e 100644 --- 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 @@ -1,6 +1,7 @@ +/* eslint-disable no-await-in-loop */ import dm from "deepmerge"; import db from "external/mongo/db"; -import { ChartDocument } from "tachi-common"; +import { ChartDocument, IIDX_GRADES, IIDX_LAMPS } from "tachi-common"; import t from "tap"; import { CreateFakeAuthCookie } from "test-utils/fake-auth"; import mockApi from "test-utils/mock-api"; @@ -10,6 +11,7 @@ import { HC511Goal, HC511UserGoal, Testing511SPA, + TestingIIDXFolderSP10, TestingIIDXSPMilestone, } from "test-utils/test-data"; @@ -57,7 +59,7 @@ t.test("POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal", asyn const baseInput = { criteria: { key: "scoreData.percent", - value: 1, + value: 80, mode: "single", }, charts: { @@ -67,30 +69,181 @@ t.test("POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal", asyn }; function mkInput(caseName: string, merge: any) { - return { + const input = { caseName, - content: dm(baseInput, merge), + content: dm(baseInput, merge) as any, }; + + // if we've set this prop to undefined, we want to delete it, actually. + if (input.content.charts.data === undefined) { + delete input.content.charts.data; + } + + return input; } + t.beforeEach(() => db.charts.iidx.insert(anotherFakeChart)); + // @ts-expect-error weird type issues + t.beforeEach(() => db.folders.insert(dm(TestingIIDXFolderSP10, {}))); + + const multiCharts = { + type: "multi", + data: [Testing511SPA.chartID, "another_chart"], + }; + + const anyCharts = { + type: "any", + data: undefined, + }; + + const folderCharts = { + type: "folder", + data: TestingIIDXFolderSP10.folderID, + }; + t.test("Should set valid goals.", async (t) => { const validInput = [ - mkInput("Base Case", baseInput), + mkInput("Percent:Single Case", {}), mkInput("Score:Single Case", { criteria: { key: "scoreData.score", - value: 1, - mode: "", + value: 1000, }, }), + mkInput("LampIndex:Single Case", { + criteria: { + key: "scoreData.lampIndex", + value: IIDX_LAMPS.EX_HARD_CLEAR, + }, + }), + mkInput("GradeIndex:Single Case", { + criteria: { + key: "scoreData.gradeIndex", + value: IIDX_GRADES.AAA, + }, + }), + mkInput("Percent:Multi Case", { + charts: multiCharts, + }), + // NOT LEGAL FOR IIDX, due to being nonsense! NEEDS SPECIFIC TESTING. + // mkInput("Score:Multi Case", { + // criteria: { + // key: "scoreData.score", + // value: 1, + // }, + // charts: multiCharts, + // }), + mkInput("LampIndex:Multi Case", { + criteria: { + key: "scoreData.lampIndex", + value: IIDX_LAMPS.HARD_CLEAR, + }, + charts: multiCharts, + }), + mkInput("GradeIndex:Multi Case", { + criteria: { + key: "scoreData.gradeIndex", + value: IIDX_GRADES.AAA, + }, + charts: multiCharts, + }), + mkInput("Percent:Any Case", { + charts: anyCharts, + }), + // also illegal in iidx, bms and pms. + // mkInput("Score:Any Case", { + // criteria: { + // key: "scoreData.score", + // value: 1000, + // }, + // charts: anyCharts, + // }), + mkInput("LampIndex:Any Case", { + criteria: { + key: "scoreData.lampIndex", + value: IIDX_LAMPS.HARD_CLEAR, + }, + charts: anyCharts, + }), + mkInput("GradeIndex:Any Case", { + criteria: { + key: "scoreData.gradeIndex", + value: IIDX_GRADES.AAA, + }, + charts: anyCharts, + }), + mkInput("Percent:Folder Case", { + charts: folderCharts, + }), + // also illegal in iidx, bms and pms. + // mkInput("Score:Folder Case", { + // criteria: { + // key: "scoreData.score", + // value: 1000, + // }, + // charts: folderCharts, + // }), + mkInput("LampIndex:Folder Case", { + criteria: { + key: "scoreData.lampIndex", + value: IIDX_LAMPS.HARD_CLEAR, + }, + charts: folderCharts, + }), + mkInput("GradeIndex:Folder Case", { + criteria: { + key: "scoreData.gradeIndex", + value: IIDX_GRADES.AAA, + }, + charts: folderCharts, + }), ]; + for (const input of validInput) { + await t.test(`Valid Goal: ${input.caseName}`, async (t) => { + const res = await mockApi + .post("/api/v1/users/1/games/iidx/SP/targets/goals/add-goal") + .set("Cookie", cookie) + .send(input.content); + + t.equal( + // this looks stupid, but results in better errmsgs. + res.statusCode === 200 ? 200 : res.body.description, + 200, + "Should return 200" + ); + + // don't bother doing circular db checks if this is good. + if (res.statusCode !== 200) { + return; + } + + const existsInDb = await db.goals.findOne({ + charts: input.content.charts, + criteria: input.content.criteria, + }); + + t.not(existsInDb, null, "A new goal should be added to the database."); + + const subscribed = await db["goal-subs"].findOne({ + userID: 1, + goalID: existsInDb?.goalID ?? "edge_case", + }); + + t.not( + subscribed, + null, + `Requesting user 1 should be subscribed to their new goal.` + ); + + t.end(); + }); + } + t.end(); }); t.test("Should reject invalid goals.", async (t) => { - await db.charts.iidx.insert(anotherFakeChart); - const invalidInput = [ mkInput("negative percent", { criteria: { @@ -107,15 +260,47 @@ t.test("POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal", asyn value: 100.1, }, }), + mkInput("too big grade", { + criteria: { + key: "scoreData.gradeIndex", + value: IIDX_GRADES.MAX + 1, + }, + }), + mkInput("invalid grade", { + criteria: { + key: "scoreData.gradeIndex", + value: 0.5, + }, + }), + mkInput("too big lamp", { + criteria: { + key: "scoreData.lampIndex", + value: IIDX_LAMPS.FULL_COMBO + 1, + }, + }), + mkInput("invalid lamp", { + criteria: { + key: "scoreData.lampIndex", + value: 0.5, + }, + }), + mkInput("too big score", { + criteria: { + key: "scoreData.gradeIndex", + value: 9_000, + }, + }), mkInput("abs without countNum", { criteria: { mode: "abs", }, + charts: multiCharts, }), mkInput("proportion without countNum", { criteria: { mode: "proportion", }, + charts: multiCharts, }), mkInput("single with countNum", { criteria: { @@ -123,6 +308,27 @@ t.test("POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal", asyn countNum: 123, }, }), + mkInput("proportion with negative countNum", { + criteria: { + mode: "proportion", + countNum: -0.5, + }, + charts: multiCharts, + }), + mkInput("proportion with greater than 1 countNum", { + criteria: { + mode: "proportion", + countNum: 1.1, + }, + charts: multiCharts, + }), + mkInput("proportion which results in countNum of 0", { + criteria: { + mode: "proportion", + countNum: 0.05, + }, + charts: multiCharts, + }), mkInput("nonsense mode", { criteria: { mode: "nonsense", @@ -151,7 +357,7 @@ t.test("POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal", asyn mkInput("charts.data array when type == single", { charts: { type: "single", - data: [Testing511SPA.chartID, Testing511SPA.chartID], + data: multiCharts.data, }, }), mkInput("charts.data array of identical chartIDs", { @@ -166,7 +372,7 @@ t.test("POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal", asyn data: [Testing511SPA.chartID], }, }), - mkInput("charts.data array of chartIDs that don't exist", { + mkInput("charts.data array of chartIDs where some don't exist", { charts: { type: "multi", data: [Testing511SPA.chartID, "not_exist"], @@ -186,30 +392,90 @@ t.test("POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal", asyn mkInput("multi-score for iidx is illegal", { criteria: { key: "scoreData.score", - value: 1, + value: 1000, }, - charts: { - type: "multi", - data: [Testing511SPA.chartID, "another_chart"], + charts: multiCharts, + }), + mkInput("folder-score for iidx is illegal", { + criteria: { + key: "scoreData.score", + value: 1000, + }, + charts: folderCharts, + }), + mkInput("any-score for iidx is illegal", { + criteria: { + key: "scoreData.score", + value: 1000, + }, + charts: anyCharts, + }), + mkInput("charts single but criteria not.", { + criteria: { + mode: "abs", + countNum: 2, }, }), ]; 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); + await t.test(`Invalid Goal: ${input.caseName}`, async (t) => { + 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.equal(res.statusCode, 400); - TestSnapshot(t, res.body.description, `Invalid Goal: ${input.caseName}`); + TestSnapshot(t, res.body.description, `Invalid Goal: ${input.caseName}`); + + t.end(); + }); } t.end(); }); + t.test("Should reject if user tries to subscribe to already subscribed goal.", async (t) => { + const res = await mockApi + .post("/api/v1/users/1/games/iidx/SP/targets/goals/add-goal") + .set("Cookie", cookie) + .send(baseInput); + + t.equal(res.statusCode, 200, "Should allow a subscription the first time."); + + const res2 = await mockApi + .post("/api/v1/users/1/games/iidx/SP/targets/goals/add-goal") + .set("Cookie", cookie) + .send(baseInput); + + t.equal(res2.statusCode, 409, "Should fail the second time."); + + t.end(); + }); + + t.test("Should reject if user tries to subscribe to immediately achieved goal.", async (t) => { + const res = await mockApi + .post("/api/v1/users/1/games/iidx/SP/targets/goals/add-goal") + .set("Cookie", cookie) + .send( + dm(baseInput, { + criteria: { + key: "scoreData.score", + value: 1, + }, + }) + ); + + t.equal( + res.statusCode, + 400, + "Should disallow goal subscriptions that would be instantly achieved." + ); + + 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 b65b7b365..228db5b01 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 @@ -114,12 +114,15 @@ router.post( "Expected an array of 2 to 5 strings in charts.data due to charts.type being 'multi'." ); } else if (parent.type === "folder") { + /* istanbul ignore previous */ return ( typeof self === "string" || "Expected a string in charts.data due to charts.type being 'folder'." ); } + // impossible to reach, so doesn't count for coverage. + /* istanbul ignore next */ return "Unknown charts.type."; }, }, diff --git a/server/src/test-utils/mock-db/personal-bests.json b/server/src/test-utils/mock-db/personal-bests.json index 0637a088a..b9fab093c 100644 --- a/server/src/test-utils/mock-db/personal-bests.json +++ b/server/src/test-utils/mock-db/personal-bests.json @@ -1 +1,38 @@ -[] \ No newline at end of file +[ + { + "chartID": "c2311194e3897ddb5745b1760d2c0141f933e683", + "userID": 1, + "calculatedData": { + "ktLampRating": 0, + "BPI": 10.1 + }, + "composedFrom": { + "scorePB": "TESTING_SCORE_ID", + "lampPB": "TESTING_SCORE_ID" + }, + "highlight": false, + "isPrimary": true, + "scoreData": { + "score": 786, + "lamp": "HARD CLEAR", + "percent": 50, + "grade": "C", + "esd": 30, + "gradeIndex": 3, + "lampIndex": 4, + "judgements": { + "pgreat": 50, + "great": 10 + }, + "hitMeta": {} + }, + "rankingData": { + "rank": 1, + "outOf": 2 + }, + "songID": 1, + "game": "iidx", + "playtype": "SP", + "timeAchieved": 10000 + } +] \ No newline at end of file diff --git a/server/src/test-utils/snapshots/snapshot-data.json b/server/src/test-utils/snapshots/snapshot-data.json index ab2122bf6..ba173504b 100644 --- a/server/src/test-utils/snapshots/snapshot-data.json +++ b/server/src/test-utils/snapshots/snapshot-data.json @@ -9,11 +9,23 @@ "Invalid Goal: abs with countNum but charts.type == single": "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.", "Invalid Goal: nonsense charts.type": "[charts.type] Expected any of single, multi, folder, any. (Received nonsense)", "Invalid Goal: charts.data when type == any": "[charts.data] Invalid charts.data for type 'any'. Must not have any data! (Received foo)", - "Invalid Goal: charts.data array when type == single": "[charts.data] Expected a string in charts.data due to charts.type being 'single'. (Received c2311194e3897ddb5745b1760d2c0141f933e683,c2311194e3897ddb5745b1760d2c0141f933e683)", + "Invalid Goal: charts.data array when type == single": "[charts.data] Expected a string in charts.data due to charts.type being 'single'. (Received c2311194e3897ddb5745b1760d2c0141f933e683,another_chart)", "Invalid Goal: charts.data array of identical chartIDs": "Expected charts.data to match 2 charts. Instead, it only matched 1. Are all of these chartIDs valid?", "Invalid Goal: charts.data array of single chartID": "[charts.data] Expected an array of 2 to 5 strings in charts.data due to charts.type being 'multi'. (Received c2311194e3897ddb5745b1760d2c0141f933e683)", "Invalid Goal: charts.data array of chartIDs that don't exist": "Expected charts.data to match 2 charts. Instead, it only matched 1. Are all of these chartIDs valid?", "Invalid Goal: charts.data folder refers to folder that doesn't exist": "A folder with id fake-folder does not exist for iidx:SP.", "Invalid Goal: nonsense charts.data": "A chart with id nonsense does not exist for iidx:SP.", - "Invalid Goal: multi-score for iidx is illegal": "Invalid key for iidx with multiple charts. Creating score goals on multiple charts where score is relative to notecount is a terrible idea, and has been disabled." + "Invalid Goal: multi-score for iidx is illegal": "Invalid key for iidx with multiple charts. Creating score goals on multiple charts where score is relative to notecount is a terrible idea, and has been disabled.", + "Invalid Goal: too big grade": "Invalid value of 10 for grade goal. No such grade exists at that index.", + "Invalid Goal: invalid grade": "Invalid value of 0.5 for grade goal. No such grade exists at that index.", + "Invalid Goal: too big lamp": "Invalid value of 8 for lamp goal. No such lamp exists at that index.", + "Invalid Goal: invalid lamp": "Invalid value of 0.5 for lamp goal. No such lamp exists at that index.", + "Invalid Goal: too big score": "Invalid value of 9000 for grade goal. No such grade exists at that index.", + "Invalid Goal: proportion with negative countNum": "[criteria.countNum] Expected number to be greater than or equal to 0. (Received -0.5)", + "Invalid Goal: proportion with greater than 1 countNum": "Invalid countNum for goal with criteria.mode of 'proportion'. Expected a decimal in (0, 1]", + "Invalid Goal: proportion which results in countNum of 0": "countNum is too small for goal. Would result in requiring 0 charts to achieve the goal.", + "Invalid Goal: charts.data array of chartIDs where some don't exist": "Expected charts.data to match 2 charts. Instead, it only matched 1. Are all of these chartIDs valid?", + "Invalid Goal: folder-score for iidx is illegal": "Invalid key for iidx with multiple charts. Creating score goals on multiple charts where score is relative to notecount is a terrible idea, and has been disabled.", + "Invalid Goal: any-score for iidx is illegal": "Invalid key for iidx with multiple charts. Creating score goals on multiple charts where score is relative to notecount is a terrible idea, and has been disabled.", + "Invalid Goal: charts single but criteria not.": "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." } \ No newline at end of file