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 000000000..e10026d0e Binary files /dev/null and b/db-test.sqlite3 differ 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",