From f4b778907583e3b69700ba777334896ddf35169c Mon Sep 17 00:00:00 2001 From: Taevas <67872932+TTTaevas@users.noreply.github.com> Date: Sat, 30 Mar 2024 19:20:21 +0100 Subject: [PATCH] Write tests for functions that need scopes, also fix two things First thing being a User.Extended's playstyle's ability to be null Second thing being keepChatAlive() not properly returnng user silences --- .env.example | 4 +- CONTRIBUTING.md | 1 + lib/beatmapset.ts | 2 +- lib/chat.ts | 5 +- lib/tests/test.ts | 14 ++- lib/tests/test_authorized.ts | 229 ++++++++++++++++++++++++++++++----- lib/user.ts | 4 +- 7 files changed, 224 insertions(+), 35 deletions(-) diff --git a/.env.example b/.env.example index 59c39c0..298ca17 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ ID=72727 SECRET=aPOPR151Atgb145f74aAJpAoBDSYS47402 -REDIRECT_URI=http://localhost:7272 \ No newline at end of file +REDIRECT_URI=http://localhost:7272 +DEV_ID=72 +DEV_SECRET=KVAkotr48hytR201eAR4zkAOWp4Qu59AW \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2563858..380aa52 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,6 +5,7 @@ git clone https://github.com//osu-api-v2-js.git # Clone your fork o yarn install # Add its dependencies using Yarn Classic # Duplicate the .env.example file and rename it .env, then fill it with the details of one of your clients +# Note: the env variables starting with DEV are only used in test_authorized if the server used isn't osu.ppy.sh (and is for example a development server) # As a reminder, you may find and create your clients at https://osu.ppy.sh/home/account/edit#oauth yarn run test # Check that everything seems to be in order diff --git a/lib/beatmapset.ts b/lib/beatmapset.ts index 4b85dce..54c7fc0 100644 --- a/lib/beatmapset.ts +++ b/lib/beatmapset.ts @@ -98,7 +98,7 @@ export namespace Beatmapset { export interface Event { id: number /** - * @privateRemarks Searching for `approve` events brings nothing, yet the code seems to indicate it exists, so I'm keeping it here + * @remarks "approve" is currently not used, it's here just in case that ever changes * https://github.com/ppy/osu-web/blob/master/app/Models/BeatmapsetEvent.php */ type: "nominate" | "love" | "remove_from_loved" | "qualify" | "disqualify" | "approve" | "rank" | diff --git a/lib/chat.ts b/lib/chat.ts index ead4944..ef2adff 100644 --- a/lib/chat.ts +++ b/lib/chat.ts @@ -49,7 +49,7 @@ export namespace Chat { } /** - * Get a ChatChannel, and the users in it if it is a private channel! + * Get a ChatChannel that you have joined, and the users in it if it is a private channel! * @scope {@link Scope"chat.read"} * @param channel The channel in question * @remarks Will 404 if the user has not joined the channel (use `joinChatChannel` for that) @@ -201,6 +201,7 @@ export namespace Chat { Promise { const history_since = since?.user_silence ? getId(since.user_silence) : undefined const message_since = since?.message ? getId(since.message, "message_id") : undefined - return await this.request("post", "chat/ack", {history_since, since: message_since}) + const response = await this.request("post", "chat/ack", {history_since, since: message_since}) + return response.silences // It's the only property } } diff --git a/lib/tests/test.ts b/lib/tests/test.ts index d205810..493659c 100644 --- a/lib/tests/test.ts +++ b/lib/tests/test.ts @@ -156,10 +156,20 @@ const testBeatmapsetDiscussion = async (): Promise => { const testBeatmapset = async (): Promise => { console.log("\n===> BEATMAPSET") let okay = true + const a = await attempt(api.getBeatmapset, 1971037) if (!isOk(a, !a || (a.submitted_date?.toISOString().substring(0, 10) === "2023-04-07", validate(a, "Beatmapset.Extended.Plus")))) okay = false - const b = await attempt(api.getBeatmapsetEvents) - if (!isOk(b, !b || (validate(b.events, "Beatmapset.Event.Any") && validate(b.users, "User.WithGroups")))) okay = false + + const b1 = await attempt(api.getBeatmapsetEvents) + if (!isOk(b1, !b1 || (validate(b1.events, "Beatmapset.Event.Any") && validate(b1.users, "User.WithGroups")))) okay = false + const b2 = await attempt(api.getBeatmapsetEvents, {}, ["beatmap_owner_change", "genre_edit", "language_edit", "nsfw_toggle", "offset_edit", "tags_edit"]) + if (!isOk(b2, !b2 || (validate(b2.events, "Beatmapset.Event.AnyBeatmapChange")))) okay = false + const b3 = await attempt(api.getBeatmapsetEvents, {}, ["qualify", "love", "nominate", "remove_from_loved", "nomination_reset", "nomination_reset_received"]) + if (!isOk(b3, !b3 || (validate(b3.events, "Beatmapset.Event.AnyBeatmapsetStatusChange")))) okay = false + const b4 = await attempt(api.getBeatmapsetEvents, {}, ["discussion_delete", "discussion_restore", "kudosu_recalculate", "kudosu_allow", "kudosu_deny", + "discussion_post_delete", "discussion_post_restore", "discussion_lock", "discussion_unlock", "issue_resolve", "issue_reopen", "kudosu_gain", "kudosu_lost"]) + if (!isOk(b4, !b4 || (validate(b4.events, "Beatmapset.Event.AnyDiscussionChange")))) okay = false + return okay } diff --git a/lib/tests/test_authorized.ts b/lib/tests/test_authorized.ts index 3c3ed46..525b97e 100644 --- a/lib/tests/test_authorized.ts +++ b/lib/tests/test_authorized.ts @@ -3,46 +3,221 @@ * The token is considered by the API as myself */ -import "dotenv/config" import * as osu from "../index.js" +import "dotenv/config" +import util from "util" + +import tsj from "ts-json-schema-generator" +import ajv from "ajv" + import promptSync from "prompt-sync" import { exec } from "child_process" -import util from "util" +let api: osu.API +const generator = tsj.createGenerator({path: "lib/index.ts", additionalProperties: true}) const prompt = promptSync({sigint: true}) -const server = "https://osu.ppy.sh" +const server: string = "https://dev.ppy.sh" + +async function attempt any>(fun: T, ...args: Parameters): Promise | false> { + process.stdout.write(fun.name + ": ") + try { + const result = await fun.call(api, ...args) + return result + } catch(err) { + console.error(err) + return false + } +} + +function isOk(response: any, condition?: boolean, depth: number = Infinity) { + if (condition === undefined) condition = true + if (!response || !condition) { + if (Array.isArray(response) && response[0]) { + console.log("(only printing the first element of the response array for the error below)") + response = response[0] + } + console.error("❌ Bad response:", util.inspect(response, {colors: true, compact: true, breakLength: 400, depth: depth})) + return false + } + return true +} + +// ajv will not work properly if type is not changed from string to object where format is date-time +function fixDate(x: any) { + if (typeof x === "object" && x !== null) { + if (x["format"] && x["format"] === "date-time" && x["type"] && x["type"] === "string") { + x["type"] = "object" + } + + const k = Object.keys(x) + const v = Object.values(x) + for (let i = 0; i < k.length; i++) { + x[k[i]] = fixDate(v[i]) + } + } + + return x +} + +function validate(object: unknown, schemaName: string): boolean { + try { + const schema = fixDate(generator.createSchema(schemaName)) + const ajv_const = new ajv.default({strict: false}) + ajv_const.addFormat("date-time", true) + const validator = ajv_const.compile(schema) + + if (Array.isArray(object)) { + for (let i = 0; i < object.length; i++) { + const result = validator(object[i]) + if (validator.errors) console.error(validator.errors) + if (!result) return false + } + return true + } else { + const result = validator(object) + if (validator.errors) console.error(validator.errors) + return result + } + } catch(err) { + console.log(err) + return false + } +} + + +// THE ACTUAL TESTS + +const testChat = async (): Promise => { + let okay = true + console.log("\n===> CHAT") + if (server === "https://osu.ppy.sh") console.warn("⚠️ DOING THE TESTS ON THE ACTUAL OSU SERVER") -function sleep(seconds: number) { - return new Promise(resolve => setTimeout(resolve, seconds * 1000)) + const a = await attempt(api.getChatChannels) + if (!isOk(a, !a || (validate(a, "Chat.Channel")))) okay = false + + if (a && a.length) { + const channels = a.filter((c) => c.moderated === false) // make sure you can write in those channels + const b = await attempt(api.joinChatChannel, channels[0]) + if (!isOk(b, !b || (validate(b, "Chat.Channel.WithDetails")))) okay = false + const c = await attempt(api.getChatChannel, channels[0]) + if (!isOk(c, !c || (validate(c, "Chat.Channel.WithDetails")))) okay = false + const d = await attempt(api.getChatMessages, channels[0]) + if (!isOk(d, !d || (validate(d, "Chat.Message")))) okay = false + if (d && d.length) { + const e = await attempt(api.markChatChannelAsRead, channels[0], d[0]) + if (e === false) okay = false + } + const f = await attempt(api.sendChatMessage, channels[0], "hello, just testing something") + if (!isOk(f, !f || (f.content === "hello, just testing something" && validate(f, "Chat.Message")))) okay = false + const g = await attempt(api.leaveChatChannel, channels[0]) + if (g === false) okay = false + } + + const h = await attempt(api.createChatPrivateChannel, 3) + if (!isOk(h, !h || (validate(h, "Chat.Channel")))) okay = false + const i = await attempt(api.sendChatPrivateMessage, 3, "hello") + if (!isOk(i, !i || (i.message.content === "hello" && validate(i.channel, "Chat.Channel") && validate(i.message, "Chat.Message")))) okay = false + if (h) { + const j = await attempt(api.leaveChatChannel, h) + if (j === false) okay = false + } + const k = await attempt(api.keepChatAlive) + if (!isOk(k, !k || (validate(k, "Chat.UserSilence")))) okay = false + + return okay +} + +// const testForum = async (): Promise => { +// let okay = true +// console.log("\n===> FORUM") + +// const a = await attempt(api.getSpotlights) +// if (!isOk(a, !a || (a.length >= 132 && validate(a, "Spotlight")))) okay = false + +// return okay +// } + +const testMultiplayer = async (): Promise => { + let okay = true + console.log("\n===> MULTIPLAYER") + + const a1 = await attempt(api.getRooms, "playlists", "all") + if (!isOk(a1, !a1 || (validate(a1, "Multiplayer.Room")))) okay = false + const a2 = await attempt(api.getRooms, "realtime", "all") + if (!isOk(a2, !a2 || (validate(a2, "Multiplayer.Room")))) okay = false + + if (a1 && a1.length) { + const b1 = await attempt(api.getRoomLeaderboard, a1[0]) + if (!isOk(b1, !b1 || (validate(b1, "Multiplayer.Room.Leader")))) okay = false + } + if (a2 && a2.length) { + const b2 = await attempt(api.getRoomLeaderboard, a2[0]) + if (!isOk(b2, !b2 || (validate(b2, "Multiplayer.Room.Leader")))) okay = false + } + + return okay } -async function test(id: string | undefined, secret: string | undefined, redirect_uri: string | undefined) { +const testScore = async (): Promise => { + let okay = true + console.log("\n===> SCORE") + + if (server !== "https://osu.ppy.sh") { + console.log("Skipping, unable to do this test on this server") + return true + } + + const a = await attempt(api.getReplay, 393079484) + if (!isOk(a, !a || (a.length === 119546))) okay = false + return okay +} + +const testUser = async (): Promise => { + let okay = true + console.log("\n===> USER") + + const a = await attempt(api.getResourceOwner) + if (!isOk(a, !a || (validate(a, "User.Extended.WithStatisticsrulesets")))) okay = false + const b = await attempt(api.getFriends) + if (!isOk(b, !b || (validate(b, "User.WithCountryCoverGroupsStatisticsSupport")))) okay = false + + return okay +} + +const test = async (id: number | string | undefined, secret: string | undefined, redirect_uri: string | undefined): Promise => { if (id === undefined) {throw new Error("no ID env var")} if (secret === undefined) {throw new Error("no SECRET env var")} if (redirect_uri === undefined) {throw new Error("no REDIRECT_URI env var")} - let url = osu.generateAuthorizationURL(Number(id), redirect_uri, ["public", "chat.read"], server) + let url = osu.generateAuthorizationURL(Number(id), redirect_uri, + ["public", "chat.read", "chat.write", "chat.write_manage", "forum.write", "friends.read", "identify", "public"], server) exec(`xdg-open "${url}"`) let code = prompt(`What code do you get from: ${url}\n\n`) - let api = await osu.API.createAsync({id: Number(id), secret}, {code, redirect_uri}, "all", server) - - // Proof web socket stuff is working well - const socket = api.generateWebSocket() - - socket.on("open", () => { - socket.send(osu.WebSocket.Command.chatStart) - api.keepChatAlive() - setInterval(() => api.keepChatAlive(), 30 * 1000) - }) - - socket.on("message", (m: MessageEvent) => { - let event: osu.WebSocket.Event.Any = JSON.parse(m.toString()) - if (event.event === "chat.message.new") { - let message = event.data.messages.map((message) => message.content).join(" | ") - let user = event.data.users.map((user) => user.username).join(" | ") - console.log(`${user}: ${message}`) - } - }) + api = await osu.API.createAsync({id: Number(id), secret}, {code, redirect_uri}, "all", server) + + const tests = [ + testChat, + // testForum, + testMultiplayer, + testScore, + testUser + ] + + const results: {test_name: string, passed: boolean}[] = [] + for (let i = 0; i < tests.length; i++) { + results.push({test_name: tests[i].name, passed: await tests[i]()}) + } + console.log("\n", ...results.map((r) => `${r.test_name}: ${r.passed ? "✔️" : "❌"}\n`)) + await api.revokeToken() + + if (!results.find((r) => !r.passed)) { + console.log("✔️ Looks like the test went well!") + } else { + throw new Error("❌ Something in the test went wrong...") + } + process.exit() } -test(process.env.ID, process.env.SECRET, process.env.REDIRECT_URI) +const id = server === "https://osu.ppy.sh" ? process.env.ID : process.env.DEV_ID +const secret = server === "https://osu.ppy.sh" ? process.env.SECRET : process.env.DEV_SECRET +test(id, secret, process.env.REDIRECT_URI) diff --git a/lib/user.ts b/lib/user.ts index cfe0622..75922a5 100644 --- a/lib/user.ts +++ b/lib/user.ts @@ -97,7 +97,7 @@ export namespace User { max_friends: number occupation: string | null playmode: keyof typeof Ruleset - playstyle: string[] + playstyle: string[] | null post_count: number profile_order: ("me" | "recent_activity" | "beatmaps" | "historical" | "kudosu" | "top_ranks" | "medals")[] title: string | null @@ -210,7 +210,7 @@ export namespace User { export namespace Statistics { export interface WithCountryrank extends Statistics { - country_rank: number + country_rank: number | null } export interface WithUser extends Statistics {