mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-04 21:08:09 +03:00
feat: ultrafast tests (#1511)
* feat: ultrafast tests * fix: lint * fix: misc fixes and remove this dense perf code * fix: update server.yml appropriately * fix: nice one * fix: improve our luck
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# Test stuffs
|
||||
.setup_env
|
||||
failed-tests.txt
|
||||
.vite-cache
|
||||
|
||||
# Error Files
|
||||
scripts/validate-database-errs/*
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Action, CronTask, CronTaskExecution, JobQueue } from "tachi-db";
|
||||
|
||||
import { ONE_HOUR } from "#lib/constants/time";
|
||||
import {
|
||||
SELECT_CRON_TASK,
|
||||
SELECT_CRON_TASK_EXECUTION,
|
||||
@@ -9,6 +10,13 @@ import DB from "#services/pg/db";
|
||||
|
||||
export const ADMIN_PAGE_SIZE = 50;
|
||||
|
||||
/** Only list job queue rows and actions from this many hours ago (inclusive). */
|
||||
export const ADMIN_RECENT_HOURS = 12;
|
||||
|
||||
export function adminRecentSinceIso(hours = ADMIN_RECENT_HOURS): string {
|
||||
return new Date(Date.now() - ONE_HOUR * hours).toISOString();
|
||||
}
|
||||
|
||||
export interface JobQueueFilters {
|
||||
job_kind?: string;
|
||||
scope?: string;
|
||||
@@ -22,24 +30,26 @@ export interface PaginatedResult<T> {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function GetActiveJobs(): Promise<Array<JobQueue>> {
|
||||
/** Currently running jobs (not limited by the recent-hours window). */
|
||||
export function GetActiveJobs(limit = ADMIN_PAGE_SIZE): Promise<Array<JobQueue>> {
|
||||
return DB.selectFrom("job_queue")
|
||||
.select(SELECT_JOB_QUEUE)
|
||||
.where("job_queue.status", "=", 1)
|
||||
.orderBy("job_queue.scheduled_for", "asc")
|
||||
.limit(limit)
|
||||
.execute();
|
||||
}
|
||||
|
||||
function jobQueueBaseQuery(filters: JobQueueFilters) {
|
||||
let q = DB.selectFrom("job_queue");
|
||||
let q = DB.selectFrom("job_queue").where("job_queue.created_at", ">=", adminRecentSinceIso());
|
||||
if (filters.status !== undefined) {
|
||||
q = q.where("status", "=", filters.status);
|
||||
q = q.where("job_queue.status", "=", filters.status);
|
||||
}
|
||||
if (filters.job_kind) {
|
||||
q = q.where("job_kind", "=", filters.job_kind);
|
||||
q = q.where("job_queue.job_kind", "=", filters.job_kind);
|
||||
}
|
||||
if (filters.scope) {
|
||||
q = q.where("scope", "=", filters.scope);
|
||||
q = q.where("job_queue.scope", "=", filters.scope);
|
||||
}
|
||||
return q;
|
||||
}
|
||||
@@ -82,7 +92,9 @@ export interface ActionFilters {
|
||||
export type ActionRow = { username: string | null } & Action;
|
||||
|
||||
function actionFilteredQuery(filters: ActionFilters) {
|
||||
let q = DB.selectFrom("action").leftJoin("account", "account.id", "action.user_id");
|
||||
let q = DB.selectFrom("action")
|
||||
.leftJoin("account", "account.id", "action.user_id")
|
||||
.where("action.ts_start", ">=", adminRecentSinceIso());
|
||||
if (filters.kind) {
|
||||
q = q.where("action.kind", "=", filters.kind);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { log } from "#lib/log/log";
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import { Env, ServerConfig } from "#lib/setup/config";
|
||||
import DB from "#services/pg/db";
|
||||
import nodeFetch from "#utils/fetch";
|
||||
import { Random20Hex } from "#utils/misc";
|
||||
@@ -17,8 +17,6 @@ import {
|
||||
} from "tachi-common";
|
||||
import { type Database } from "tachi-db";
|
||||
|
||||
const BCRYPT_SALT_ROUNDS = 12;
|
||||
|
||||
export const ValidatePassword = (self: unknown) =>
|
||||
(typeof self === "string" && self.length >= 8) || "Passwords must be 8 characters or more.";
|
||||
|
||||
@@ -83,7 +81,7 @@ export const DEFAULT_USER_SETTINGS: UserSettingsDocument["preferences"] = {
|
||||
};
|
||||
|
||||
export function HashPassword(plaintext: string) {
|
||||
return bcrypt.hash(plaintext, BCRYPT_SALT_ROUNDS);
|
||||
return bcrypt.hash(plaintext, Env.BCRYPT_SALT_ROUNDS);
|
||||
}
|
||||
|
||||
export async function AddNewUser(
|
||||
|
||||
@@ -585,6 +585,26 @@ if (!versionDetail) {
|
||||
*/
|
||||
const PG_POOL_MAX = parseIntEnv("PG_POOL_MAX", 10);
|
||||
|
||||
/**
|
||||
* Cost factor for `bcrypt.hash` (and the round-trip cost of `bcrypt.compare`
|
||||
* against any resulting hash). Defaults to 12 - the value the codebase shipped
|
||||
* with - and is enforced as a minimum outside test environments.
|
||||
*
|
||||
* In tests we want bcrypt to be effectively free: the test suite hashes /
|
||||
* verifies dozens of passwords and at 12 rounds bcrypt dominates the auth-
|
||||
* flavoured test budget. Set via TACHI_BCRYPT_SALT_ROUNDS (defaults to the
|
||||
* bcryptjs minimum, 4, when NODE_ENV=test).
|
||||
*/
|
||||
const BCRYPT_SALT_ROUNDS = parseIntEnv("TACHI_BCRYPT_SALT_ROUNDS", NODE_ENV === "test" ? 4 : 12);
|
||||
|
||||
if (NODE_ENV !== "test" && BCRYPT_SALT_ROUNDS < 12) {
|
||||
log.fatal(
|
||||
`TACHI_BCRYPT_SALT_ROUNDS=${BCRYPT_SALT_ROUNDS} but NODE_ENV=${NODE_ENV}. ` +
|
||||
`Refusing to start: rounds below 12 are only acceptable in test environments.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export const Env = {
|
||||
PORT,
|
||||
REDIS_URL,
|
||||
@@ -595,4 +615,5 @@ export const Env = {
|
||||
NODE_ENV: NODE_ENV as "dev" | "production" | "staging" | "test",
|
||||
LOG_LEVEL: logLevel as "crit" | "debug" | "error" | "info" | "severe" | "verbose" | "warn",
|
||||
PG_POOL_MAX,
|
||||
BCRYPT_SALT_ROUNDS,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,14 @@ import { OmitUndefinedKeys } from "#utils/misc";
|
||||
import rateLimit, { type Options } from "express-rate-limit";
|
||||
import RateLimitRedis from "rate-limit-redis";
|
||||
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
// Signal to vitest.setup.ts that this worker has loaded the rate limiter,
|
||||
// so its beforeEach should clear the in-memory cache. Pure-unit test files
|
||||
// that never load any router skip the cache-clear (and the import chain)
|
||||
// entirely.
|
||||
(globalThis as { __tachi_rate_limiter_loaded?: boolean }).__tachi_rate_limiter_loaded = true;
|
||||
}
|
||||
|
||||
function CreateStore(name: string) {
|
||||
// undefined forces a default to an in-memory store
|
||||
// So we use that when in testing or localdev.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* `API_V1_ROUTER` is the singleton TypedRouter that every `/api/v1/...`
|
||||
* submodule registers its routes on. It lives in its own module (with no
|
||||
* transitive imports of any submodule) so that:
|
||||
*
|
||||
* 1. `router.ts` can synchronously `import "./sub/router"` every submodule
|
||||
* to register routes as a side effect, then call `API_V1_ROUTER.build()`.
|
||||
* 2. The submodules' `import { API_V1_ROUTER } from "../_singleton"` does
|
||||
* NOT cycle through `router.ts` (the previous setup had submodules
|
||||
* importing `../router`, which made the only way to break the cycle
|
||||
* `await import(...)` + top-level await in `router.ts` - and vitest's
|
||||
* vite-node under `pool: "threads" + isolate: false` does not reliably
|
||||
* wait for TLA before serving a module's exports to a static import).
|
||||
*
|
||||
* Keep this module dependency-light: it must not, transitively, import
|
||||
* anything that itself imports any of the route submodules.
|
||||
*/
|
||||
|
||||
import { TypedRouter } from "#lib/router/typed-router";
|
||||
|
||||
import { API_V1_SPEC } from "./spec";
|
||||
|
||||
export const API_V1_ROUTER = new TypedRouter(API_V1_SPEC);
|
||||
@@ -2,7 +2,7 @@ import { GetRecentActivityForMultipleGames } from "#lib/activity/activity";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { ALL_GAMES } from "tachi-common";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
async function globalActivityImpl(input: { startTime?: number }) {
|
||||
const data = await GetRecentActivityForMultipleGames(
|
||||
|
||||
@@ -24,7 +24,7 @@ import { GetUserWithIDGuaranteed, ResolveUser } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { GameToGameGroup, type V3Game } from "tachi-common";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
API_V1_ROUTER.add("GET /admin/job-queue", withAdmin, async ({ input }) => {
|
||||
const page = Math.max(0, input.page ?? 0);
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
const aggressiveRL = wrapExpressMiddleware(AggressiveRateLimitMiddleware);
|
||||
const hyperAggressiveRL = wrapExpressMiddleware(HyperAggressiveRateLimitMiddleware);
|
||||
|
||||
@@ -9,7 +9,7 @@ import DB from "#services/pg/db";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { type APIPermissions } from "tachi-common";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
/**
|
||||
* Retrieve the clients you created. Must be performed with a session-level request.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { ServerConfig, TachiConfig } from "#lib/setup/config";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
/**
|
||||
* Returns Tachi Configuration info, such as server name, type, supported games
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "#lib/game-specific/custom-bms-tables";
|
||||
import { withGame } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { FindBMSSieglindeRatedCharts } from "#utils/queries/charts";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { type GamesForGroup, GameToGameGroup } from "tachi-common";
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from "#lib/game-specific/iidx-playlists";
|
||||
import { withGame } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { type GamesForGroup, GameToGameGroup } from "tachi-common";
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ import {
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
const gptStatCache = new NodeCache();
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { FormatUserDoc, GetUserWithIDGuaranteed } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { p } from "prudence";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
const ParseMultipartScoredata = CreateMulterSingleUploadMiddleware("scoreData", SIXTEEN_MEGABTYES);
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { GetRelevantSongsAndCharts } from "#utils/db";
|
||||
import { GetUsersWithIDs, GetUserWithID } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
// ─── Admin-facing import list ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import DB from "#services/pg/db";
|
||||
import { GetFirstAdmin } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
/**
|
||||
* Reports whether the database has any rows in the `song` table (seed data).
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ANON_ACTION_OAuthTokenExchange } from "#anon-actions/oauth-token-exchan
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
/**
|
||||
* Converts an auth code into a valid API key that is returned.
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
} from "#lib/proposals/github";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
|
||||
@@ -1,64 +1,69 @@
|
||||
import { TypedRouter } from "#lib/router/typed-router";
|
||||
// `API_V1_ROUTER` is the singleton route registry; it lives in `./_singleton`
|
||||
// so the submodules below can import it without cycling back through this
|
||||
// file (the old `await import(...)` + top-level-await scheme was the only
|
||||
// way to break the previous cycle, and vitest's vite-node under `pool:
|
||||
// "threads" + isolate: false` does not reliably await TLA before serving a
|
||||
// module's exports to a static `import` somewhere up the tree).
|
||||
import { API_V1_ROUTER } from "./_singleton";
|
||||
|
||||
import { API_V1_SPEC } from "./spec";
|
||||
export { API_V1_ROUTER };
|
||||
|
||||
export const API_V1_ROUTER = new TypedRouter(API_V1_SPEC);
|
||||
|
||||
// this sucks, but there's no "mod" tree in typescript, unlike rust.
|
||||
await import("./status/router");
|
||||
await import("./auth/router");
|
||||
await import("./admin/router");
|
||||
await import("./import/router");
|
||||
await import("./imports/router");
|
||||
await import("./users/router");
|
||||
await import("./games/router");
|
||||
await import("./games/@gameSpecificRoutes/bms/router");
|
||||
await import("./games/@gameSpecificRoutes/iidx/router");
|
||||
await import("./search/router");
|
||||
await import("./sessions/router");
|
||||
await import("./sessions/_sessionID/router");
|
||||
await import("./oauth/router");
|
||||
await import("./clients/router");
|
||||
await import("./activity/router");
|
||||
await import("./config/router");
|
||||
await import("./localdev/router");
|
||||
await import("./seeds/router");
|
||||
await import("./proposals/router");
|
||||
await import("./scores/_scoreID/router");
|
||||
|
||||
await import("./users/_userID/router");
|
||||
await import("./users/_userID/pfp/router");
|
||||
await import("./users/_userID/banner/router");
|
||||
await import("./users/_userID/api-tokens/router");
|
||||
await import("./users/_userID/invites/router");
|
||||
await import("./users/_userID/following/router");
|
||||
await import("./users/_userID/notifications/router");
|
||||
await import("./users/_userID/sessions/router");
|
||||
await import("./users/_userID/imports/router");
|
||||
await import("./users/_userID/settings/router");
|
||||
await import("./users/_userID/integrations/router");
|
||||
await import("./users/_userID/integrations/cg/_cgType/router");
|
||||
await import("./users/_userID/integrations/kai/_kaiType/router");
|
||||
await import("./users/_userID/integrations/fervidex/router");
|
||||
await import("./users/_userID/integrations/kshook-sv6c/router");
|
||||
await import("./users/_userID/integrations/myt/router");
|
||||
|
||||
await import("./users/_userID/games/@gameSpecificRoutes/bms/router");
|
||||
await import("./users/_userID/games/@gameSpecificRoutes/iidx/router");
|
||||
await import("./users/_userID/games/@gameSpecificRoutes/jubeat/router");
|
||||
await import("./users/_userID/games/_game/_playtype/pbs/router");
|
||||
await import("./users/_userID/games/_game/_playtype/scores/router");
|
||||
await import("./users/_userID/games/_game/_playtype/sessions/router");
|
||||
await import("./users/_userID/games/_game/_playtype/tables/router");
|
||||
await import("./users/_userID/games/_game/_playtype/showcase/router");
|
||||
await import("./users/_userID/games/_game/_playtype/settings/router");
|
||||
await import("./users/_userID/games/_game/_playtype/targets/router");
|
||||
await import("./users/_userID/games/_game/_playtype/targets/goals/router");
|
||||
await import("./users/_userID/games/_game/_playtype/targets/quests/router");
|
||||
await import("./users/_userID/games/_game/_playtype/folders/router");
|
||||
await import("./users/_userID/games/_game/_playtype/folders/_folderSlug/router");
|
||||
await import("./users/_userID/games/_game/_playtype/router");
|
||||
await import("./users/_userID/games/_game/_playtype/rivals/router");
|
||||
// Submodules register routes on `API_V1_ROUTER` as a side effect of being
|
||||
// imported. Ordering only matters where one route prefix shadows another
|
||||
// (e.g. `/users/_userID` after `/users`); within a group the order below
|
||||
// mirrors the original `await import(...)` block.
|
||||
import "./status/router";
|
||||
import "./auth/router";
|
||||
import "./admin/router";
|
||||
import "./import/router";
|
||||
import "./imports/router";
|
||||
import "./users/router";
|
||||
import "./games/router";
|
||||
import "./games/@gameSpecificRoutes/bms/router";
|
||||
import "./games/@gameSpecificRoutes/iidx/router";
|
||||
import "./search/router";
|
||||
import "./sessions/router";
|
||||
import "./sessions/_sessionID/router";
|
||||
import "./oauth/router";
|
||||
import "./clients/router";
|
||||
import "./activity/router";
|
||||
import "./config/router";
|
||||
import "./localdev/router";
|
||||
import "./seeds/router";
|
||||
import "./proposals/router";
|
||||
import "./scores/_scoreID/router";
|
||||
import "./users/_userID/router";
|
||||
import "./users/_userID/pfp/router";
|
||||
import "./users/_userID/banner/router";
|
||||
import "./users/_userID/api-tokens/router";
|
||||
import "./users/_userID/invites/router";
|
||||
import "./users/_userID/following/router";
|
||||
import "./users/_userID/notifications/router";
|
||||
import "./users/_userID/sessions/router";
|
||||
import "./users/_userID/imports/router";
|
||||
import "./users/_userID/settings/router";
|
||||
import "./users/_userID/integrations/router";
|
||||
import "./users/_userID/integrations/cg/_cgType/router";
|
||||
import "./users/_userID/integrations/kai/_kaiType/router";
|
||||
import "./users/_userID/integrations/fervidex/router";
|
||||
import "./users/_userID/integrations/kshook-sv6c/router";
|
||||
import "./users/_userID/integrations/myt/router";
|
||||
import "./users/_userID/games/@gameSpecificRoutes/bms/router";
|
||||
import "./users/_userID/games/@gameSpecificRoutes/iidx/router";
|
||||
import "./users/_userID/games/@gameSpecificRoutes/jubeat/router";
|
||||
import "./users/_userID/games/_game/_playtype/pbs/router";
|
||||
import "./users/_userID/games/_game/_playtype/scores/router";
|
||||
import "./users/_userID/games/_game/_playtype/sessions/router";
|
||||
import "./users/_userID/games/_game/_playtype/tables/router";
|
||||
import "./users/_userID/games/_game/_playtype/showcase/router";
|
||||
import "./users/_userID/games/_game/_playtype/settings/router";
|
||||
import "./users/_userID/games/_game/_playtype/targets/router";
|
||||
import "./users/_userID/games/_game/_playtype/targets/goals/router";
|
||||
import "./users/_userID/games/_game/_playtype/targets/quests/router";
|
||||
import "./users/_userID/games/_game/_playtype/folders/router";
|
||||
import "./users/_userID/games/_game/_playtype/folders/_folderSlug/router";
|
||||
import "./users/_userID/games/_game/_playtype/router";
|
||||
import "./users/_userID/games/_game/_playtype/rivals/router";
|
||||
|
||||
const router = API_V1_ROUTER.build();
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { GetSongByID } from "#lib/db-formats/song";
|
||||
import { log } from "#lib/log/log";
|
||||
import { withScore, withScoreOwner } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetUserWithID } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { GameToGameGroup } from "tachi-common";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SearchGamesSongsCharts } from "#lib/search/song-charts";
|
||||
import { GetAllUserRivals, GetUserPlayedGames } from "#utils/user";
|
||||
import { type integer, type UserDocument, type V3Game } from "tachi-common";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
/**
|
||||
* Performs a generic "search" across Tachi.
|
||||
|
||||
@@ -10,7 +10,7 @@ import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
// Routes for interacting with the `seeds` folder in this instance of Tachi.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { GetSessionFolderRaises } from "#lib/folders/get-session-folder-raises";
|
||||
import { withSession, withSessionOwner } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetAdjacentSessions, GetSessionData, GetSessionIndex } from "#utils/queries/sessions";
|
||||
import { GetUserWithID } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { VERSION_PRETTY } from "#lib/constants/version";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ACTION_DeleteApiToken } from "#actions/delete-api-token";
|
||||
import { SELECT_API_TOKEN, ToAPITokenDocument } from "#lib/db-formats/api-token";
|
||||
import { withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,7 @@ import { withAuthedAsUser, withPermission, withRequestedUser } from "#lib/router
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { RequirePermissions } from "#server/middleware/auth";
|
||||
import { CreateMulterSingleUploadMiddleware } from "#server/middleware/multer-upload";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { REQ_GetTachiData } from "#utils/req-tachi-data";
|
||||
|
||||
import { GetUserFromParam, RequireAuthedAsUser } from "../middleware";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ACTION_FollowUser } from "#actions/follow-user";
|
||||
import { ACTION_UnfollowUser } from "#actions/unfollow-user";
|
||||
import { withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetFollowingForUser } from "#utils/queries/settings";
|
||||
import { GetUsersWithIDs } from "#utils/user";
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
} from "#lib/game-specific/custom-bms-tables";
|
||||
import { withGame, withRequestedUserAndReqData } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { FindBMSChartOnHashInGame } from "#utils/queries/charts";
|
||||
import { REQ_GetUser } from "#utils/req-tachi-data";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import { withGame, withRequestedUserAndReqData } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { EAM_VERSION_NAMES } from "#lib/score-import/import-types/common/eamusement-iidx-csv/parser";
|
||||
import { AggressiveRateLimitMiddleware } from "#server/middleware/rate-limiter";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetUserFromParam } from "#server/router/api/v1/users/_userID/middleware";
|
||||
import DB from "#services/pg/db";
|
||||
import { REQ_AssignToReqTachiData, REQ_GetUser } from "#utils/req-tachi-data";
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { GetPBsForJubility } from "#game-implementations/games/jubeat";
|
||||
import { withGame, withRequestedUserAndReqData } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetRelevantSongsAndCharts } from "#utils/db";
|
||||
import { REQ_GetUser } from "#utils/req-tachi-data";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import {
|
||||
import { LoadFolderEvolutionPayload } from "#lib/folders/table-evolution";
|
||||
import { withSelf, withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { GetFolderTimelineScores } from "#utils/queries/scores";
|
||||
import { UnixMillisecondsToISO8601 } from "#utils/time";
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { GetEnumDistForFolders, GetRecentlyViewedFolders } from "#lib/folders/fo
|
||||
import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { SearchFoldersForGameFtsAndTrgm } from "#lib/search/folders";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
|
||||
/**
|
||||
* Search folders with user grade+lamp distribution.
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { ResolveSongAndChart } from "#lib/score-import/import-types/common/batch-manual/converter";
|
||||
import { SearchSpecificGameSongsAndCharts } from "#lib/search/song-charts";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetRelevantSongsAndCharts } from "#utils/db";
|
||||
import { IsValidScoreAlg } from "#utils/misc";
|
||||
import { GetAdjacentAbove, GetAdjacentBelow } from "#utils/queries/pbs";
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { LoadPbDocumentsForUserSetSortedByCalculatedAlg } from "#lib/db-formats/
|
||||
import { GetChallengerUsers, GetRivalIDs, GetRivalUsers } from "#lib/rivals/rivals";
|
||||
import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetRelevantSongsAndCharts } from "#utils/db";
|
||||
import { DedupeArr, IsString } from "#utils/misc";
|
||||
import { CheckStrScoreAlg } from "#utils/string-checks";
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import { GetSongsByIDs } from "#lib/db-formats/song";
|
||||
import { log } from "#lib/log/log";
|
||||
import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { IsString } from "#utils/misc";
|
||||
import DestroyUserGameProfile from "#utils/reset-state/destroy-user-game-profile";
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import {
|
||||
import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { SearchSpecificGameSongsAndCharts } from "#lib/search/song-charts";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { GetRelevantSongsAndCharts } from "#utils/db";
|
||||
import {
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { GetSessionScoreInfo } from "#lib/score-import/framework/sessions/sessions";
|
||||
import { SearchSessions } from "#lib/search/search";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { GetScoreIdsGroupedBySessionId } from "#utils/queries/sessions";
|
||||
import { CheckStrSessionAlg } from "#utils/string-checks";
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { GetUGPTSettingsDocument } from "#lib/db-formats/ugpt-settings";
|
||||
import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetUserWithIDGuaranteed } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { success } from "#lib/router/typed-router";
|
||||
import { EvaluateShowcaseStat } from "#lib/showcase/evaluator";
|
||||
import { GetRelatedStatDocuments } from "#lib/showcase/get-related";
|
||||
import { EvaluateUsersStatsShowcase } from "#lib/showcase/get-stats";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetUserWithIDGuaranteed, ResolveUser } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import {
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { GetEnumDistForFolders, GetFoldersFromTable } from "#lib/folders/folders
|
||||
import { LoadTableEvolutionPayload } from "#lib/folders/table-evolution";
|
||||
import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import {
|
||||
import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { GetParentQuests } from "#lib/targets/quests";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { type GoalDocument } from "tachi-common";
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { SELECT_QUEST, SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/
|
||||
import { ToQuestDocument, ToQuestSubscriptionDocument } from "#lib/db-formats/target-documents";
|
||||
import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { sql } from "kysely";
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { GetRelevantGoals } from "#lib/targets/goals";
|
||||
import { GetParentQuests } from "#lib/targets/quests";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import {
|
||||
GetRecentlyAchievedGoals,
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from "#lib/db-formats/import-document";
|
||||
import { withRequestedUser } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
|
||||
/**
|
||||
* Query this user's imports. Returns the 500 most recently-finished imports.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { ACTION_UpdateCgCardInfo } from "#actions/update-cg-card-info";
|
||||
import { SELECT_CG_CARD_INFO, ToCGCardInfo } from "#lib/db-formats/cg-card-info";
|
||||
import { withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ACTION_UpdateFervidexSettings } from "#actions/update-fervidex-settings
|
||||
import { SELECT_FER_SETTINGS, ToFervidexSettingsDocument } from "#lib/db-formats/fervidex-settings";
|
||||
import { withKamaitachi, withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import {
|
||||
GetKaiTypeClientCredentials,
|
||||
KaiTypeToBaseURL,
|
||||
} from "#lib/score-import/import-types/common/api-kai/utils";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import fetch from "#utils/fetch";
|
||||
import { GetKaiAuth } from "#utils/queries/auth";
|
||||
import { FormatUserDoc } from "#utils/user";
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
||||
} from "#lib/db-formats/kshook-sv6c-settings";
|
||||
import { withKamaitachi, withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ACTION_UpdateMytCardInfo } from "#actions/update-myt-card-info";
|
||||
import { SELECT_MYT_CARD_INFO, ToMytCardInfo } from "#lib/db-formats/myt-card-info";
|
||||
import { withKamaitachi, withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SELECT_INVITE, ToInviteDocument } from "#lib/db-formats/invite";
|
||||
import { GetTotalAllowedInvites } from "#lib/invites/invites";
|
||||
import { withInvitesEnabled, withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { GetUsersWithIDs } from "#utils/user";
|
||||
import { sql } from "kysely";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ACTION_MarkAllNotificationsRead } from "#actions/mark-all-notifications
|
||||
import { SELECT_NOTIFICATION, ToNotificationDocument } from "#lib/db-formats/notification";
|
||||
import { withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@ import { withAuthedAsUser, withPermission, withRequestedUser } from "#lib/router
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { RequirePermissions } from "#server/middleware/auth";
|
||||
import { CreateMulterSingleUploadMiddleware } from "#server/middleware/multer-upload";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { REQ_GetTachiData } from "#utils/req-tachi-data";
|
||||
|
||||
import { GetUserFromParam, RequireAuthedAsUser } from "../middleware";
|
||||
|
||||
@@ -9,7 +9,7 @@ import { log } from "#lib/log/log";
|
||||
import { GetRivalIDs } from "#lib/rivals/rivals";
|
||||
import { withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import {
|
||||
GetGoalSummary,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SELECT_SESSION_CALENDAR, ToSessionCalendarDocument } from "#lib/db-formats/session";
|
||||
import { withRequestedUser } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ACTION_UpdateUserSettings } from "#actions/update-user-settings";
|
||||
import { withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetSettingsForUser } from "#utils/user";
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,7 @@ import DB from "#services/pg/db";
|
||||
import { UnixMillisecondsToISO8601 } from "#utils/time";
|
||||
import { GetOnlineCutoff } from "#utils/user";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
/**
|
||||
* Search users.
|
||||
|
||||
@@ -159,6 +159,24 @@ app.use((req, res, next) => {
|
||||
|
||||
app.use(RequestLoggerMiddleware);
|
||||
|
||||
// Per-request timing for test-suite profiling. Enabled by TACHI_REQ_TIMING=1.
|
||||
// Writes one line per request to stderr with method, url, status, total ms
|
||||
// and an approximate "handler" budget (server-side time from middleware entry
|
||||
// to res.on('finish')). Cheap enough to leave in for ad-hoc profiling but
|
||||
// gated so it doesn't pollute the normal test log.
|
||||
if (Env.NODE_ENV === "test" && process.env.TACHI_REQ_TIMING === "1") {
|
||||
app.use((req, res, next) => {
|
||||
const t0 = performance.now();
|
||||
res.on("finish", () => {
|
||||
const ms = performance.now() - t0;
|
||||
process.stderr.write(
|
||||
`[reqtiming] ${req.method.padEnd(4)} ${res.statusCode} ${ms.toFixed(1).padStart(7)}ms ${req.originalUrl}\n`,
|
||||
);
|
||||
});
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
app.use("/", mainRouter);
|
||||
|
||||
// completely stolen from ktapi error handler
|
||||
|
||||
@@ -22,6 +22,30 @@ if (process.env.NODE_ENV === "test") {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// Track whether app code under test has actually touched the DB since the
|
||||
// last reset. vitest.setup.ts reads this via `globalThis` (a property read,
|
||||
// no import) so files that never load #services/pg/db skip resetDatabase
|
||||
// entirely - the biggest single source of per-file overhead in pure-unit
|
||||
// tests where the first beforeEach was paying ~2 s just to import this
|
||||
// module and run a probe query.
|
||||
const g = globalThis as unknown as {
|
||||
__tachi_pg_loaded?: boolean;
|
||||
__tachi_pg_used?: boolean;
|
||||
};
|
||||
g.__tachi_pg_loaded = true;
|
||||
const origConnect = pool.connect.bind(pool);
|
||||
const origQuery = pool.query.bind(pool) as (...args: unknown[]) => unknown;
|
||||
|
||||
pool.connect = ((...args: unknown[]) => {
|
||||
g.__tachi_pg_used = true;
|
||||
return (origConnect as (...a: unknown[]) => unknown)(...args);
|
||||
}) as typeof pool.connect;
|
||||
|
||||
pool.query = ((...args: unknown[]) => {
|
||||
g.__tachi_pg_used = true;
|
||||
return origQuery(...args);
|
||||
}) as typeof pool.query;
|
||||
}
|
||||
|
||||
const DB = new Kysely<Database>({
|
||||
|
||||
@@ -9,17 +9,17 @@ const connection = server.listen();
|
||||
log.debug("Connecting to Supertest...");
|
||||
const mockApi = supertest(connection);
|
||||
|
||||
/**
|
||||
* No-op. Many test files call this in their own `afterAll` to "close" the
|
||||
* supertest http.Server, but with `pool: "threads"` + `isolate: false`
|
||||
* (vitest.config.ts) a single listener is shared across every test file a
|
||||
* worker processes - a real `connection.close()` here would break every
|
||||
* subsequent file in the worker. Node tears the socket down on process exit,
|
||||
* which is sufficient for test mode. Kept exported for source compatibility
|
||||
* with the existing test suite.
|
||||
*/
|
||||
export function CloseServerConnection() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
connection.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
export default mockApi;
|
||||
|
||||
@@ -3,6 +3,25 @@ import DB from "#services/pg/db";
|
||||
|
||||
let minimalIidxChartCounter = 0;
|
||||
|
||||
// `bcrypt.hash` at the test-mode rounds (BCRYPT_SALT_ROUNDS=4, see config.ts)
|
||||
// is ~5 ms; in CI we have hundreds of `seedUser({ withCredential: true })`
|
||||
// calls across the suite, almost all of them with the same handful of
|
||||
// plaintexts (`"password123"` and friends). With `pool: "threads" +
|
||||
// isolate: false` this cache survives across every file a worker processes,
|
||||
// turning that into one hash per (plaintext, worker). The hash is a pure
|
||||
// function of plaintext + rounds for our purposes (the salt varies, but
|
||||
// tests only care that `PasswordCompare(plaintext, hash)` round-trips).
|
||||
const hashedPasswordCache = new Map<string, Promise<string>>();
|
||||
function cachedHashPassword(plaintext: string): Promise<string> {
|
||||
const cached = hashedPasswordCache.get(plaintext);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
const p = HashPassword(plaintext);
|
||||
hashedPasswordCache.set(plaintext, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a minimal `song` + `chart` row for `iidx` / `SP` (`game` = `iidx-sp`) so
|
||||
* goal/chart validation (`GetChartById`) succeeds in tests.
|
||||
@@ -86,7 +105,7 @@ export async function seedUser(opts?: SeedUserOpts) {
|
||||
const userId = Number(id);
|
||||
|
||||
if (opts?.withCredential) {
|
||||
const hashedPassword = await HashPassword(password);
|
||||
const hashedPassword = await cachedHashPassword(password);
|
||||
|
||||
await DB.insertInto("priv_account_credential")
|
||||
.values({ user_id: userId, email, password: hashedPassword })
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
@@ -7,6 +8,37 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
/** When set (e.g. `test:coverage:set-user-supporter`), enforce 100% coverage on that file only. */
|
||||
const coverageSupporterActionOnly = process.env.VITEST_COVERAGE_SUPPORTER_ACTION === "1";
|
||||
|
||||
/**
|
||||
* Coverage is opt-in: gated on `VITEST_COVERAGE=1` (CI's coverage job sets this,
|
||||
* `just coverage-report` flips it on too). Keeping coverage off in the default
|
||||
* developer/watch path is the single biggest vitest config lever - V8 coverage
|
||||
* adds substantial overhead per worker even when nobody is reading the report.
|
||||
*
|
||||
* The supporter-action focussed run also forces coverage on (it inspects a
|
||||
* specific file's percentages).
|
||||
*/
|
||||
const coverageEnabled = process.env.VITEST_COVERAGE === "1" || coverageSupporterActionOnly;
|
||||
|
||||
/** Allow CI to dial worker count down (default: all cores, capped to keep PG happy). */
|
||||
function envInt(name: string, fallback: number): number {
|
||||
const v = process.env[name];
|
||||
if (v === undefined || v === "") {
|
||||
return fallback;
|
||||
}
|
||||
const n = Number.parseInt(v, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
const cpuCount = os.cpus().length;
|
||||
// With `isolate: false` (see below) the per-file collect/setup cost is paid
|
||||
// once per worker, not once per file. Each additional worker therefore costs
|
||||
// a fresh ~2 s of module evaluation + a long-lived Postgres DB whose tables
|
||||
// accumulate bloat (autovacuum is off on the tmpfs test PG for speed) over
|
||||
// the ~25-30 files it processes. Cap at 8 to keep the tmpfs footprint
|
||||
// bounded; past ~8 threads on a normal box we are CPU-saturated anyway.
|
||||
const maxWorkers = envInt("VITEST_MAX_WORKERS", Math.min(cpuCount, 8));
|
||||
const minWorkers = envInt("VITEST_MIN_WORKERS", Math.min(maxWorkers, cpuCount));
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
// Map #* path aliases to src/* so vite-node resolves them correctly.
|
||||
@@ -18,6 +50,10 @@ export default defineConfig({
|
||||
],
|
||||
},
|
||||
|
||||
// Cache vite-node's transform results on disk so cold starts in CI (and in
|
||||
// fresh dev containers) re-use prior compilation work.
|
||||
cacheDir: path.resolve(__dirname, ".vite-cache"),
|
||||
|
||||
test: {
|
||||
passWithNoTests: true,
|
||||
|
||||
@@ -41,32 +77,93 @@ export default defineConfig({
|
||||
VERSION_DETAIL: "test-detail",
|
||||
},
|
||||
|
||||
// Parallel test execution - each worker gets its own isolated Postgres database.
|
||||
// Parallel test execution. Each worker is a thread (not a fork) - thread
|
||||
// startup is dramatically cheaper than process forking. `isolate: false`
|
||||
// keeps the module graph alive across every test file a worker
|
||||
// processes; with ~218 files split across ~8 worker threads, the
|
||||
// alternative (`isolate: true`) re-evaluates Kysely + the full Express
|
||||
// router tree ~218 times instead of ~8, which dominates the per-file
|
||||
// "fixed overhead" budget. Per-test DB state is still reset in
|
||||
// vitest.setup.ts's beforeEach, and the worker DB lifecycle is
|
||||
// per-worker (not per-file) so re-using the pool across files is safe.
|
||||
//
|
||||
// EXCEPTION: a handful of files use `vi.mock(...)` to swap out modules
|
||||
// like `bms-table-loader` or `tachi-common`. With `isolate: false` the
|
||||
// vite-node module cache is shared across files in a worker, which
|
||||
// breaks vitest's per-file mock scoping (the mock factory does not
|
||||
// reliably take effect against an already-cached module). Those files
|
||||
// are routed through a separate project that runs with the default
|
||||
// `isolate: true`. The split keeps the fast path fast without giving
|
||||
// up the correctness of `vi.mock` for the handful of files that need
|
||||
// it.
|
||||
fileParallelism: true,
|
||||
globalSetup: "./vitest.globalSetup.ts",
|
||||
setupFiles: "./vitest.setup.ts",
|
||||
// forks pool gives stronger process isolation between workers.
|
||||
pool: "forks",
|
||||
|
||||
coverage: {
|
||||
enabled: true,
|
||||
provider: "v8",
|
||||
// Defaults plus lcov; `json` emits coverage-final.json for tachi-coverage-tools.
|
||||
reporter: ["text", "html", "clover", "json", "lcov"],
|
||||
include: coverageSupporterActionOnly
|
||||
? ["src/actions/set-user-supporter-status.ts"]
|
||||
: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.test.ts", "src/**/*.bench.ts", "src/test-utils/**"],
|
||||
...(coverageSupporterActionOnly
|
||||
? {
|
||||
thresholds: {
|
||||
lines: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
statements: 100,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
pool: "threads",
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: false,
|
||||
isolate: false,
|
||||
useAtomics: true,
|
||||
minThreads: minWorkers,
|
||||
maxThreads: maxWorkers,
|
||||
},
|
||||
},
|
||||
maxWorkers,
|
||||
minWorkers,
|
||||
|
||||
// `vi.mock(...)` users - run with full per-file isolation.
|
||||
// Keep this list short; if it grows, revisit the mocking approach
|
||||
// (e.g. dependency injection) rather than expanding the isolated set.
|
||||
projects: [
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: "default",
|
||||
exclude: [
|
||||
"src/actions/bms-table-sync.test.ts",
|
||||
"src/actions/change-pfp.test.ts",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: "isolated",
|
||||
include: [
|
||||
"src/actions/bms-table-sync.test.ts",
|
||||
"src/actions/change-pfp.test.ts",
|
||||
],
|
||||
poolOptions: {
|
||||
threads: {
|
||||
isolate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
...(coverageEnabled
|
||||
? {
|
||||
coverage: {
|
||||
enabled: true,
|
||||
provider: "v8",
|
||||
// Defaults plus lcov; `json` emits coverage-final.json for tachi-coverage-tools.
|
||||
reporter: ["text", "html", "clover", "json", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.test.ts", "src/**/*.bench.ts", "src/test-utils/**"],
|
||||
...(coverageSupporterActionOnly
|
||||
? {
|
||||
thresholds: {
|
||||
lines: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
statements: 100,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: { coverage: { enabled: false } }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,10 +4,12 @@ import pg from "pg";
|
||||
|
||||
import { ensureTestCdnBucket } from "./src/test-utils/ensure-test-cdn-bucket";
|
||||
|
||||
const POSTGRES_HOST = "tachi-postgres";
|
||||
// See note in vitest.setup.ts about POSTGRES_TEST_HOST + tachi-postgres-test.
|
||||
const POSTGRES_HOST = process.env.POSTGRES_TEST_HOST ?? "tachi-postgres";
|
||||
const POSTGRES_USER = "tachi";
|
||||
const POSTGRES_PASS = "tachi";
|
||||
const TEMPLATE_DB = "tachi_server_test_template";
|
||||
const WORKER_DB_PREFIX = "tachi_server_test_";
|
||||
|
||||
/**
|
||||
* Installs per-test dirty-table tracking in the template DB so every worker
|
||||
@@ -70,14 +72,59 @@ async function installTestDirtyTracking(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops every leaked worker DB (`tachi_server_test_*` minus the template).
|
||||
*
|
||||
* With `isolate: false` (vitest.config.ts) the per-worker setup file does NOT
|
||||
* drop its DB on `afterAll` - that hook fires per test file and we share the
|
||||
* worker across many files. We sweep them here as part of the suite-wide
|
||||
* teardown, plus opportunistically at startup so stale DBs from a killed run
|
||||
* do not accumulate.
|
||||
*/
|
||||
async function dropLeakedWorkerDatabases(): Promise<void> {
|
||||
const client = new pg.Client({
|
||||
host: POSTGRES_HOST,
|
||||
user: POSTGRES_USER,
|
||||
password: POSTGRES_PASS,
|
||||
database: "postgres",
|
||||
});
|
||||
await client.connect();
|
||||
try {
|
||||
const res = await client.query<{ datname: string }>(
|
||||
`SELECT datname FROM pg_database WHERE datname LIKE $1 AND datname <> $2`,
|
||||
[`${WORKER_DB_PREFIX}%`, TEMPLATE_DB],
|
||||
);
|
||||
for (const { datname } of res.rows) {
|
||||
// Each step is best-effort: tachi-postgres-test runs with
|
||||
// `fsync=off + full_page_writes=off` for speed, so DROP DATABASE
|
||||
// storms occasionally trip `checkpoint request failed`. We sweep
|
||||
// again on the next run's globalSetup, so logging is enough.
|
||||
try {
|
||||
await client.query(
|
||||
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1`,
|
||||
[datname],
|
||||
);
|
||||
await client.query(`DROP DATABASE IF EXISTS "${datname}"`);
|
||||
} catch (err) {
|
||||
console.warn(`[vitest-globalSetup] failed to drop ${datname}:`, err);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global vitest setup - runs ONCE before any workers start.
|
||||
*
|
||||
* Creates a fully-migrated template database and installs per-test dirty-table
|
||||
* tracking on it. Workers clone from it instead of running migrations
|
||||
* themselves, which is much faster.
|
||||
*
|
||||
* Returns a teardown function (Vitest runs this when the entire suite exits)
|
||||
* that sweeps every leaked `tachi_server_test_*` worker DB.
|
||||
*/
|
||||
export default async function globalSetup() {
|
||||
export default async function globalSetup(): Promise<() => Promise<void>> {
|
||||
const timing = process.env.TACHI_VITEST_TIMING === "1";
|
||||
const t0 = performance.now();
|
||||
execSync("just server-db-test-template-reset", { stdio: "inherit" });
|
||||
@@ -85,13 +132,26 @@ export default async function globalSetup() {
|
||||
await installTestDirtyTracking();
|
||||
const t1b = performance.now();
|
||||
await ensureTestCdnBucket();
|
||||
const t1c = performance.now();
|
||||
// Sweep stale worker DBs from prior killed runs before workers start cloning.
|
||||
await dropLeakedWorkerDatabases();
|
||||
const t2 = performance.now();
|
||||
if (timing) {
|
||||
const resetMs = t1 - t0;
|
||||
const dirtyMs = t1b - t1;
|
||||
const cdnMs = t2 - t1b;
|
||||
const cdnMs = t1c - t1b;
|
||||
const sweepMs = t2 - t1c;
|
||||
console.error(
|
||||
`[vitest-timing] globalSetup: template_reset_ms=${resetMs.toFixed(1)} install_dirty_tracking_ms=${dirtyMs.toFixed(1)} ensure_test_cdn_bucket_ms=${cdnMs.toFixed(1)} total_ms=${(t2 - t0).toFixed(1)}`,
|
||||
`[vitest-timing] globalSetup: template_reset_ms=${resetMs.toFixed(1)} install_dirty_tracking_ms=${dirtyMs.toFixed(1)} ensure_test_cdn_bucket_ms=${cdnMs.toFixed(1)} sweep_leaked_dbs_ms=${sweepMs.toFixed(1)} total_ms=${(t2 - t0).toFixed(1)}`,
|
||||
);
|
||||
}
|
||||
return async () => {
|
||||
const tTd0 = performance.now();
|
||||
await dropLeakedWorkerDatabases();
|
||||
if (timing) {
|
||||
console.error(
|
||||
`[vitest-timing] globalTeardown: sweep_leaked_dbs_ms=${(performance.now() - tTd0).toFixed(1)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
/**
|
||||
* Vitest per-worker setup for parallel test execution.
|
||||
*
|
||||
* Each worker gets its own isolated Postgres database cloned from the
|
||||
* template created in vitest.globalSetup.ts.
|
||||
* Each WORKER (not file) gets its own isolated Postgres database cloned from
|
||||
* the template created in vitest.globalSetup.ts. With `pool: "threads"` and
|
||||
* `isolate: false`, this module evaluates ONCE per worker thread but the
|
||||
* lifecycle callbacks below still fire per test file - that asymmetry is
|
||||
* fundamental to the perf win of `isolate: false`. As a result:
|
||||
*
|
||||
* - The per-worker DB is created lazily on the first file that needs it.
|
||||
* - Cleanup (close pool, close mock-api, DROP DATABASE) deliberately does
|
||||
* NOT happen in `afterAll` - that would kill shared resources mid-worker.
|
||||
* Worker DBs are swept by vitest.globalSetup.ts's teardown.
|
||||
* - `beforeEach` still runs per test; it TRUNCATEs whatever the previous
|
||||
* test dirtied via the trigger-based tracker, gated on whether any app
|
||||
* code in this worker has actually touched the DB.
|
||||
*
|
||||
* IMPORTANT: process.env assignments at the top level of this module run
|
||||
* before the test file's module graph is resolved, so app code that reads
|
||||
@@ -14,7 +25,10 @@ import crypto from "node:crypto";
|
||||
const WORKER_ID = crypto.randomUUID().slice(0, 8);
|
||||
const WORKER_DB_NAME = `tachi_server_test_${WORKER_ID}`;
|
||||
|
||||
const POSTGRES_HOST = "tachi-postgres";
|
||||
// `tachi-postgres-test` (tmpfs + fsync=off, see docker-compose-dev.yml) is the
|
||||
// preferred backend for tests. Fall back to the dev Postgres if the dedicated
|
||||
// service isn't running so a freshly-cloned repo still passes tests.
|
||||
const POSTGRES_HOST = process.env.POSTGRES_TEST_HOST ?? "tachi-postgres";
|
||||
const POSTGRES_USER = "tachi";
|
||||
const POSTGRES_PASS = "tachi";
|
||||
|
||||
@@ -63,25 +77,6 @@ async function createWorkerDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
async function dropWorkerDatabase() {
|
||||
const client = adminClient();
|
||||
|
||||
await client.connect();
|
||||
|
||||
try {
|
||||
// Terminate any open connections first so DROP DATABASE succeeds.
|
||||
await client.query(`
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = '${WORKER_DB_NAME}'
|
||||
`);
|
||||
|
||||
await client.query(`DROP DATABASE IF EXISTS "${WORKER_DB_NAME}"`);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDatabase() {
|
||||
// Lazily import so env vars are definitely set before the pool is created.
|
||||
const { default: db } = await import("#services/pg/db");
|
||||
@@ -100,7 +95,24 @@ async function resetDatabase() {
|
||||
const idents = ["_test_dirty_tables", ...dirty.rows.map((r) => r.table_name)].map((n) =>
|
||||
sql.id(n),
|
||||
);
|
||||
await sql`TRUNCATE TABLE ${sql.join(idents, sql`, `)} RESTART IDENTITY CASCADE`.execute(db);
|
||||
// TRUNCATE takes ACCESS EXCLUSIVE on every dirty table AND every table
|
||||
// reachable via FK CASCADE. With `pool: "threads"` + `isolate: false`
|
||||
// the mock-api supertest server is shared across files in a worker, so
|
||||
// an HTTP response from the previous `it()` can still be holding a row
|
||||
// lock on one of the cascade targets when `beforeEach` fires here. We
|
||||
// surface that as a fail-fast `lock_timeout` (set on the txn so it
|
||||
// applies to the TRUNCATE) instead of waiting `deadlock_timeout`
|
||||
// (1 s by default), then retry up to 5 times. CI exposes this maybe
|
||||
// 1-2 tests per ~1700 runs; locally it is invisible.
|
||||
await runTruncateWithRetry(async () => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
await sql`SET LOCAL lock_timeout = '500ms'`.execute(trx);
|
||||
await sql`TRUNCATE TABLE ${sql.join(
|
||||
idents,
|
||||
sql`, `,
|
||||
)} RESTART IDENTITY CASCADE`.execute(trx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -111,8 +123,52 @@ async function resetDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const RETRYABLE_PG_CODES = new Set([
|
||||
"40P01", // deadlock_detected
|
||||
"55P03", // lock_not_available (lock_timeout)
|
||||
]);
|
||||
|
||||
async function runTruncateWithRetry(fn: () => Promise<void>): Promise<void> {
|
||||
const maxAttempts = 5;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
// Sequential retry of a single fail-fast op; awaiting in the loop
|
||||
// is the whole point.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await fn();
|
||||
return;
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string } | null)?.code;
|
||||
if (attempt === maxAttempts || code === undefined || !RETRYABLE_PG_CODES.has(code)) {
|
||||
throw err;
|
||||
}
|
||||
// Exponential backoff with jitter, capped: 20, 40, 80, 160 ms.
|
||||
const baseMs = 20 * 2 ** (attempt - 1);
|
||||
const sleepMs = baseMs + Math.floor(Math.random() * baseMs);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => setTimeout(resolve, sleepMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-worker DB creation is lazy: only paid by workers whose tests actually
|
||||
// load #services/pg/db. With `isolate: false` this still triggers on the first
|
||||
// DB-using file in the worker; pure-unit-only workers skip CREATE DATABASE
|
||||
// entirely. `__tachi_pg_loaded` is set in db.ts on module load.
|
||||
let workerDbCreatedHere = false;
|
||||
async function ensureWorkerDatabase() {
|
||||
if (workerDbCreatedHere) {
|
||||
return;
|
||||
}
|
||||
await createWorkerDatabase();
|
||||
workerDbCreatedHere = true;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const gFlags = globalThis as { __tachi_pg_loaded?: boolean };
|
||||
if (gFlags.__tachi_pg_loaded === true) {
|
||||
await ensureWorkerDatabase();
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async (ctx) => {
|
||||
@@ -130,51 +186,54 @@ beforeEach(async (ctx) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const tReset0 = performance.now();
|
||||
await resetDatabase();
|
||||
if (TIMING) {
|
||||
const d = performance.now() - tReset0;
|
||||
resetCalls += 1;
|
||||
msResetDatabaseTotal += d;
|
||||
msResetDatabaseMax = Math.max(msResetDatabaseMax, d);
|
||||
// Skip the TRUNCATE / cache-reset work when this worker has not yet loaded
|
||||
// the DB at all (`__tachi_pg_loaded`, set in db.ts) or has not issued any
|
||||
// query since the last reset (`__tachi_pg_used`, set by the pool wrappers
|
||||
// in db.ts). Both are plain property reads on globalThis - no import cost
|
||||
// when the conditions are false. Worth roughly 0.5-2 ms per pure-unit
|
||||
// test in a mixed worker, and the DROP DATABASE / pool init cost on
|
||||
// workers that happen to be pure-unit only.
|
||||
const g = globalThis as unknown as {
|
||||
__tachi_pg_loaded?: boolean;
|
||||
__tachi_pg_used?: boolean;
|
||||
};
|
||||
|
||||
if (g.__tachi_pg_loaded === true && g.__tachi_pg_used === true) {
|
||||
// Defensive: a test file that loads db.ts only inside an `it()` body
|
||||
// would skip our beforeAll gate, so re-check here.
|
||||
await ensureWorkerDatabase();
|
||||
const tReset0 = performance.now();
|
||||
await resetDatabase();
|
||||
g.__tachi_pg_used = false;
|
||||
if (TIMING) {
|
||||
const d = performance.now() - tReset0;
|
||||
resetCalls += 1;
|
||||
msResetDatabaseTotal += d;
|
||||
msResetDatabaseMax = Math.max(msResetDatabaseMax, d);
|
||||
}
|
||||
}
|
||||
// Login-heavy router tests share the in-memory login rate limiter; reset each
|
||||
// test so AggressiveRateLimit (15 / 10 min) does not 429 and omit Set-Cookie.
|
||||
const tRl0 = performance.now();
|
||||
const { ClearTestingRateLimitCache } = await import("#server/middleware/rate-limiter");
|
||||
ClearTestingRateLimitCache();
|
||||
if (TIMING) {
|
||||
msRateLimitCacheTotal += performance.now() - tRl0;
|
||||
|
||||
// Login-heavy router tests share the in-memory login rate limiter; reset
|
||||
// each test so AggressiveRateLimit (15 / 10 min) does not 429 and omit
|
||||
// Set-Cookie. `__tachi_rate_limiter_loaded` is set on module load in
|
||||
// rate-limiter.ts so we skip the import entirely on workers that never
|
||||
// touch any router.
|
||||
const gRl = globalThis as unknown as { __tachi_rate_limiter_loaded?: boolean };
|
||||
if (gRl.__tachi_rate_limiter_loaded === true) {
|
||||
const tRl0 = performance.now();
|
||||
const { ClearTestingRateLimitCache } = await import("#server/middleware/rate-limiter");
|
||||
ClearTestingRateLimitCache();
|
||||
if (TIMING) {
|
||||
msRateLimitCacheTotal += performance.now() - tRl0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
let msCloseMock = 0;
|
||||
let msClosePg = 0;
|
||||
let msDropDb = 0;
|
||||
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
const { CloseServerConnection } = await import("#test-utils/mock-api");
|
||||
await CloseServerConnection();
|
||||
msCloseMock = performance.now() - t0;
|
||||
} catch {
|
||||
// No mock HTTP server in this worker, or close failed.
|
||||
}
|
||||
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
const { ClosePgConnection } = await import("#services/pg/db");
|
||||
await ClosePgConnection();
|
||||
msClosePg = performance.now() - t0;
|
||||
} catch {
|
||||
// Pool may not have been initialised if no test ran a query.
|
||||
}
|
||||
|
||||
const tDrop0 = performance.now();
|
||||
await dropWorkerDatabase();
|
||||
msDropDb = performance.now() - tDrop0;
|
||||
|
||||
// Per-file afterAll is deliberately a no-op with `isolate: false` - closing
|
||||
// the pool or stopping the supertest server here would break subsequent
|
||||
// files in the same worker. Worker-wide cleanup (DROP DATABASE, pool/server
|
||||
// close) is the job of vitest.globalSetup.ts's teardown sweep + process exit.
|
||||
afterAll(() => {
|
||||
if (TIMING) {
|
||||
const wallMs = performance.now() - workerWallStart;
|
||||
const avgReset = resetCalls > 0 ? msResetDatabaseTotal / resetCalls : 0;
|
||||
@@ -187,9 +246,6 @@ afterAll(async () => {
|
||||
`reset_avg_ms=${avgReset.toFixed(1)}`,
|
||||
`reset_max_ms=${msResetDatabaseMax.toFixed(1)}`,
|
||||
`rate_limit_cache_reset_total_ms=${msRateLimitCacheTotal.toFixed(1)}`,
|
||||
`teardown_close_mock_ms=${msCloseMock.toFixed(1)}`,
|
||||
`teardown_close_pg_ms=${msClosePg.toFixed(1)}`,
|
||||
`teardown_drop_db_ms=${msDropDb.toFixed(1)}`,
|
||||
`worker_wall_ms=${wallMs.toFixed(1)}`,
|
||||
].join(" "),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user