feat: validators

This commit is contained in:
zkldi
2023-01-19 16:39:42 +00:00
parent 7d3ffed722
commit 2ff1dc1c29
17 changed files with 337 additions and 82 deletions
+14 -7
View File
@@ -403,11 +403,7 @@ const PRE_SCHEMAS = {
const gptConfig = GetGamePTConfig(game, playtype);
return prSchemaFnWrap({
composedFrom: {
scorePB: "string",
lampPB: "string",
other: p.optional([{ name: "string", scoreID: "string" }]),
},
composedFrom: [{ name: "string", scoreID: "string" }],
rankingData: {
rank: p.isPositiveNonZeroInteger,
outOf: p.isPositiveNonZeroInteger,
@@ -424,6 +420,8 @@ const PRE_SCHEMAS = {
...PR_METRICS(gptConfig.providedMetrics),
...PR_METRICS(gptConfig.derivedMetrics),
// TODO ENUMINDEXES
judgements: Object.fromEntries(
gptConfig.orderedJudgements.map((j) => [j, optNull(p.isInteger)])
),
@@ -871,10 +869,19 @@ function PR_METRICS(metrics: Record<string, ConfScoreMetric>, shouldAllBeOptNull
const schema: PrudenceSchema = {};
for (const [key, value] of Object.entries(metrics)) {
let prValidator = PR_METRIC(value);
if ("validate" in value) {
prValidator = p.and(
prValidator,
(self) => typeof self === "number" && value.validate(self)
);
}
if (shouldAllBeOptNull === true) {
schema[key] = optNull(PR_METRIC(value));
schema[key] = optNull(prValidator);
} else {
schema[key] = PR_METRIC(value);
schema[key] = prValidator;
}
}
+2 -1
View File
@@ -47,7 +47,8 @@
"@types/mongodb": "3.6.20",
"@types/bson": "1.0.11",
"@types/node": "16.11.7",
"@types/express-serve-static-core": "4.17.29"
"@types/express-serve-static-core": "4.17.29",
"ts-node": "10.9.1"
},
"patchedDependencies": {
"@types/mongodb@3.6.20": "patches/@types__mongodb@3.6.20.patch",
+1
View File
@@ -5,6 +5,7 @@ overrides:
'@types/bson': 1.0.11
'@types/node': 16.11.7
'@types/express-serve-static-core': 4.17.29
ts-node: 10.9.1
patchedDependencies:
'@types/mongodb@3.6.20':
@@ -78,6 +78,10 @@ export class InternalFailure extends ConverterFailure {
}
}
export function IsConverterFailure(err: ConverterFailure | Error): err is ConverterFailure {
export function IsConverterFailure(err: unknown): err is ConverterFailure {
if (err === null || typeof err !== "object") {
return false;
}
return "failureType" in err;
}
@@ -1,4 +1,5 @@
import { Converters } from "../../import-types/converters";
import { IsConverterFailure } from "../common/converter-failures";
import { HandlePostImportSteps } from "../score-importing/score-import-main";
import { ProcessSuccessfulConverterReturn } from "../score-importing/score-importing";
import db from "external/mongo/db";
@@ -135,13 +136,24 @@ export async function ReprocessOrphan(
await db["orphan-scores"].remove({ orphanID: orphan.orphanID });
// else, import the orphan.
const converterReturns = await ProcessSuccessfulConverterReturn(
orphan.userID,
res,
blacklist,
logger,
true
);
let converterReturns;
try {
converterReturns = await ProcessSuccessfulConverterReturn(
orphan.userID,
res,
blacklist,
logger,
true
);
} catch (err) {
if (IsConverterFailure(err) && err.failureType === "InvalidScore") {
return null;
}
throw err;
}
if (converterReturns === null || !converterReturns.success) {
return null;
@@ -105,9 +105,8 @@ t.test("#CreatePBDoc", (t) => {
t.strictSame(
res,
deepmerge(ExamplePBDoc, {
composedFrom: {
other: [{ name: "Best BP", scoreID: "BP_PB_ID" }],
},
composedFrom: [{ name: "Best BP", scoreID: "BP_PB_ID" }],
scoreData: {
optional: {
bp: 5,
@@ -251,16 +250,11 @@ t.test("#CreatePBDoc", (t) => {
"Should select the best BP's BP and not the score PBs."
);
t.strictSame(res?.composedFrom, {
lampPB: "LAMP_PB_ID",
scorePB: TestingBMS7KScore.scoreID,
other: [
{
name: "Best BP",
scoreID: "BP_PB_ID",
},
],
});
t.strictSame(res?.composedFrom, [
{ name: "Best Score", scoreID: TestingBMS7KScore.scoreID },
{ name: "Best Lamp", scoreID: "LAMP_PB_ID" },
{ name: "Best BP", scoreID: "BP_PB_ID" },
]);
t.end();
});
@@ -313,16 +307,11 @@ t.test("#CreatePBDoc", (t) => {
"Should select the best BP's BP and not the score PBs."
);
t.strictSame(res?.composedFrom, {
lampPB: "LAMP_PB_ID",
scorePB: TestingBMS7KScore.scoreID,
other: [
{
name: "Best BP",
scoreID: "BP_PB_ID",
},
],
});
t.strictSame(res?.composedFrom, [
{ name: "Best Score", scoreID: TestingBMS7KScore.scoreID },
{ name: "Best Lamp", scoreID: "LAMP_PB_ID" },
{ name: "Best BP", scoreID: "BP_PB_ID" },
]);
t.end();
});
@@ -1,6 +1,7 @@
import { HydrateScore } from "./hydrate-score";
import { GetScoreQueueMaybe, InsertQueue, QueueScoreInsert } from "./insert-score";
import { CreateScoreID } from "./score-id";
import { ValidateScore } from "./validate-score";
import { IsConverterFailure } from "../common/converter-failures";
import { OrphanScore } from "../orphans/orphans";
import db from "external/mongo/db";
@@ -120,11 +121,12 @@ export async function ImportIterableDatapoint<D, C>(
blacklist: Array<string>,
logger: KtLogger
): Promise<ImportProcessingInfo | null> {
// Converter Function Return
let cfnReturn: ConverterFnSuccessReturn;
try {
cfnReturn = await ConverterFunction(data, context, importType, logger);
const cfnReturn = await ConverterFunction(data, context, importType, logger);
const res = await ProcessSuccessfulConverterReturn(userID, cfnReturn, blacklist, logger);
return res;
} catch (e) {
const err = e as ConverterFailure | Error;
@@ -237,8 +239,6 @@ export async function ImportIterableDatapoint<D, C>(
}
}
}
return ProcessSuccessfulConverterReturn(userID, cfnReturn, blacklist, logger);
}
export async function ProcessSuccessfulConverterReturn(
@@ -248,7 +248,7 @@ export async function ProcessSuccessfulConverterReturn(
logger: KtLogger,
forceImmediateImport = false
): Promise<ImportProcessingInfo | null> {
const result = await HydrateAndInsertScore(
const result = await HydrateCheckAndInsertScore(
userID,
cfnReturn.dryScore,
cfnReturn.chart,
@@ -278,7 +278,7 @@ export async function ProcessSuccessfulConverterReturn(
}
/**
* Hydrates and inserts a score to the Tachi database.
* Hydrates, validates and inserts a score to the Tachi database.
* @param userID - The user this score is from.
* @param dryScore - The score that is to be hydrated and inserted.
* @param chart - The chart this score is on.
@@ -288,7 +288,7 @@ export async function ProcessSuccessfulConverterReturn(
* @param force - Whether to immediately insert the score into the database
* or not.
*/
async function HydrateAndInsertScore(
async function HydrateCheckAndInsertScore(
userID: integer,
dryScore: DryScore,
chart: ChartDocument,
@@ -336,6 +336,8 @@ async function HydrateAndInsertScore(
const score = HydrateScore(userID, dryScore, chart, song, scoreID, logger);
ValidateScore(score, chart);
let res;
if (force) {
@@ -0,0 +1,148 @@
import { InvalidScoreFailure } from "../common/converter-failures";
import { GPT_SERVER_IMPLEMENTATIONS } from "game-implementations/game-implementations";
import { GetGPTConfig, GetGPTString } from "tachi-common";
import type { ChartDocument, GPTString, ScoreDocument } from "tachi-common";
import type { ConfScoreMetric } from "tachi-common/types/metrics";
/**
* Checks if a score passes all of its validation checks. This tests gpt specific
* things, like whether this passes the validators defined in gptConfig, or whether
* it passes the chart-dependent validators defined in the gpt server implementation.
*
* @returns nothing. This will throw an InvalidScoreFailure on error.
*/
export function ValidateScore(score: ScoreDocument, chart: ChartDocument): void {
const gptString = GetGPTString(score.game, score.playtype);
const gptConfig = GetGPTConfig(gptString);
const errs: Array<string> = [];
ValidateMetrics(
errs,
gptConfig.providedMetrics,
gptString,
score,
chart,
// @ts-expect-error ughhh
(s, m) => s.scoreData[m]
);
ValidateMetrics(
errs,
gptConfig.derivedMetrics,
gptString,
score,
chart,
// @ts-expect-error ughhh
(s, m) => s.scoreData[m]
);
ValidateMetrics(
errs,
gptConfig.optionalMetrics,
gptString,
score,
chart,
// @ts-expect-error ughhh
(s, m) => s.scoreData.optional[m],
true
);
if (errs.length > 0) {
const errorStr = errs.length === 1 ? "error" : "errors";
throw new InvalidScoreFailure(`Got ${errs.length} ${errorStr} when validating score:
${errs.join("\n")}`);
}
}
function ValidateMetrics(
errs: Array<string>,
metrics: Record<string, ConfScoreMetric>,
gptString: GPTString,
score: ScoreDocument,
chart: ChartDocument,
valueGetter: (s: ScoreDocument, metric: string) => any,
optional?: boolean
) {
const gptImpl = GPT_SERVER_IMPLEMENTATIONS[gptString];
for (const [metric, conf] of Object.entries(metrics)) {
const scoreVal: any = valueGetter(score, metric);
if (optional && scoreVal === undefined) {
continue;
}
switch (conf.type) {
case "ENUM": {
if (!conf.values.includes(scoreVal)) {
errs.push(
`Invalid value for ${metric}, got ${scoreVal}, but expected any of ${conf.values.join(
", "
)}.`
);
}
break;
}
case "INTEGER":
case "DECIMAL": {
if (conf.type === "INTEGER" && !Number.isSafeInteger(scoreVal)) {
errs.push(
`Invalid value for ${metric}, got ${scoreVal}, but expected an integer.`
);
} else if (!Number.isFinite(scoreVal)) {
errs.push(
`Invalid value for ${metric}, got ${scoreVal}, but expected a finite number.`
);
}
let err: string | true;
if (conf.chartDependentMax) {
// @ts-expect-error hack, this is fine. don't worry.
err = gptImpl.validators[metric](scoreVal, chart);
} else {
err = conf.validate(scoreVal);
}
if (typeof err === "string") {
errs.push(`Invalid value for ${metric}, ${err}`);
}
break;
}
case "GRAPH":
case "NULLABLE_GRAPH": {
if (!Array.isArray(scoreVal)) {
errs.push(`Invalid value for metric ${metric}, expected an array.`);
break;
}
for (const v of scoreVal) {
if (conf.type === "NULLABLE_GRAPH" && v === null) {
continue;
}
const err = conf.validate(v);
if (typeof err === "string") {
errs.push(`Invalid value for metric ${metric}, ${err}, got ${v}.`);
}
}
if (conf.size) {
const err = conf.size(scoreVal.length);
if (typeof err === "string") {
errs.push(
`Invalid size of metric ${metric}, ${err}, got an array of size ${scoreVal.length}.`
);
}
}
}
}
}
}
+22 -7
View File
@@ -10,7 +10,10 @@ import {
import { GetFolderForIDGuaranteed, HumaniseChartID } from "utils/db";
import { GetFolderChartIDs } from "utils/folder";
import { FormatMaxDP, HumanisedJoinArray } from "utils/misc";
import type { GoalCriteriaFormatter } from "game-implementations/types";
import type {
ChartSpecificMetricValidator,
GoalCriteriaFormatter,
} from "game-implementations/types";
import type { GPTString, Game, GoalDocument, Playtype } from "tachi-common";
export async function CreateGoalTitle(
@@ -146,6 +149,8 @@ function FormatCriteria<GPT extends GPTString>(
if (v === undefined) {
throw new Error(`Invalid criteria value '${criteria.value}'.`);
}
return v;
} else if (conf.type === "DECIMAL" || conf.type === "INTEGER") {
const fmt: GoalCriteriaFormatter | undefined =
// @ts-expect-error it still thinks criteria.key might be a symbol.
@@ -288,19 +293,29 @@ export async function ValidateGoalChartsAndCriteria(
);
}
let validateFn: (v: number) => string | true;
let err;
if (config.chartDependentMax) {
const chart = await db.anyCharts[game].findOne({
playtype,
// guaranteed by previous if statement
chartID: charts.data as string,
});
if (!chart) {
throw new Error(
`Chart ${charts.data} was removed from the database while a goal was being validated on it?`
);
}
// @ts-expect-error this is fine leave me alone
validateFn = gptImpl.validators[criteria.key]!;
err = gptImpl.validators[criteria.key](criteria.value, chart);
} else {
validateFn = config.validate;
err = config.validate(criteria.value);
}
const err = validateFn(criteria.value);
if (err !== true) {
throw new Error(`Invalid value ${criteria.value} for ${criteria.key}, ${err}.`);
throw new Error(`Invalid value ${criteria.value} for ${criteria.key}, ${err}`);
}
break;
@@ -314,11 +314,13 @@ t.test("POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal", asyn
value: -1,
},
}),
mkInput("percent of 0 is a non-goal", {
criteria: {
value: 0,
},
}),
// as stupid as it is, it technically "is" a goal, as it's a valid
// value for percent.
// mkInput("percent of 0 is a non-goal", {
// criteria: {
// value: 0,
// },
// }),
mkInput("percent greater than 100", {
criteria: {
value: 100.1,
@@ -330,6 +332,12 @@ t.test("POST /api/v1/users/:userID/games/:game/:playtype/targets/add-goal", asyn
value: IIDX_GRADES.MAX + 1,
},
}),
mkInput("string enum", {
criteria: {
key: "grade",
value: IIDX_GRADES.MAX.toString(),
},
}),
mkInput("invalid grade", {
criteria: {
key: "grade",
@@ -156,13 +156,15 @@ router.post(
const validCriteria = [
...Object.keys(gptConfig.providedMetrics),
Object.keys(gptConfig.derivedMetrics),
...Object.keys(gptConfig.derivedMetrics),
];
if (!validCriteria.includes(req.body.criteria.key)) {
return res.status(400).json({
success: false,
description: `Invalid criteria, expected any of ${validCriteria.join(", ")}.`,
description: `Invalid criteria '${
req.body.criteria.key
}', expected any of ${validCriteria.join(", ")}.`,
});
}
@@ -6,11 +6,11 @@ import { BMSGazerChart, GetKTDataJSON } from "test-utils/test-data";
import type { PBScoreDocument } from "tachi-common";
const pbScore = {
composedFrom: {
lampPB: "mock_lampPB",
},
composedFrom: [{ name: "Best Score", scoreID: "mock_lampPB" }],
scoreData: {
lampIndex: 4,
enumIndexes: {
lamp: 4,
},
score: 1234,
optional: {},
},
@@ -24,13 +24,15 @@ t.test("GET /ir/beatoraja/charts/:chartSHA256/scores", (t) => {
t.test("Should return PB scores on a chart", async (t) => {
// very lazy fake scores
await db["personal-bests"].insert({
composedFrom: {
lampPB: "mock_lampPB",
},
composedFrom: [{ name: "Best Lamp", scoreID: "mock_lampPB" }],
scoreData: {
lampIndex: 4,
score: 1234,
optional: {},
enumIndexes: {
lamp: 4,
},
optional: {
enumIndexes: {},
},
},
scoreMeta: {},
chartID: GAZER_CHARTID,
@@ -166,7 +166,70 @@ t.test("POST /ir/direct-manual/import", async (t) => {
t.equal(res.body.success, false, "Should not be successful");
t.match(
res.body.description,
/Invalid BATCH-MANUAL: scores\[0\].score \| Expected a positive integer. \| Received 123\.5/iu
/Invalid BATCH-MANUAL: scores\[0\].score \| Expected an integer. \| Received 123\.5/iu
);
t.end();
});
t.test("Should apply chart specific validators for scores.", async (t) => {
const bmScore: BatchManualScore = {
identifier: "1",
lamp: "CLEAR",
matchType: "tachiSongID",
difficulty: "ANOTHER",
// greater than the max possible score on this chart
score: 9000,
};
const res = await mockApi
.post("/ir/direct-manual/import")
.set("Authorization", `Bearer mock_token`)
.send(deepmerge(baseBatchManual, { scores: [bmScore] }));
t.equal(res.body.success, true, "Should be successful but with one failed score");
t.strictSame(
res.body.body.errors,
[
{
type: "InvalidDatapoint",
message: `Got 1 error when validating score:
Invalid value for score, EX Score cannot be greater than 1572 for this chart. Got 9000.`,
},
],
"Should have one failed score."
);
t.end();
});
t.test("Should reject negative values for jubeat scores.", async (t) => {
const bmScore: BatchManualScore = {
identifier: "1",
lamp: "CLEAR",
matchType: "tachiSongID",
difficulty: "EXT",
score: -100,
musicRate: 50,
};
const res = await mockApi
.post("/ir/direct-manual/import")
.set("Authorization", `Bearer mock_token`)
.send(
deepmerge(baseBatchManual, {
scores: [bmScore],
meta: {
game: "jubeat",
playtype: "Single",
},
})
);
t.equal(res.body.success, false, "Should not be successful");
t.match(
res.body.description,
"Invalid BATCH-MANUAL: scores[0].score | Expected a number between 0 and 1000000. | Received -100 [type: number]."
);
t.end();
@@ -233,7 +233,7 @@ t.test("POST /ir/fervidex/class/submit", (t) => {
const ugs = await db["game-stats"].findOne({ userID: 1, game: "iidx", playtype: "SP" });
t.equal(ugs?.classes.dan, 18);
t.equal(ugs?.classes.dan, "KAIDEN");
const recentAchievement = await db["class-achievements"].findOne({
userID: 1,
@@ -267,7 +267,7 @@ t.test("POST /ir/fervidex/class/submit", (t) => {
const ugs = await db["game-stats"].findOne({ userID: 1, game: "iidx", playtype: "DP" });
t.equal(ugs?.classes.dan, 17);
t.equal(ugs?.classes.dan, "CHUUDEN");
t.end();
});
@@ -479,7 +479,7 @@ t.test("POST /ir/fervidex/profile/submit", (t) => {
playtype: "SP",
});
t.equal(ugs!.classes.dan, 15, "Should successfully update dan to 9th.");
t.equal(ugs!.classes.dan, "DAN_9", "Should successfully update dan to 9th.");
t.end();
});
@@ -520,7 +520,7 @@ t.test("POST /ir/fervidex/profile/submit", (t) => {
playtype: "SP",
});
t.equal(ugs!.classes.dan, 15, "Should successfully update dan to 9th.");
t.equal(ugs!.classes.dan, "DAN_9", "Should successfully update dan to 9th.");
const dbRes = await db["fer-settings"].findOne({ userID: 1 });
@@ -307,7 +307,7 @@ t.test("GET /charts/:chartHash/leaderboard", (t) => {
score: 8_000_000,
},
rankingData: { rank: 2 },
composedFrom: { scorePB: "other_usc_score_pb" },
composedFrom: [{ name: "Best Score", scoreID: "other_usc_score_pb" }],
}),
]);
@@ -1,7 +1,7 @@
{
"Invalid Goal: negative percent": "[criteria.value] Expected number to be greater than or equal to 0. (Received -1)",
"Invalid Goal: percent of 0 is a non-goal": "Invalid value of 0 for percent goal. Percents must be between 0 and 100.",
"Invalid Goal: percent greater than 100": "Invalid value of 100.1 for percent goal. Percents must be between 0 and 100.",
"Invalid Goal: percent greater than 100": "Invalid value 100.1 for percent, Expected a number between 0 and 100.",
"Invalid Goal: abs without countNum": "[criteria.countNum] Expected number to be greater than or equal to 0. (Received nothing (undefined))",
"Invalid Goal: proportion without countNum": "[criteria.countNum] Expected number to be greater than or equal to 0. (Received nothing (undefined))",
"Invalid Goal: single with countNum": "[criteria.countNum] Invalid countNum for mode 'single'. Must not have one! (Received 123)",
@@ -15,7 +15,7 @@
"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": "Creating score goals on multiple charts where the maximum value is relative to the chart 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.",
@@ -24,11 +24,12 @@
"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 (0.05) is too small for a goal with 2 charts. 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: folder-score for iidx is illegal": "Creating score goals on multiple charts where the maximum value is relative to the chart 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 'absolute'. Expected a whole number less than the total amount of charts available and greater than 1. (Got 2, while total charts was 1.)",
"Invalid Goal: too big score": "Invalid value of 9000 for goal. Maximum score possible on this chart is 1572.",
"Invalid Goal: too big score": "Invalid value 9000 for score, EX Score cannot be greater than 1572 for this chart. Got 9000.",
"Invalid Goal: LampIndex:Multi:Proportion Case": "Criteria Mode must be 'single' or 'absolute' if Charts Type is 'multi'. Doesn't make sense to have proportional goals when you're capped at 10 charts.",
"Invalid Goal: GradeIndex:Multi:Proportion Case": "Criteria Mode must be 'single' or 'absolute' if Charts Type is 'multi'. Doesn't make sense to have proportional goals when you're capped at 10 charts.",
"Invalid Goal: Percent:Multi:Proportion Case": "Criteria Mode must be 'single' or 'absolute' if Charts Type is 'multi'. Doesn't make sense to have proportional goals when you're capped at 10 charts."
"Invalid Goal: Percent:Multi:Proportion Case": "Criteria Mode must be 'single' or 'absolute' if Charts Type is 'multi'. Doesn't make sense to have proportional goals when you're capped at 10 charts.",
"Invalid Goal: string enum": "[criteria.value] Expected number to be greater than or equal to 0. (Received 9)"
}