From 9a126f543d9bd442cd9cdccd9279d498da5b485d Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 11 Nov 2023 12:19:57 +0200 Subject: [PATCH] Plus Voting to 60% criteria + leaderboard access (#1537) * Update PlusVotingResult * Logic * Replace plus tiers * Testing initial * Test progress * Working one test * Tests * Schema different file * Dynamic test * Fix test util --- .gitignore | 1 + app/db/sql.ts | 8 +- app/db/tables.ts | 4 +- app/features/admin/AdminRepository.server.ts | 27 ++- app/features/admin/admin-schemas.server.ts | 35 +++ app/features/admin/core/plus-tier.server.ts | 44 ++++ app/features/admin/routes/admin.test.ts | 221 +++++++++++++++++++ app/features/admin/routes/admin.tsx | 51 +---- app/features/mmr/season.ts | 6 +- app/utils/Test.ts | 70 ++++++ db-test.sqlite3 | Bin 0 -> 741376 bytes migrations/041-plus-sixty.js | 37 ++++ package-lock.json | 13 ++ package.json | 3 +- 14 files changed, 462 insertions(+), 58 deletions(-) create mode 100644 app/features/admin/admin-schemas.server.ts create mode 100644 app/features/admin/core/plus-tier.server.ts create mode 100644 app/features/admin/routes/admin.test.ts create mode 100644 app/utils/Test.ts create mode 100644 db-test.sqlite3 create mode 100644 migrations/041-plus-sixty.js diff --git a/.gitignore b/.gitignore index 08243a8c3..f17a26bf5 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ translation-progress.md notes.md db*.sqlite3* +!db-test.sqlite3 dump .DS_Store diff --git a/app/db/sql.ts b/app/db/sql.ts index 19aee80e6..3466c9a8b 100644 --- a/app/db/sql.ts +++ b/app/db/sql.ts @@ -3,8 +3,14 @@ import invariant from "tiny-invariant"; import { Kysely, ParseJSONResultsPlugin, SqliteDialect } from "kysely"; import type { DB } from "./tables"; +const migratedEmptyDb = new Database("db-test.sqlite3").serialize(); + invariant(process.env["DB_PATH"], "DB_PATH env variable must be set"); -export const sql = new Database(process.env["DB_PATH"]); +const isInMemoryDB = process.env["DB_PATH"] === ":memory:"; + +export const sql = new Database( + isInMemoryDB ? migratedEmptyDb : process.env["DB_PATH"], +); sql.pragma("journal_mode = WAL"); sql.pragma("foreign_keys = ON"); diff --git a/app/db/tables.ts b/app/db/tables.ts index da3285722..eeb5d6588 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -164,8 +164,8 @@ export interface CalendarEventResultTeam { } export interface FreshPlusTier { - tier: string | null; - userId: number | null; + tier: number | null; + userId: number; } export interface Group { diff --git a/app/features/admin/AdminRepository.server.ts b/app/features/admin/AdminRepository.server.ts index 02a2060a4..666d7d97d 100644 --- a/app/features/admin/AdminRepository.server.ts +++ b/app/features/admin/AdminRepository.server.ts @@ -2,6 +2,7 @@ import { db, sql } from "~/db/sql"; import { syncXPBadges } from "../badges"; import type { Tables } from "~/db/tables"; import { dateToDatabaseTimestamp } from "~/utils/dates"; +import invariant from "tiny-invariant"; const removeOldLikesStm = sql.prepare(/*sql*/ ` delete from @@ -48,23 +49,25 @@ export function migrate(args: { newUserId: number; oldUserId: number }) { }); } -export function refreshPlusTiers() { +export function replacePlusTiers( + plusTiers: Array<{ userId: number; tier: number }>, +) { + invariant(plusTiers.length > 0, "plusTiers must not be empty"); + return db.transaction().execute(async (trx) => { await trx.deleteFrom("PlusTier").execute(); - - await trx - .insertInto("PlusTier") - .columns(["userId", "tier"]) - .expression((eb) => - eb - .selectFrom("FreshPlusTier") - .select(["FreshPlusTier.userId", "FreshPlusTier.tier"]) - .where("FreshPlusTier.tier", "is not", null), - ) - .execute(); + await trx.insertInto("PlusTier").values(plusTiers).execute(); }); } +export function allPlusTiersFromLatestVoting() { + return db + .selectFrom("FreshPlusTier") + .select(["FreshPlusTier.userId", "FreshPlusTier.tier"]) + .where("FreshPlusTier.tier", "is not", null) + .execute() as Promise<{ userId: number; tier: number }[]>; +} + export function makeVideoAdderByUserId(userId: number) { return db .updateTable("User") diff --git a/app/features/admin/admin-schemas.server.ts b/app/features/admin/admin-schemas.server.ts new file mode 100644 index 000000000..ce056eca1 --- /dev/null +++ b/app/features/admin/admin-schemas.server.ts @@ -0,0 +1,35 @@ +import { _action, actualNumber } from "~/utils/zod"; +import { z } from "zod"; + +export const adminActionSchema = z.union([ + z.object({ + _action: _action("MIGRATE"), + "old-user": z.preprocess(actualNumber, z.number().positive()), + "new-user": z.preprocess(actualNumber, z.number().positive()), + }), + z.object({ + _action: _action("REFRESH"), + }), + z.object({ + _action: _action("CLEAN_UP"), + }), + z.object({ + _action: _action("FORCE_PATRON"), + user: z.preprocess(actualNumber, z.number().positive()), + patronTier: z.preprocess(actualNumber, z.number()), + patronTill: z.string(), + }), + z.object({ + _action: _action("VIDEO_ADDER"), + user: z.preprocess(actualNumber, z.number().positive()), + }), + z.object({ + _action: _action("ARTIST"), + user: z.preprocess(actualNumber, z.number().positive()), + }), + z.object({ + _action: _action("LINK_PLAYER"), + user: z.preprocess(actualNumber, z.number().positive()), + playerId: z.preprocess(actualNumber, z.number().positive()), + }), +]); diff --git a/app/features/admin/core/plus-tier.server.ts b/app/features/admin/core/plus-tier.server.ts new file mode 100644 index 000000000..febdcd476 --- /dev/null +++ b/app/features/admin/core/plus-tier.server.ts @@ -0,0 +1,44 @@ +import invariant from "tiny-invariant"; +import { addPendingPlusTiers } from "~/features/leaderboards/core/leaderboards.server"; +import { userSPLeaderboard } from "~/features/leaderboards/queries/userSPLeaderboard.server"; +import { previousSeason } from "~/features/mmr/season"; +import * as AdminRepository from "~/features/admin/AdminRepository.server"; + +export async function plusTiersFromVotingAndLeaderboard() { + const newMembersFromLeaderboard = fromLeaderboard(); + const newMembersFromVoting = + await AdminRepository.allPlusTiersFromLatestVoting(); + + return [ + ...newMembersFromLeaderboard, + // filter to ensure that user gets their highest tier + ...newMembersFromVoting.filter( + (member) => + !newMembersFromLeaderboard.some( + (leaderboardMember) => leaderboardMember.userId === member.userId, + ), + ), + ]; +} + +function fromLeaderboard() { + const now = new Date(); + const lastCompletedSeason = previousSeason(now); + invariant(lastCompletedSeason, "No previous season found"); + + // there has been voting after this season ended, the results no longer apply + if (now.getMonth() !== lastCompletedSeason.ends.getMonth()) return []; + + const leaderboard = addPendingPlusTiers( + userSPLeaderboard(lastCompletedSeason.nth), + ); + + return leaderboard.flatMap((entry) => { + if (!entry.pendingPlusTier) return []; + + return { + userId: entry.id, + tier: entry.pendingPlusTier, + }; + }); +} diff --git a/app/features/admin/routes/admin.test.ts b/app/features/admin/routes/admin.test.ts new file mode 100644 index 000000000..a14e11f16 --- /dev/null +++ b/app/features/admin/routes/admin.test.ts @@ -0,0 +1,221 @@ +import { suite } from "uvu"; +import * as assert from "uvu/assert"; +import * as Test from "~/utils/Test"; +import { action } from "./admin"; +import { db } from "~/db/sql"; +import MockDate from "mockdate"; +import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import type { adminActionSchema } from "../admin-schemas.server"; + +const PlusVoting = suite("Plus voting"); + +const adminAction = Test.wrappedAction({ action }); + +const voteArgs = ({ + score, + votedId, + authorId = 1, + month = 6, + year = 2021, +}: { + score: number; + votedId: number; + authorId?: number; + month?: number; + year?: number; +}) => ({ + score, + votedId, + authorId, + month, + tier: 1, + validAfter: dateToDatabaseTimestamp(new Date("2021-12-11T00:00:00.000Z")), + year, +}); + +const countPlusTierMembers = (tier = 1) => + db + .selectFrom("PlusTier") + .where("PlusTier.tier", "=", tier) + .select(({ fn }) => fn.count("PlusTier.tier").as("count")) + .executeTakeFirstOrThrow() + .then((row) => row.count); + +const createLeaderboard = (userIds: number[]) => + db + .insertInto("Skill") + .values( + userIds.map((userId, i) => ({ + matchesCount: 10, + mu: 25, + sigma: 8.333333333333334, + ordinal: 0.5 - i * 0.001, + userId, + season: 1, + })), + ) + .execute(); + +PlusVoting.after.each(() => { + MockDate.reset(); + Test.database.reset(); +}); + +PlusVoting("gives correct amount of plus tiers", async () => { + MockDate.set(new Date("2023-12-12T00:00:00.000Z")); + + await Test.database.insertUsers(10); + await PlusVotingRepository.upsertMany( + Array.from({ length: 10 }).map((_, i) => { + const id = i + 1; + + return voteArgs({ + score: id <= 5 ? -1 : 1, + votedId: id, + }); + }), + ); + + await adminAction({ _action: "REFRESH" }, { user: "admin" }); + + assert.equal(await countPlusTierMembers(), 5); +}); + +PlusVoting("60% is the criteria to pass voting", async () => { + MockDate.set(new Date("2023-12-12T00:00:00.000Z")); + + await Test.database.insertUsers(10); + + // 50% + await PlusVotingRepository.upsertMany( + Array.from({ length: 10 }).map((_, i) => { + return voteArgs({ + authorId: i + 1, + score: i < 5 ? -1 : 1, + votedId: 1, + }); + }), + ); + // 60% + await PlusVotingRepository.upsertMany( + Array.from({ length: 10 }).map((_, i) => { + return voteArgs({ + authorId: i + 1, + score: i < 4 ? -1 : 1, + votedId: 2, + }); + }), + ); + + await adminAction({ _action: "REFRESH" }, { user: "admin" }); + + const rows = await db + .selectFrom("PlusTier") + .select(["PlusTier.tier", "PlusTier.userId"]) + .where("PlusTier.tier", "=", 1) + .execute(); + + assert.equal(rows.length, 1); + assert.equal(rows[0].userId, 2); +}); + +PlusVoting( + "combines leaderboard and voting results (after season over)", + async () => { + MockDate.set(new Date("2023-11-29T00:00:00.000Z")); + + await Test.database.insertUsers(2); + await PlusVotingRepository.upsertMany([ + voteArgs({ + score: 1, + votedId: 1, + }), + ]); + await createLeaderboard([2]); + + await adminAction({ _action: "REFRESH" }, { user: "admin" }); + + assert.equal(await countPlusTierMembers(), 2); + }, +); + +PlusVoting("ignores leaderboard while season is ongoing", async () => { + MockDate.set(new Date("2024-02-15T00:00:00.000Z")); + + await Test.database.insertUsers(2); + await PlusVotingRepository.upsertMany([ + voteArgs({ + score: 1, + votedId: 1, + }), + ]); + await createLeaderboard([2]); + + await adminAction({ _action: "REFRESH" }, { user: "admin" }); + + assert.equal(await countPlusTierMembers(), 1); + assert.equal(await countPlusTierMembers(2), 0); +}); + +PlusVoting("leaderboard gives members to all tiers", async () => { + MockDate.set(new Date("2023-11-20T00:00:00.000Z")); + + await Test.database.insertUsers(60); + await createLeaderboard(Array.from({ length: 60 }, (_, i) => i + 1)); + + await adminAction({ _action: "REFRESH" }, { user: "admin" }); + + assert.ok((await countPlusTierMembers()) > 0); + assert.ok((await countPlusTierMembers(2)) > 0); + assert.ok((await countPlusTierMembers(3)) > 0); +}); + +PlusVoting( + "gives membership if failed voting and is on the leaderboard", + async () => { + MockDate.set(new Date("2023-11-29T00:00:00.000Z")); + + await Test.database.insertUsers(1); + await PlusVotingRepository.upsertMany([ + voteArgs({ + score: -1, + votedId: 1, + }), + ]); + await createLeaderboard([1]); + + await adminAction({ _action: "REFRESH" }, { user: "admin" }); + + assert.equal(await countPlusTierMembers(1), 1); + }, +); + +PlusVoting("members who fails voting drops one tier", async () => { + MockDate.set(new Date("2024-02-15T00:00:00.000Z")); + + await Test.database.insertUsers(1); + await PlusVotingRepository.upsertMany([ + voteArgs({ + score: 1, + votedId: 1, + month: 11, + year: 2023, + }), + ]); + + await PlusVotingRepository.upsertMany([ + voteArgs({ + score: -1, + votedId: 1, + month: 2, + year: 2024, + }), + ]); + + await adminAction({ _action: "REFRESH" }, { user: "admin" }); + + assert.equal(await countPlusTierMembers(2), 1); +}); + +PlusVoting.run(); diff --git a/app/features/admin/routes/admin.tsx b/app/features/admin/routes/admin.tsx index 68e3f4c7f..01e954b9b 100644 --- a/app/features/admin/routes/admin.tsx +++ b/app/features/admin/routes/admin.tsx @@ -1,17 +1,17 @@ import type { - ActionFunction, + ActionArgs, LoaderFunction, V2_MetaFunction, } from "@remix-run/node"; import { json, redirect } from "@remix-run/node"; import { useFetcher, useLoaderData, useNavigation } from "@remix-run/react"; import * as React from "react"; -import { z } from "zod"; import { Button } from "~/components/Button"; import { Catcher } from "~/components/Catcher"; import { Main } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; import { UserSearch } from "~/components/UserSearch"; +import * as AdminRepository from "~/features/admin/AdminRepository.server"; import { makeArtist } from "~/features/art"; import { useUser } from "~/features/auth/core"; import { @@ -27,48 +27,15 @@ import { } from "~/utils/remix"; import { makeTitle } from "~/utils/strings"; import { assertUnreachable } from "~/utils/types"; -import { impersonateUrl, SEED_URL, STOP_IMPERSONATING_URL } from "~/utils/urls"; -import { _action, actualNumber } from "~/utils/zod"; -import * as AdminRepository from "~/features/admin/AdminRepository.server"; +import { SEED_URL, STOP_IMPERSONATING_URL, impersonateUrl } from "~/utils/urls"; +import { adminActionSchema } from "../admin-schemas.server"; +import { plusTiersFromVotingAndLeaderboard } from "../core/plus-tier.server"; export const meta: V2_MetaFunction = () => { return [{ title: makeTitle("Admin page") }]; }; -const adminActionSchema = z.union([ - z.object({ - _action: _action("MIGRATE"), - "old-user": z.preprocess(actualNumber, z.number().positive()), - "new-user": z.preprocess(actualNumber, z.number().positive()), - }), - z.object({ - _action: _action("REFRESH"), - }), - z.object({ - _action: _action("CLEAN_UP"), - }), - z.object({ - _action: _action("FORCE_PATRON"), - user: z.preprocess(actualNumber, z.number().positive()), - patronTier: z.preprocess(actualNumber, z.number()), - patronTill: z.string(), - }), - z.object({ - _action: _action("VIDEO_ADDER"), - user: z.preprocess(actualNumber, z.number().positive()), - }), - z.object({ - _action: _action("ARTIST"), - user: z.preprocess(actualNumber, z.number().positive()), - }), - z.object({ - _action: _action("LINK_PLAYER"), - user: z.preprocess(actualNumber, z.number().positive()), - playerId: z.preprocess(actualNumber, z.number().positive()), - }), -]); - -export const action: ActionFunction = async ({ request }) => { +export const action = async ({ request }: ActionArgs) => { const data = await parseRequestFormData({ request, schema: adminActionSchema, @@ -88,7 +55,9 @@ export const action: ActionFunction = async ({ request }) => { case "REFRESH": { validate(isAdmin(user)); - await AdminRepository.refreshPlusTiers(); + await AdminRepository.replacePlusTiers( + await plusTiersFromVotingAndLeaderboard(), + ); break; } case "FORCE_PATRON": { @@ -136,7 +105,7 @@ export const action: ActionFunction = async ({ request }) => { } } - return null; + return { ok: true }; }; interface AdminPageLoaderData { diff --git a/app/features/mmr/season.ts b/app/features/mmr/season.ts index f825f46e2..661b57314 100644 --- a/app/features/mmr/season.ts +++ b/app/features/mmr/season.ts @@ -31,9 +31,13 @@ export function currentOrPreviousSeason(date: Date) { const _currentSeason = currentSeason(date); if (_currentSeason) return _currentSeason; + return previousSeason(date); +} + +export function previousSeason(date: Date) { let latestPreviousSeason; for (const season of SEASONS) { - if (date >= season.ends) latestPreviousSeason = season; + if (date > season.ends) latestPreviousSeason = season; } return latestPreviousSeason; diff --git a/app/utils/Test.ts b/app/utils/Test.ts new file mode 100644 index 000000000..104d9a542 --- /dev/null +++ b/app/utils/Test.ts @@ -0,0 +1,70 @@ +import type { ActionArgs } from "@remix-run/node"; +import type { z } from "zod"; +import { ADMIN_ID } from "~/constants"; +import { NZAP_TEST_ID } from "~/db/seed/constants"; +import { db, sql } from "~/db/sql"; +import { SESSION_KEY } from "~/features/auth/core/authenticator.server"; +import { authSessionStorage } from "~/features/auth/core/session.server"; + +export function wrappedAction({ + action, +}: { + // TODO: strongly type this + action: (args: ActionArgs) => any; +}) { + return async ( + args: z.infer, + { user }: { user?: "admin" | "regular" } = {}, + ) => { + const params = new URLSearchParams(args); + const request = new Request("http://app.com/path", { + method: "POST", + body: params, + headers: await authHeader(user), + }); + + return action({ + request, + context: {}, + params: {}, + }); + }; +} + +async function authHeader(user?: "admin" | "regular"): Promise { + if (!user) return []; + + const session = await authSessionStorage.getSession(); + + session.set(SESSION_KEY, user === "admin" ? ADMIN_ID : NZAP_TEST_ID); + + return [["Cookie", await authSessionStorage.commitSession(session)]]; +} + +export const database = { + reset: () => { + const tables = sql + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'migrations';", + ) + .all() as { name: string }[]; + + sql.prepare("PRAGMA foreign_keys = OFF").run(); + for (const table of tables) { + sql.prepare(`DELETE FROM "${table.name}"`).run(); + } + sql.prepare("PRAGMA foreign_keys = ON").run(); + }, + insertUsers: (count: number) => + db + .insertInto("User") + .values( + Array.from({ length: count }).map((_, i) => ({ + id: i + 1, + discordName: `user${i + 1}`, + discordDiscriminator: "0", + discordId: String(i), + })), + ) + .execute(), +}; diff --git a/db-test.sqlite3 b/db-test.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..e10026d0e6c9c63024cb6b841cf6df3edbd66c56 GIT binary patch literal 741376 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 z009U literal 0 HcmV?d00001 diff --git a/migrations/041-plus-sixty.js b/migrations/041-plus-sixty.js new file mode 100644 index 000000000..ed0963799 --- /dev/null +++ b/migrations/041-plus-sixty.js @@ -0,0 +1,37 @@ +module.exports.up = function (db) { + db.transaction(() => { + db.prepare(`drop view "PlusVotingResult"`).run(); + + // same as 000-initial.js except 60% criteria + db.prepare( + /* sql */ ` + create view "PlusVotingResult" as + select + "votedId", + "tier", + avg("score") as "score", + avg("score") >= 0.2 as "passedVoting", + "month", + "year", + exists ( + select + 1 + from + "PlusSuggestion" + where + "PlusSuggestion"."month" = "PlusVote"."month" + and "PlusSuggestion"."year" = "PlusVote"."year" + and "PlusSuggestion"."suggestedId" = "PlusVote"."votedId" + and "PlusSuggestion"."tier" = "PlusVote"."tier" + ) as "wasSuggested" + from + "PlusVote" + group by + "votedId", + "tier", + "month", + "year"; + `, + ).run(); + })(); +}; diff --git a/package-lock.json b/package-lock.json index cbd13a969..2eba35d3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,6 +82,7 @@ "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "ley": "^0.8.1", + "mockdate": "^3.0.5", "prettier": "3.0.2", "stylelint": "^15.10.3", "stylelint-config-standard": "^34.0.0", @@ -12554,6 +12555,12 @@ "ufo": "^1.1.1" } }, + "node_modules/mockdate": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz", + "integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==", + "dev": true + }, "node_modules/morgan": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", @@ -25941,6 +25948,12 @@ "ufo": "^1.1.1" } }, + "mockdate": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz", + "integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==", + "dev": true + }, "morgan": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", diff --git a/package.json b/package.json index 0d499388f..ce9db5ab9 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "prettier:check": "prettier --check . --log-level warn", "prettier:write": "prettier --write . --log-level warn", "typecheck": "tsc --noEmit", - "test:unit": "uvu -r tsm -r tsconfig-paths/register -i e2e", + "test:unit": "cross-env DB_PATH=:memory: NODE_ENV=test BASE_URL=https://example.com uvu -r tsm -r tsconfig-paths/register -i e2e", "test:e2e": "npx playwright test", "checks": "npm run test:unit && npm run lint:css && npm run lint:ts && npm run prettier:check && npm run typecheck", "cf": "npm run test:unit && npm run check-translation-jsons && npm run lint:css -- --fix && npm run lint:ts -- --fix && npm run prettier:write && npm run typecheck && npm run test:e2e", @@ -116,6 +116,7 @@ "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "ley": "^0.8.1", + "mockdate": "^3.0.5", "prettier": "3.0.2", "stylelint": "^15.10.3", "stylelint-config-standard": "^34.0.0",