Merge pull request #156 from zkldi/zkldi/155-Test-KAI-APIs

Test KAI APIs
This commit is contained in:
zkldi
2021-06-01 13:41:55 +01:00
committed by GitHub
17 changed files with 702 additions and 23 deletions
+3 -3
View File
@@ -6,9 +6,9 @@ env:
KTBSV_LOG_LEVEL: "info"
KTBSV_CAPTCHA_SECRET_KEY: "unused"
KTBSV_SESSION_SECRET: "unused"
KTBSV_FLO_API_URL: "unused"
KTBSV_EAG_API_URL: "unused"
KTBSV_ARC_API_URL: "unused"
KTBSV_FLO_API_URL: "https://flo.example.com"
KTBSV_EAG_API_URL: "https://eag.example.com"
KTBSV_ARC_API_URL: "https://arc.example.com"
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
on:
@@ -0,0 +1,99 @@
import t from "tap";
import { CloseAllConnections } from "../../../../../../test-utils/close-connections";
import ResetDBState from "../../../../../../test-utils/reset-db-state";
import {
GetKTDataJSON,
Testing511Song,
Testing511SPA,
} from "../../../../../../test-utils/test-data";
import CreateLogCtx from "../../../../../logger/logger";
import { ConvertAPIKaiIIDX } from "./converter";
import deepmerge from "deepmerge";
import db from "../../../../../../external/mongo/db";
const logger = CreateLogCtx(__filename);
const iidxScore = GetKTDataJSON("./api-kai/iidx-score.json");
t.test("#ConvertAPIKaiIIDX", (t) => {
t.beforeEach(ResetDBState);
t.test("Should return a dryScore on valid input.", async (t) => {
const res = await ConvertAPIKaiIIDX(iidxScore, { service: "FLO" }, "api/flo-iidx", logger);
t.hasStrict(res, {
song: Testing511Song,
chart: Testing511SPA,
dryScore: {
comment: null,
game: "iidx",
importType: "api/flo-iidx",
timeAchieved: 1604171450000,
service: "FLO",
scoreData: {
grade: "MAX-",
// percent: 99.87277353689568 floating point,
score: 1570,
lamp: "HARD CLEAR",
hitData: {},
hitMeta: {
fast: null,
slow: null,
bp: 24,
},
},
scoreMeta: {},
},
} as any);
t.end();
});
t.test("Should throw KTDataNotFound on unknown chart", (t) => {
t.rejects(
() =>
ConvertAPIKaiIIDX(
deepmerge(iidxScore, { music_id: 0 }),
{ service: "FLO" },
"api/flo-iidx",
logger
),
{
message: /Could not find chart with songID 0 \(SP ANOTHER - Version 26\)/u,
} as any
);
t.end();
});
t.test("Should throw InvalidScoreFailure on invalid score", (t) => {
t.rejects(
() =>
ConvertAPIKaiIIDX(
deepmerge(iidxScore, { music_id: "foo" }),
{ service: "FLO" },
"api/flo-iidx",
logger
),
{
message: /Error: music_id.*Expected a positive integer.* received foo \[string\]/iu,
} as any
);
t.end();
});
t.test("Should throw InternalFailure on song-chart desync", async (t) => {
await db.songs.iidx.remove({});
t.rejects(() => ConvertAPIKaiIIDX(iidxScore, { service: "FLO" }, "api/flo-iidx", logger), {
message: /Song-Chart desync/u,
} as any);
t.end();
});
t.end();
});
t.teardown(CloseAllConnections);
@@ -24,8 +24,8 @@ const PR_KaiIIDXScore = {
lamp: p.isBoundedInteger(0, 7),
ex_score: p.isPositiveInteger,
miss_count: p.or(p.isPositiveInteger, p.is(-1)),
fast_count: p.isPositiveInteger,
slow_count: p.isPositiveInteger,
fast_count: p.nullable(p.isPositiveInteger),
slow_count: p.nullable(p.isPositiveInteger),
timestamp: "string",
};
@@ -0,0 +1,89 @@
import t from "tap";
import { CloseAllConnections } from "../../../../../../test-utils/close-connections";
import { MockJSONFetch } from "../../../../../../test-utils/mock-fetch";
import ResetDBState from "../../../../../../test-utils/reset-db-state";
import CreateLogCtx from "../../../../../logger/logger";
import { ParseKaiIIDX } from "./parser";
const fakeAuth = {
userID: 1,
refreshToken: "foo",
service: "FLO" as const,
token: "bar",
};
const logger = CreateLogCtx(__filename);
t.test("#ParseKaiIIDX", (t) => {
t.beforeEach(ResetDBState);
t.test("Should iterate over the IIDX FLO API asynchronously.", async (t) => {
const mockFloAPI = MockJSONFetch({
"https://flo.example.com/api/iidx/v2/play_history": {
_links: {
_next: "https://flo.example.com/api/iidx/v2/play_history?page=2",
},
_items: [1, 2, 3, 4],
},
"https://flo.example.com/api/iidx/v2/play_history?page=2": {
_links: {
_next: null,
},
_items: [5, 6],
},
});
const res = ParseKaiIIDX("FLO", fakeAuth, logger, mockFloAPI);
t.equal(res.game, "iidx");
t.equal(res.classHandler, null);
t.strictSame(res.context, { service: "FLO" });
const iter = [];
for await (const el of res.iterable) {
iter.push(el);
}
t.strictSame(iter, [1, 2, 3, 4, 5, 6]);
t.end();
});
t.test("Should iterate over the IIDX EAG API asynchronously.", async (t) => {
const mockEagAPI = MockJSONFetch({
"https://eag.example.com/api/iidx/v2/play_history": {
_links: {
_next: "https://eag.example.com/api/iidx/v2/play_history?page=2",
},
_items: [1, 2, 3, 4],
},
"https://eag.example.com/api/iidx/v2/play_history?page=2": {
_links: {
_next: null,
},
_items: [5, 6],
},
});
const res = ParseKaiIIDX("EAG", fakeAuth, logger, mockEagAPI);
t.equal(res.game, "iidx");
t.equal(res.classHandler, null);
t.strictSame(res.context, { service: "EAG" });
const iter = [];
for await (const el of res.iterable) {
iter.push(el);
}
t.strictSame(iter, [1, 2, 3, 4, 5, 6]);
t.end();
});
t.end();
});
t.teardown(CloseAllConnections);
@@ -2,7 +2,6 @@ import { KtLogger } from "../../../../../logger/logger";
import nodeFetch from "../../../../../../utils/fetch";
import { KaiAuthDocument } from "kamaitachi-common";
import { KaiContext } from "../types";
import { ConvertAPIKaiIIDX } from "./converter";
import { TraverseKaiAPI } from "../traverse-api";
import { ParserFunctionReturnsAsync } from "../../types";
import { FLO_API_URL, EAG_API_URL } from "../../../../../env/env";
@@ -0,0 +1,133 @@
import t from "tap";
import { CloseAllConnections } from "../../../../../../test-utils/close-connections";
import ResetDBState from "../../../../../../test-utils/reset-db-state";
import {
GetKTDataJSON,
TestingAlbidaADV,
TestingSDVXAlbidaSong,
} from "../../../../../../test-utils/test-data";
import CreateLogCtx from "../../../../../logger/logger";
import { ConvertAPIKaiSDVX, ConvertDifficulty, ConvertVersion, ResolveKaiLamp } from "./converter";
import deepmerge from "deepmerge";
import db from "../../../../../../external/mongo/db";
const logger = CreateLogCtx(__filename);
const sdvxScore = GetKTDataJSON("./api-kai/sdvx-score.json");
t.test("#ConvertAPIKaiSDVX", (t) => {
t.beforeEach(ResetDBState);
t.test("Should return a dryScore on valid input.", async (t) => {
const res = await ConvertAPIKaiSDVX(sdvxScore, { service: "FLO" }, "api/flo-sdvx", logger);
t.hasStrict(res, {
song: TestingSDVXAlbidaSong,
chart: TestingAlbidaADV,
dryScore: {
comment: null,
game: "sdvx",
importType: "api/flo-sdvx",
timeAchieved: 1598792891000,
service: "FLO",
scoreData: {
grade: "AA",
// percent: 93.10699, floating point
score: 9310699,
lamp: "EXCESSIVE CLEAR",
hitData: {},
hitMeta: {
fast: 70,
slow: 42,
gauge: 90,
maxCombo: 179,
},
},
scoreMeta: {},
},
} as any);
t.end();
});
t.test("Should throw KTDataNotFound on unknown chart", (t) => {
t.rejects(
() =>
ConvertAPIKaiSDVX(
deepmerge(sdvxScore, { music_id: 0 }),
{ service: "FLO" },
"api/flo-sdvx",
logger
),
{
message: /Could not find chart with songID 0 \(ADV - Version vivid\)/u,
} as any
);
t.end();
});
t.test("Should throw InvalidScoreFailure on invalid score", (t) => {
t.rejects(
() =>
ConvertAPIKaiSDVX(
deepmerge(sdvxScore, { music_id: "foo" }),
{ service: "FLO" },
"api/flo-sdvx",
logger
),
{
message: /Error: music_id.*Expected a positive integer.* received foo \[string\]/iu,
} as any
);
t.end();
});
t.test("Should throw InternalFailure on song-chart desync", async (t) => {
await db.songs.sdvx.remove({});
t.rejects(() => ConvertAPIKaiSDVX(sdvxScore, { service: "FLO" }, "api/flo-sdvx", logger), {
message: /Song-Chart desync/u,
} as any);
t.end();
});
t.end();
});
t.test("#ConvertDifficulty", (t) => {
t.equal(ConvertDifficulty(0), "NOV");
t.equal(ConvertDifficulty(1), "ADV");
t.equal(ConvertDifficulty(2), "EXH");
t.equal(ConvertDifficulty(3), "ANY_INF");
t.equal(ConvertDifficulty(4), "MXM");
t.throws(() => ConvertDifficulty(5));
t.end();
});
t.test("#ConvertVersion", (t) => {
t.equal(ConvertVersion(0), "booth");
t.equal(ConvertVersion(1), "inf");
t.equal(ConvertVersion(2), "gw");
t.equal(ConvertVersion(3), "heaven");
t.equal(ConvertVersion(4), "vivid");
t.throws(() => ConvertVersion(5));
t.end();
});
t.test("#ResolveKaiLamp", (t) => {
t.equal(ResolveKaiLamp(0), "FAILED");
t.equal(ResolveKaiLamp(1), "CLEAR");
t.equal(ResolveKaiLamp(2), "EXCESSIVE CLEAR");
t.equal(ResolveKaiLamp(3), "ULTIMATE CHAIN");
t.equal(ResolveKaiLamp(4), "PERFECT ULTIMATE CHAIN");
t.throws(() => ConvertDifficulty(5));
t.end();
});
t.teardown(CloseAllConnections);
@@ -48,16 +48,13 @@ export const ConvertAPIKaiSDVX: ConverterFunction<unknown, KaiContext> = async (
const score = data as KaiSDVXScore;
const difficulty = ConvertDifficulty(score.music_difficulty);
const version = ConvertVersion(score.played_version);
const chart = await FindSDVXChartOnInGameIDVersion(
score.music_id,
difficulty,
score.played_version.toString()
);
const chart = await FindSDVXChartOnInGameIDVersion(score.music_id, difficulty, version);
if (!chart) {
throw new KTDataNotFoundFailure(
`Could not find chart with songID ${score.music_id} (${difficulty} - Version ${score.played_version})`,
`Could not find chart with songID ${score.music_id} (${difficulty} - Version ${version})`,
importType,
data,
context
@@ -102,7 +99,7 @@ export const ConvertAPIKaiSDVX: ConverterFunction<unknown, KaiContext> = async (
return { song, chart, dryScore };
};
function ConvertDifficulty(diff: number) {
export function ConvertDifficulty(diff: number) {
switch (diff) {
case 0:
return "NOV";
@@ -119,7 +116,24 @@ function ConvertDifficulty(diff: number) {
throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`);
}
function ResolveKaiLamp(clear: number): Lamps["sdvx:Single"] {
export function ConvertVersion(ver: number) {
switch (ver) {
case 0:
return "booth";
case 1:
return "inf";
case 2:
return "gw";
case 3:
return "heaven";
case 4:
return "vivid";
}
throw new InvalidScoreFailure(`Unknown Game Version ${ver}.`);
}
export function ResolveKaiLamp(clear: number): Lamps["sdvx:Single"] {
switch (clear) {
case 0:
return "FAILED";
@@ -0,0 +1,89 @@
import t from "tap";
import { CloseAllConnections } from "../../../../../../test-utils/close-connections";
import { MockJSONFetch } from "../../../../../../test-utils/mock-fetch";
import ResetDBState from "../../../../../../test-utils/reset-db-state";
import CreateLogCtx from "../../../../../logger/logger";
import { ParseKaiSDVX } from "./parser";
const fakeAuth = {
userID: 1,
refreshToken: "foo",
service: "FLO" as const,
token: "bar",
};
const logger = CreateLogCtx(__filename);
t.test("#ParseKaiSDVX", (t) => {
t.beforeEach(ResetDBState);
t.test("Should iterate over the SDVX FLO API asynchronously.", async (t) => {
const mockFloAPI = MockJSONFetch({
"https://flo.example.com/api/sdvx/v1/play_history": {
_links: {
_next: "https://flo.example.com/api/sdvx/v1/play_history?page=2",
},
_items: [1, 2, 3, 4],
},
"https://flo.example.com/api/sdvx/v1/play_history?page=2": {
_links: {
_next: null,
},
_items: [5, 6],
},
});
const res = ParseKaiSDVX("FLO", fakeAuth, logger, mockFloAPI);
t.equal(res.game, "sdvx");
t.equal(res.classHandler, null);
t.strictSame(res.context, { service: "FLO" });
const iter = [];
for await (const el of res.iterable) {
iter.push(el);
}
t.strictSame(iter, [1, 2, 3, 4, 5, 6]);
t.end();
});
t.test("Should iterate over the SDVX EAG API asynchronously.", async (t) => {
const mockEagAPI = MockJSONFetch({
"https://eag.example.com/api/sdvx/v1/play_history": {
_links: {
_next: "https://eag.example.com/api/sdvx/v1/play_history?page=2",
},
_items: [1, 2, 3, 4],
},
"https://eag.example.com/api/sdvx/v1/play_history?page=2": {
_links: {
_next: null,
},
_items: [5, 6],
},
});
const res = ParseKaiSDVX("EAG", fakeAuth, logger, mockEagAPI);
t.equal(res.game, "sdvx");
t.equal(res.classHandler, null);
t.strictSame(res.context, { service: "EAG" });
const iter = [];
for await (const el of res.iterable) {
iter.push(el);
}
t.strictSame(iter, [1, 2, 3, 4, 5, 6]);
t.end();
});
t.end();
});
t.teardown(CloseAllConnections);
@@ -2,7 +2,6 @@ import { KtLogger } from "../../../../../logger/logger";
import nodeFetch from "../../../../../../utils/fetch";
import { KaiAuthDocument } from "kamaitachi-common";
import { KaiContext } from "../types";
import { ConvertAPIKaiSDVX } from "./converter";
import { TraverseKaiAPI } from "../traverse-api";
import { ParserFunctionReturnsAsync } from "../../types";
import { EAG_API_URL, FLO_API_URL } from "../../../../../env/env";
@@ -21,6 +20,6 @@ export function ParseKaiSDVX(
service,
},
classHandler: null,
game: "iidx",
game: "sdvx",
};
}
@@ -0,0 +1,173 @@
import t from "tap";
import { CloseAllConnections } from "../../../../../test-utils/close-connections";
import { MockJSONFetch } from "../../../../../test-utils/mock-fetch";
import ResetDBState from "../../../../../test-utils/reset-db-state";
import CreateLogCtx from "../../../../logger/logger";
import ScoreImportFatalError from "../../../framework/score-importing/score-import-error";
import { TraverseKaiAPI } from "./traverse-api";
const logger = CreateLogCtx(__filename);
const fakeAuth = {
userID: 1,
refreshToken: "foo",
service: "FLO" as const,
token: "bar",
};
/**
* Async Generator To Array
*/
async function agta(ag: AsyncGenerator<unknown>) {
const a = [];
for await (const el of ag) {
a.push(el);
}
return a;
}
t.test("#TraverseKaiAPI", (t) => {
t.beforeEach(ResetDBState);
t.test("Should traverse the API", async (t) => {
const mockKaiAPI = MockJSONFetch({
"http://url.com/sub": {
_links: {
_next: "http://url.com/sub?page=2",
},
_items: [1, 2, 3, 4],
},
"http://url.com/sub?page=2": {
_links: {
_next: null,
},
_items: [5, 6],
},
});
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
const elements = await agta(res);
t.strictSame(elements, [1, 2, 3, 4, 5, 6]);
t.end();
});
t.test("Should throw on attempted SSRF", (t) => {
const mockKaiAPI = MockJSONFetch({
"http://url.com/sub": {
_links: {
_next: "http://evil.com/sub?page=2",
},
_items: [1, 2, 3, 4],
},
"http://url.com/sub?page=2": {
_links: {
_next: null,
},
_items: [5, 6],
},
});
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
t.rejects(
() => agta(res),
new ScoreImportFatalError(500, `http://url.com returned invalid data.`)
);
t.end();
});
t.test("Should throw on invalid response JSON", (t) => {
const mockKaiAPI = () => ({ json: null } as any);
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
t.rejects(() => agta(res));
t.end();
});
t.test("Should throw on request failure", (t) => {
const mockKaiAPI = () => {
throw new Error("Fake Request timeout...");
};
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
t.rejects(() => agta(res));
t.end();
});
t.test("Should throw on invalid _links", (t) => {
const mockKaiAPI = MockJSONFetch({
"http://url.com/sub": {
_links: null,
},
});
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
t.rejects(() => agta(res));
const mockKaiAPI2 = MockJSONFetch({
"http://url.com/sub": {
_links: "foo",
},
});
const res2 = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI2);
t.rejects(() => agta(res2));
t.end();
});
t.test("Should throw on invalid _links._next", (t) => {
const mockKaiAPI = MockJSONFetch({
"http://url.com/sub": {
_links: {},
},
});
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
t.rejects(() => agta(res));
const mockKaiAPI2 = MockJSONFetch({
"http://url.com/sub": {
_links: {
_next: {},
},
},
});
const res2 = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI2);
t.rejects(() => agta(res2));
t.end();
});
t.test("Should throw on invalid _items", (t) => {
const mockKaiAPI = MockJSONFetch({
"http://url.com/sub": {
_links: {
_next: null,
},
_items: {},
},
});
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
t.rejects(() => agta(res));
t.end();
});
t.end();
});
t.teardown(CloseAllConnections);
@@ -2,7 +2,7 @@ import { CreateInviteCode, AddNewInvite, ReinstateInvite, ValidateCaptcha } from
import t from "tap";
import db from "../../../../../external/mongo/db";
import ResetDBState from "../../../../../test-utils/reset-db-state";
import { MockFetch } from "../../../../../test-utils/mock-fetch";
import { MockBasicFetch } from "../../../../../test-utils/mock-fetch";
import { CloseAllConnections } from "../../../../../test-utils/close-connections";
t.test("#ReinstateInvite", (t) => {
@@ -61,13 +61,13 @@ t.test("#AddNewInvite", (t) => {
t.test("#ValidateCaptcha", async (t) => {
t.equal(
await ValidateCaptcha("200", "bar", MockFetch({ status: 200 })),
await ValidateCaptcha("200", "bar", MockBasicFetch({ status: 200 })),
true,
"Validates captcha when status return is 200"
);
t.equal(
await ValidateCaptcha("400", "bar", MockFetch({ status: 400 })),
await ValidateCaptcha("400", "bar", MockBasicFetch({ status: 400 })),
false,
"Invalidates captcha when status return is not 200"
);
+22 -3
View File
@@ -1,8 +1,27 @@
// We know! these are mock functions.
/* eslint-disable require-await */
import { Response } from "node-fetch";
import { NodeFetch } from "../utils/fetch";
export function MockFetch(data: Partial<Response>) {
// We know! this is a mock function.
// eslint-disable-next-line require-await
/**
* Creates a basic Fetch function used for statusCode checks.
*/
export function MockBasicFetch(data: Partial<Response>) {
return ((async () => data) as unknown) as NodeFetch;
}
/**
* Creates a fake "fetch" function which takes a url and returns
* a fake JSON -> data function at that key.
*/
export function MockJSONFetch(urlDataMap: Record<string, unknown>) {
return (async (url: string) => {
if (urlDataMap[url]) {
return {
json: async () => urlDataMap[url],
};
}
throw new Error(`Unexpected url ${url} - No Data Present?`);
}) as NodeFetch;
}
+8
View File
@@ -46,6 +46,14 @@ export const Testing511Song = GetKTDataJSON(
"./kamaitachi/iidx-511-song.json"
) as SongDocument<"iidx">;
export const TestingAlbidaADV = GetKTDataJSON(
"./kamaitachi/sdvx-albida-chart.json"
) as ChartDocument<"sdvx:Single">;
export const TestingSDVXAlbidaSong = GetKTDataJSON(
"./kamaitachi/sdvx-albida-song.json"
) as SongDocument<"sdvx">;
export const TestingDoraChart = GetKTDataJSON(
"./kamaitachi/gitadora-ithinkabtyou.json"
) as ChartDocument<"gitadora:Dora">;
@@ -7,7 +7,7 @@
"iidx_id": 35247879,
"version_played": 26,
"lamp": 5,
"ex_score": 2570,
"ex_score": 1570,
"grade": "AA",
"miss_count": 24,
"fast_count": null,
@@ -0,0 +1,18 @@
{
"sdvx_id": 32157055,
"music_id": 1,
"music_difficulty": 1,
"played_version": 4,
"clear_type": 2,
"max_chain": 179,
"score": 9310699,
"critical": 1754,
"near": 112,
"error": 78,
"early": 70,
"late": 42,
"gauge_type": 0,
"gauge_rate": 90,
"timestamp": "2020-08-30T13:08:11Z",
"_id": 127108
}
@@ -0,0 +1,25 @@
{
"rgcID": null,
"chartID": "5088a4d0e1ee9d0cc2f625934306e45b1a60699b",
"difficulty": "ADV",
"songID": 1,
"playtype": "Single",
"levelNum": 10,
"level": "10",
"flags": {
"IN BASE GAME": true,
"OMNIMIX": false,
"N-1": false
},
"data": {
"inGameID": 1
},
"isPrimary": true,
"versions": [
"booth",
"inf",
"gw",
"heaven",
"vivid"
]
}
@@ -0,0 +1,14 @@
{
"title": "ALBIDA Powerless Mix",
"artist": "無力P",
"id": 1,
"firstVersion": "booth",
"alt-titles": [],
"search-titles": [
"albida_muryoku",
"アルビダパワーレスミックス"
],
"data": {
"uscEquiv": null
}
}