mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-10 00:08:22 +03:00
rebel shell
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
Dockerfile
|
||||
|
||||
# Env files — mirror repo root `.dockerignore` when this package is build context alone.
|
||||
# Env files - mirror repo root `.dockerignore` when this package is build context alone.
|
||||
.env
|
||||
.env.*
|
||||
**/.env
|
||||
|
||||
@@ -13,7 +13,6 @@ MIGRATIONS_DIR=/tachi/db/migrations
|
||||
LOG_LEVEL=debug
|
||||
|
||||
TACHI_EMAIL_FROM=dev@localhost
|
||||
TACHI_EMAIL_AUTH_POSTMARK=false
|
||||
TACHI_EMAIL_HOST=tachi-mailpit
|
||||
TACHI_EMAIL_PORT=1025
|
||||
TACHI_EMAIL_SECURE=false
|
||||
|
||||
@@ -53,7 +53,6 @@ TACHI_CDN_SAVE_LOCATION_PRIVATE_REGION=
|
||||
|
||||
# Email (required)
|
||||
TACHI_EMAIL_FROM=
|
||||
TACHI_EMAIL_AUTH_POSTMARK=
|
||||
TACHI_EMAIL_HOST=
|
||||
TACHI_EMAIL_PORT=
|
||||
TACHI_EMAIL_SECURE=
|
||||
@@ -69,7 +68,7 @@ TACHI_MYT_API_HOST=
|
||||
TACHI_ARC_AUTH_TOKEN=
|
||||
TACHI_MYT_AUTH_TOKEN=
|
||||
|
||||
# OAuth2 — set all 3 for a provider or none
|
||||
# OAuth2 - set all 3 for a provider or none
|
||||
TACHI_FLO_OAUTH2_CLIENT_ID=
|
||||
TACHI_FLO_OAUTH2_CLIENT_SECRET=
|
||||
TACHI_FLO_OAUTH2_REDIRECT_URI=
|
||||
@@ -80,7 +79,7 @@ TACHI_MIN_OAUTH2_CLIENT_ID=
|
||||
TACHI_MIN_OAUTH2_CLIENT_SECRET=
|
||||
TACHI_MIN_OAUTH2_REDIRECT_URI=
|
||||
|
||||
# CG — set both for a suffix or neither
|
||||
# CG - set both for a suffix or neither
|
||||
TACHI_CG_DEV_API_KEY=
|
||||
TACHI_CG_DEV_URL=
|
||||
TACHI_CG_NAG_API_KEY=
|
||||
@@ -88,7 +87,7 @@ TACHI_CG_NAG_URL=
|
||||
TACHI_CG_GAN_API_KEY=
|
||||
TACHI_CG_GAN_URL=
|
||||
|
||||
# Seeds (optional) — set TACHI_SEEDS_TYPE to LOCAL_FILES or GIT_REPO to enable
|
||||
# Seeds (optional) - set TACHI_SEEDS_TYPE to LOCAL_FILES or GIT_REPO to enable
|
||||
TACHI_SEEDS_TYPE=
|
||||
TACHI_SEEDS_PATH=
|
||||
TACHI_SEEDS_REPO_URL=
|
||||
@@ -96,7 +95,7 @@ TACHI_SEEDS_USER_NAME=
|
||||
TACHI_SEEDS_USER_EMAIL=
|
||||
TACHI_SEEDS_BRANCH=
|
||||
|
||||
# Invite codes — set all 3 or none
|
||||
# Invite codes - set all 3 or none
|
||||
TACHI_INVITE_CODE_BATCH_SIZE=
|
||||
TACHI_INVITE_CODE_INVITE_CAP=
|
||||
TACHI_INVITE_CODE_BETA_USER_BONUS=
|
||||
|
||||
@@ -42,7 +42,6 @@ TACHI_GAME_GROUPS=iidx,museca,sdvx,bms,chunithm,usc,wacca,popn,jubeat,pms,maimai
|
||||
TACHI_IMPORT_TYPES=file/eamusement-iidx-csv,file/batch-manual,file/solid-state-squad,file/pli-iidx-csv,ir/direct-manual,ir/barbatos,ir/fervidex,ir/fervidex-static,ir/beatoraja,ir/usc,ir/kshook-sv6c,api/eag-iidx,api/eag-sdvx,api/flo-iidx,api/flo-sdvx,api/min-sdvx
|
||||
|
||||
TACHI_EMAIL_FROM=test@example.com
|
||||
TACHI_EMAIL_AUTH_POSTMARK=false
|
||||
TACHI_EMAIL_HOST=127.0.0.1
|
||||
TACHI_EMAIL_PORT=1025
|
||||
TACHI_EMAIL_SECURE=false
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
files: "src/test-utils/single-process-tap.ts"
|
||||
test-env:
|
||||
- NODE_ENV=test
|
||||
- PORT=8080
|
||||
- LOG_LEVEL=info
|
||||
# On a local machine, these tests run in under 2-3 minutes.
|
||||
# On github actions, these tests can take an inordinate amount of time.
|
||||
# The timeout is set to 10 minutes, which is ridiculous.
|
||||
# Doesn't matter for real machines.
|
||||
timeout: 600
|
||||
ts: true
|
||||
before: "./src/test-utils/hack-setup.js"
|
||||
check-coverage: true
|
||||
branches: 30
|
||||
functions: 80
|
||||
lines: 80
|
||||
statements: 80
|
||||
reporter: terse
|
||||
@@ -31,6 +31,6 @@ export const ACTION_ResendVerifyEmail = MakeAction("RESEND_VERIFY_EMAIL", async
|
||||
|
||||
const { text, html } = EmailFormatVerifyEmail(taker.acct.username, newToken);
|
||||
|
||||
void SendEmail(verifyInfo.email, "Email Verification", html, text);
|
||||
await SendEmail(verifyInfo.email, "Email Verification", html, text);
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import DB from "#services/pg/db";
|
||||
import { seedInvite, seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ANON_ACTION_Register } from "./register";
|
||||
|
||||
@@ -217,6 +218,52 @@ describe("ANON_ACTION_Register", () => {
|
||||
expect(user).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Sequence gaps ─────────────────────────────────────────────────────────
|
||||
|
||||
it("does not burn sequence values on failed registrations with bad invite codes", async () => {
|
||||
const first = await ANON_ACTION_Register(taker, {
|
||||
username: "firstuser",
|
||||
"!password": "securepassword",
|
||||
email: "first@example.com",
|
||||
captcha: "test",
|
||||
inviteCode,
|
||||
});
|
||||
|
||||
// Need a fresh invite for the next successful signup.
|
||||
const { id: seedId } = await seedUser({
|
||||
username: "inviter2",
|
||||
email: "inviter2@example.com",
|
||||
});
|
||||
const secondInvite = await seedInvite(seedId, "SECOND_INVITE");
|
||||
|
||||
// 10 failed registrations — bad invite code but unique username/email,
|
||||
// so they enter the transaction and reach AddNewUser before failing.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await expect(
|
||||
ANON_ACTION_Register(taker, {
|
||||
username: `baduser${i}`,
|
||||
"!password": "securepassword",
|
||||
email: `bad${i}@example.com`,
|
||||
captcha: "test",
|
||||
inviteCode: "BOGUS_CODE",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
}
|
||||
|
||||
const second = await ANON_ACTION_Register(taker, {
|
||||
username: "seconduser",
|
||||
"!password": "securepassword",
|
||||
email: "second@example.com",
|
||||
captcha: "test",
|
||||
inviteCode: secondInvite,
|
||||
});
|
||||
|
||||
// If sequences are gap-free, the second real user should be first.id + 1
|
||||
// (skipping only the seedUser helper we inserted, which also burns one).
|
||||
// With BIGSERIAL, this FAILS: the 10 rolled-back txns each burned a value.
|
||||
expect(second.userID).toBe(first.userID + 2);
|
||||
});
|
||||
|
||||
// ── Audit log ──────────────────────────────────────────────────────────────
|
||||
|
||||
it("writes a BAD action row when the username is already taken", async () => {
|
||||
@@ -276,3 +323,94 @@ describe("ANON_ACTION_Register", () => {
|
||||
expect(input).not.toHaveProperty("!password");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Bootstrap invite (INVITE_ADMIN_INITIAL_INVITE_CODE) ──────────────────────
|
||||
|
||||
describe("ANON_ACTION_Register - bootstrap invite", () => {
|
||||
const taker = { ip: "127.0.0.1" };
|
||||
const BOOTSTRAP_CODE = "BOOTSTRAP_SECRET_FOR_TESTS";
|
||||
|
||||
let originalBootstrap: typeof ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE;
|
||||
|
||||
beforeEach(() => {
|
||||
originalBootstrap = ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE;
|
||||
ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE = BOOTSTRAP_CODE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE = originalBootstrap;
|
||||
});
|
||||
|
||||
it("creates an admin user when the instance has no accounts", async () => {
|
||||
const result = await ANON_ACTION_Register(taker, {
|
||||
username: "firstadmin",
|
||||
"!password": "securepassword",
|
||||
email: "admin@example.com",
|
||||
captcha: "test",
|
||||
inviteCode: BOOTSTRAP_CODE,
|
||||
});
|
||||
|
||||
const account = await DB.selectFrom("account")
|
||||
.select(["account.id", "account.auth_level"])
|
||||
.where("account.id", "=", result.userID)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(account.auth_level).toBe("admin");
|
||||
});
|
||||
|
||||
it("does not consume any priv_invite row", async () => {
|
||||
await ANON_ACTION_Register(taker, {
|
||||
username: "firstadmin",
|
||||
"!password": "securepassword",
|
||||
email: "admin@example.com",
|
||||
captcha: "test",
|
||||
inviteCode: BOOTSTRAP_CODE,
|
||||
});
|
||||
|
||||
const invites = await DB.selectFrom("priv_invite")
|
||||
.select(DB.fn.countAll().as("count"))
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(Number(invites.count)).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects the bootstrap code when an account already exists", async () => {
|
||||
await seedUser();
|
||||
|
||||
await expect(
|
||||
ANON_ACTION_Register(taker, {
|
||||
username: "seconduser",
|
||||
"!password": "securepassword",
|
||||
email: "second@example.com",
|
||||
captcha: "test",
|
||||
inviteCode: BOOTSTRAP_CODE,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 400 });
|
||||
});
|
||||
|
||||
it("rejects a wrong code even on an empty instance", async () => {
|
||||
await expect(
|
||||
ANON_ACTION_Register(taker, {
|
||||
username: "firstadmin",
|
||||
"!password": "securepassword",
|
||||
email: "admin@example.com",
|
||||
captcha: "test",
|
||||
inviteCode: "WRONG_CODE",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("falls through to normal invite validation when env is unset", async () => {
|
||||
ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE = undefined;
|
||||
|
||||
await expect(
|
||||
ANON_ACTION_Register(taker, {
|
||||
username: "firstadmin",
|
||||
"!password": "securepassword",
|
||||
email: "admin@example.com",
|
||||
captcha: "test",
|
||||
inviteCode: "NONEXISTENT",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,25 @@ import DB from "#services/pg/db";
|
||||
import { Random20Hex } from "#utils/misc";
|
||||
import { CheckIfEmailInUse, GetUserCaseInsensitive } from "#utils/user";
|
||||
import { ExpectedErr, log } from "bliss";
|
||||
import crypto from "crypto";
|
||||
import { sql } from "kysely";
|
||||
import { type UserDocument } from "tachi-common";
|
||||
|
||||
/** Namespace for the advisory lock that serialises bootstrap-invite registrations. */
|
||||
const BOOTSTRAP_INVITE_ADVISORY_KEY1 = 0x42_6f_6f_74; // "Boot"
|
||||
const BOOTSTRAP_INVITE_ADVISORY_KEY2 = 0x49_6e_76_74; // "Invt"
|
||||
|
||||
function bootstrapCodeMatches(input: string, expected: string): boolean {
|
||||
const a = Buffer.from(input, "utf8");
|
||||
const b = Buffer.from(expected, "utf8");
|
||||
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export const ANON_ACTION_Register = MakeAnonAction(
|
||||
"REGISTER",
|
||||
async (taker, { email, "!password": password, inviteCode, username, captcha }) => {
|
||||
@@ -44,10 +61,6 @@ export const ANON_ACTION_Register = MakeAnonAction(
|
||||
}
|
||||
|
||||
const newUser = await DB.transaction().execute(async (txn): Promise<UserDocument> => {
|
||||
// if we get to this point, We're good to create the user.
|
||||
|
||||
const { newUser, newSettings: _ } = await AddNewUser(txn, username, password, email);
|
||||
|
||||
if (ServerConfig.INVITE_CODE_CONFIG) {
|
||||
if (!inviteCode) {
|
||||
throw new ExpectedErr(
|
||||
@@ -55,20 +68,61 @@ export const ANON_ACTION_Register = MakeAnonAction(
|
||||
"No invite code given, yet the server uses invites.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isBootstrapInvite =
|
||||
ServerConfig.INVITE_CODE_CONFIG &&
|
||||
ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE &&
|
||||
inviteCode !== null &&
|
||||
bootstrapCodeMatches(inviteCode, ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE);
|
||||
|
||||
if (isBootstrapInvite) {
|
||||
await sql`SELECT pg_advisory_xact_lock(${BOOTSTRAP_INVITE_ADVISORY_KEY1}, ${BOOTSTRAP_INVITE_ADVISORY_KEY2})`.execute(
|
||||
txn,
|
||||
);
|
||||
|
||||
const { count } = await txn
|
||||
.selectFrom("account")
|
||||
.select(txn.fn.countAll().as("count"))
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
if (Number(count) > 0) {
|
||||
log.info("Bootstrap invite used but instance already has users.");
|
||||
throw new ExpectedErr(400, `Invalid invite code given: ${inviteCode}.`);
|
||||
}
|
||||
} else if (ServerConfig.INVITE_CODE_CONFIG) {
|
||||
// Validate the invite code BEFORE creating the user so a bad code
|
||||
// never burns a sequence value on `account.id`.
|
||||
// FOR UPDATE locks the row so a concurrent signup can't consume
|
||||
// the same code between our check and the update below.
|
||||
const inviteCodeDoc = await txn
|
||||
.selectFrom("priv_invite")
|
||||
.select("code")
|
||||
.where("code", "=", inviteCode)
|
||||
.where("consumed", "=", false)
|
||||
.select("priv_invite.code")
|
||||
.where("priv_invite.code", "=", inviteCode!)
|
||||
.where("priv_invite.consumed", "=", false)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!inviteCodeDoc) {
|
||||
log.info(`Invalid invite code given: ${inviteCode}.`);
|
||||
throw new ExpectedErr(400, `Invalid invite code given: ${inviteCode}.`);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Consumed invite ${inviteCodeDoc.code}.`);
|
||||
const { newUser, newSettings: _ } = await AddNewUser(txn, username, password, email);
|
||||
|
||||
if (isBootstrapInvite) {
|
||||
await txn
|
||||
.updateTable("account")
|
||||
.set({ auth_level: "admin" })
|
||||
.where("account.id", "=", newUser.id)
|
||||
.execute();
|
||||
|
||||
log.info(
|
||||
`Bootstrap invite consumed — user ${newUser.username} (${newUser.id}) is now admin.`,
|
||||
);
|
||||
} else if (ServerConfig.INVITE_CODE_CONFIG) {
|
||||
log.info(`Consumed invite ${inviteCode}.`);
|
||||
|
||||
await txn
|
||||
.updateTable("priv_invite")
|
||||
@@ -77,7 +131,7 @@ export const ANON_ACTION_Register = MakeAnonAction(
|
||||
consumed_at: new Date().toISOString(),
|
||||
consumed_by: newUser.id,
|
||||
})
|
||||
.where("code", "=", inviteCode)
|
||||
.where("priv_invite.code", "=", inviteCode!)
|
||||
.execute();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { loadServerEnvFile } from "#lib/setup/load-server-env";
|
||||
loadServerEnvFile(process.env.NODE_ENV === "test" ? ".env.test" : ".env");
|
||||
|
||||
@@ -5,8 +6,12 @@ import { runCronTickOnce } from "#lib/jobs/cron/cron-service";
|
||||
import { log } from "#lib/log/log";
|
||||
import { Env } from "#lib/setup/config";
|
||||
import { ClosePgConnection } from "#services/pg/db";
|
||||
import { Sleep } from "#utils/misc";
|
||||
import { writeFileSync } from "fs";
|
||||
import { applyMigrations } from "tachi-db-migration-engine";
|
||||
|
||||
const HEARTBEAT_FILE = "/tmp/worker-heartbeat";
|
||||
|
||||
const TICK_MS = 5_000;
|
||||
|
||||
process.on("uncaughtException", (err, origin) => {
|
||||
@@ -23,6 +28,7 @@ void bootstrap();
|
||||
async function bootstrap() {
|
||||
await applyMigrations(Env.POSTGRES_URL, Env.MIGRATIONS_DIR);
|
||||
log.info({ bootInfo: true }, "tachi cron worker starting.");
|
||||
|
||||
let stopping = false;
|
||||
const shutdown = () => {
|
||||
stopping = true;
|
||||
@@ -32,8 +38,8 @@ async function bootstrap() {
|
||||
|
||||
// eslint-disable-next-line no-unmodified-loop-condition
|
||||
while (!stopping) {
|
||||
writeFileSync(HEARTBEAT_FILE, Date.now().toString());
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await runCronTickOnce();
|
||||
} catch (e) {
|
||||
log.error(e, "Cron tick error.");
|
||||
@@ -41,10 +47,7 @@ async function bootstrap() {
|
||||
if (stopping) {
|
||||
break;
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, TICK_MS);
|
||||
});
|
||||
await Sleep(TICK_MS);
|
||||
}
|
||||
log.info("Cron worker stopped.");
|
||||
await ClosePgConnection();
|
||||
|
||||
@@ -150,7 +150,7 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => {
|
||||
|
||||
it("counts at most 30 PBs per flare category (global flare order, per-category index)", async () => {
|
||||
const { id: userId } = await seedUser();
|
||||
// 31 CLASSIC rows, flareSkill 1031..1001 — sorted desc; index 30 is dropped (top < 30 keeps 0..29).
|
||||
// 31 CLASSIC rows, flareSkill 1031..1001 - sorted desc; index 30 is dropped (top < 30 keeps 0..29).
|
||||
for (let i = 0; i < 31; i++) {
|
||||
await seedDdrPbRow(userId, {
|
||||
flareSkill: 1031 - i,
|
||||
|
||||
@@ -236,7 +236,7 @@ describe("IIDX_IMPL (unit)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoreCalcs ktLampRatingNC / ktLampRatingHC / ktLampRatingEXHC", () => {
|
||||
describe("scoreCalcs ktLampRatingHC / ktLampRatingEXHC", () => {
|
||||
it("IIDX SP", () => {
|
||||
const run = (
|
||||
scoreData: Partial<ScoreData<"iidx-sp">>,
|
||||
@@ -258,37 +258,30 @@ describe("IIDX_IMPL (unit)", () => {
|
||||
const tiered = { ncTier: mkTier(15), hcTier: mkTier(16), exhcTier: mkTier(17) };
|
||||
|
||||
expect(run({ lamp: "FAILED" }, {})).toMatchObject({
|
||||
ktLampRatingNC: 0,
|
||||
ktLampRatingHC: 0,
|
||||
ktLampRatingEXHC: 0,
|
||||
});
|
||||
expect(run({ lamp: "EASY CLEAR" }, {})).toMatchObject({
|
||||
ktLampRatingNC: 0,
|
||||
ktLampRatingHC: 0,
|
||||
ktLampRatingEXHC: 0,
|
||||
});
|
||||
expect(run({ lamp: "CLEAR" }, tiered)).toMatchObject({
|
||||
ktLampRatingNC: 15,
|
||||
ktLampRatingHC: 0,
|
||||
ktLampRatingEXHC: 0,
|
||||
});
|
||||
expect(run({ lamp: "HARD CLEAR" }, tiered)).toMatchObject({
|
||||
ktLampRatingNC: 15,
|
||||
ktLampRatingHC: 16,
|
||||
ktLampRatingEXHC: 0,
|
||||
});
|
||||
expect(run({ lamp: "EX HARD CLEAR" }, tiered)).toMatchObject({
|
||||
ktLampRatingNC: 15,
|
||||
ktLampRatingHC: 16,
|
||||
ktLampRatingEXHC: 17,
|
||||
});
|
||||
expect(run({ lamp: "FULL COMBO" }, tiered)).toMatchObject({
|
||||
ktLampRatingNC: 15,
|
||||
ktLampRatingHC: 16,
|
||||
ktLampRatingEXHC: 17,
|
||||
});
|
||||
expect(run({ lamp: "HARD CLEAR" }, { ncTier: mkTier(15) })).toMatchObject({
|
||||
ktLampRatingNC: 15,
|
||||
ktLampRatingHC: 15,
|
||||
ktLampRatingEXHC: 0,
|
||||
});
|
||||
@@ -313,27 +306,22 @@ describe("IIDX_IMPL (unit)", () => {
|
||||
}
|
||||
|
||||
expect(run({ lamp: "EASY CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({
|
||||
ktLampRatingNC: 0,
|
||||
ktLampRatingHC: 0,
|
||||
ktLampRatingEXHC: 0,
|
||||
});
|
||||
expect(run({ lamp: "CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({
|
||||
ktLampRatingNC: 15,
|
||||
ktLampRatingHC: 0,
|
||||
ktLampRatingEXHC: 0,
|
||||
});
|
||||
expect(run({ lamp: "HARD CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({
|
||||
ktLampRatingNC: 15,
|
||||
ktLampRatingHC: 15,
|
||||
ktLampRatingEXHC: 0,
|
||||
});
|
||||
expect(run({ lamp: "EX HARD CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({
|
||||
ktLampRatingNC: 15,
|
||||
ktLampRatingHC: 15,
|
||||
ktLampRatingEXHC: 15,
|
||||
});
|
||||
expect(run({ lamp: "FULL COMBO" }, { dpTier: mkTier(15) })).toMatchObject({
|
||||
ktLampRatingNC: 15,
|
||||
ktLampRatingHC: 15,
|
||||
ktLampRatingEXHC: 15,
|
||||
});
|
||||
|
||||
@@ -29,23 +29,19 @@ type IIDXGames = "iidx-dp" | "iidx-sp";
|
||||
const IIDX_SESSION_CALCS: GPTSessionCalcs<IIDXGames> = (arr) => ({
|
||||
BPI: SessionAvgBest10For("BPI")(arr),
|
||||
ktLampRating: SessionAvgBest10For("ktLampRating")(arr),
|
||||
ktLampRatingNC: SessionAvgBest10For("ktLampRatingNC")(arr),
|
||||
ktLampRatingHC: SessionAvgBest10For("ktLampRatingHC")(arr),
|
||||
ktLampRatingEXHC: SessionAvgBest10For("ktLampRatingEXHC")(arr),
|
||||
});
|
||||
|
||||
const IIDX_PROFILE_CALCS: GPTProfileCalcs<IIDXGames> = async (game, userID) => {
|
||||
const [BPI, ktLampRating, ktLampRatingNC, ktLampRatingHC, ktLampRatingEXHC] = await Promise.all(
|
||||
[
|
||||
ProfileAvgBestN("BPI", 20, true)(game, userID),
|
||||
ProfileAvgBestN("ktLampRating", 20)(game, userID),
|
||||
ProfileAvgBestN("ktLampRatingNC", 20)(game, userID),
|
||||
ProfileAvgBestN("ktLampRatingHC", 20)(game, userID),
|
||||
ProfileAvgBestN("ktLampRatingEXHC", 20)(game, userID),
|
||||
],
|
||||
);
|
||||
const [BPI, ktLampRating, ktLampRatingHC, ktLampRatingEXHC] = await Promise.all([
|
||||
ProfileAvgBestN("BPI", 20, true)(game, userID),
|
||||
ProfileAvgBestN("ktLampRating", 20)(game, userID),
|
||||
ProfileAvgBestN("ktLampRatingHC", 20)(game, userID),
|
||||
ProfileAvgBestN("ktLampRatingEXHC", 20)(game, userID),
|
||||
]);
|
||||
|
||||
return { BPI, ktLampRating, ktLampRatingNC, ktLampRatingHC, ktLampRatingEXHC };
|
||||
return { BPI, ktLampRating, ktLampRatingHC, ktLampRatingEXHC };
|
||||
};
|
||||
|
||||
const IIDX_MERGERS: Array<PBMergeFunction<IIDXGames>> = [
|
||||
@@ -156,11 +152,6 @@ export const IIDX_SP_IMPL: GameImplementation<"iidx-sp"> = {
|
||||
ktLampRating = 0;
|
||||
}
|
||||
|
||||
const atLeastNcClear =
|
||||
scoreData.lamp === "CLEAR" ||
|
||||
scoreData.lamp === "HARD CLEAR" ||
|
||||
scoreData.lamp === "EX HARD CLEAR" ||
|
||||
scoreData.lamp === "FULL COMBO";
|
||||
const atLeastHcClear =
|
||||
scoreData.lamp === "HARD CLEAR" ||
|
||||
scoreData.lamp === "EX HARD CLEAR" ||
|
||||
@@ -168,14 +159,12 @@ export const IIDX_SP_IMPL: GameImplementation<"iidx-sp"> = {
|
||||
const atLeastExhcClear =
|
||||
scoreData.lamp === "EX HARD CLEAR" || scoreData.lamp === "FULL COMBO";
|
||||
|
||||
const ktLampRatingNC = atLeastNcClear ? ncValue : 0;
|
||||
const ktLampRatingHC = atLeastHcClear ? hcValue : 0;
|
||||
const ktLampRatingEXHC = atLeastExhcClear ? exhcValue : 0;
|
||||
|
||||
return {
|
||||
BPI: bpi,
|
||||
ktLampRating,
|
||||
ktLampRatingNC,
|
||||
ktLampRatingHC,
|
||||
ktLampRatingEXHC,
|
||||
};
|
||||
@@ -235,11 +224,6 @@ export const IIDX_DP_IMPL: GameImplementation<"iidx-dp"> = {
|
||||
ktLampRating = 0;
|
||||
}
|
||||
|
||||
const atLeastNcClear =
|
||||
scoreData.lamp === "CLEAR" ||
|
||||
scoreData.lamp === "HARD CLEAR" ||
|
||||
scoreData.lamp === "EX HARD CLEAR" ||
|
||||
scoreData.lamp === "FULL COMBO";
|
||||
const atLeastHcClear =
|
||||
scoreData.lamp === "HARD CLEAR" ||
|
||||
scoreData.lamp === "EX HARD CLEAR" ||
|
||||
@@ -247,14 +231,12 @@ export const IIDX_DP_IMPL: GameImplementation<"iidx-dp"> = {
|
||||
const atLeastExhcClear =
|
||||
scoreData.lamp === "EX HARD CLEAR" || scoreData.lamp === "FULL COMBO";
|
||||
|
||||
const ktLampRatingNC = atLeastNcClear ? ecValue : 0;
|
||||
const ktLampRatingHC = atLeastHcClear ? ecValue : 0;
|
||||
const ktLampRatingEXHC = atLeastExhcClear ? ecValue : 0;
|
||||
|
||||
return {
|
||||
BPI: bpi,
|
||||
ktLampRating,
|
||||
ktLampRatingNC,
|
||||
ktLampRatingHC,
|
||||
ktLampRatingEXHC,
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ import { GoalFmtPercent, GradeGoalFormatter } from "./_common";
|
||||
export const ITG_STAMINA_IMPL: GameImplementation<"itg-stamina"> = {
|
||||
chartSpecificValidators: {},
|
||||
scoreDeriver: (scoreData, _chart) => ({
|
||||
// *important*: don't check survivedPercent === 100 — floating point can
|
||||
// *important*: don't check survivedPercent === 100 - floating point can
|
||||
// produce a 100% survived-percent on a fail for very long charts.
|
||||
finalPercent:
|
||||
scoreData.lamp === "FAILED" ? scoreData.survivedPercent : 100 + scoreData.scorePercent,
|
||||
|
||||
@@ -60,7 +60,6 @@ describe("CreatePBMergeFor (Postgres)", () => {
|
||||
judgements: s.mongo.judgements ?? {},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await DB.insertInto("score")
|
||||
.values({
|
||||
id: scoreId,
|
||||
|
||||
@@ -10,8 +10,12 @@ import { processScoreImportJobFromPayload } from "#lib/score-import/worker/score
|
||||
import { Env } from "#lib/setup/config";
|
||||
import { ClosePgConnection } from "#services/pg/db";
|
||||
import { CloseRedisConnection } from "#services/redis/redis";
|
||||
import { Sleep } from "#utils/misc";
|
||||
import { writeFileSync } from "fs";
|
||||
import { applyMigrations } from "tachi-db-migration-engine";
|
||||
|
||||
const HEARTBEAT_FILE = "/tmp/worker-heartbeat";
|
||||
|
||||
const POLL_MS = 250;
|
||||
|
||||
process.on("uncaughtException", (err, origin) => {
|
||||
@@ -21,31 +25,32 @@ process.on("uncaughtException", (err, origin) => {
|
||||
|
||||
void bootstrap();
|
||||
|
||||
/**
|
||||
* Often run by `just server` (one or more via `TACHI_SERVER_JOB_WORKER_COUNT`); each process claims
|
||||
* with `FOR UPDATE SKIP LOCKED`.
|
||||
*/
|
||||
async function bootstrap() {
|
||||
await applyMigrations(Env.POSTGRES_URL, Env.MIGRATIONS_DIR);
|
||||
log.info({ bootInfo: true }, "tachi job-queue worker starting (Postgres job_queue).");
|
||||
|
||||
let stopping = false;
|
||||
const shutdown = () => {
|
||||
stopping = true;
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
// eslint-disable-next-line no-unmodified-loop-condition
|
||||
while (!stopping) {
|
||||
writeFileSync(HEARTBEAT_FILE, Date.now().toString());
|
||||
|
||||
const job = await ClaimNextJob();
|
||||
|
||||
if (!job) {
|
||||
if (stopping) {
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, POLL_MS);
|
||||
});
|
||||
|
||||
await Sleep(POLL_MS);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (job.job_kind) {
|
||||
case JOB_KIND_SCORE_IMPORT:
|
||||
@@ -61,6 +66,7 @@ async function bootstrap() {
|
||||
await MarkJobFailed(job.row_id);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Job worker loop stopped, closing resources.");
|
||||
await CloseScoreImportQueue();
|
||||
await CloseRedisConnection();
|
||||
|
||||
@@ -38,7 +38,7 @@ export type ActivityConstraint = {
|
||||
userID?: integer | { $in: Array<integer> };
|
||||
};
|
||||
|
||||
/** Kysely dynamic column refs — same pattern as `whereUserIdOnGoalSub` in `#utils/db`. */
|
||||
/** Kysely dynamic column refs - same pattern as `whereUserIdOnGoalSub` in `#utils/db`. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- OperandExpressionFactory varies by query (session / join / class_achievement).
|
||||
type ActivityWhereEb = any;
|
||||
|
||||
|
||||
@@ -92,13 +92,13 @@ export const SELECT_PB_DOCUMENT_WITH_LEADERBOARD = [
|
||||
export async function ToPbScoreDocument(row: PbDocumentJoinRow): Promise<PBScoreDocument> {
|
||||
const composedRows = await DB.selectFrom("pb_composed_from")
|
||||
.where("pb_id", "=", row.row_id)
|
||||
.select("score_id")
|
||||
.select(["score_id", "merge_name"])
|
||||
.execute();
|
||||
|
||||
const composedFrom: [PBReference, ...PBReference[]] =
|
||||
composedRows.length > 0
|
||||
? (composedRows.map((c, i) => ({
|
||||
name: i === 0 ? "Primary" : `Merge${i}`,
|
||||
? (composedRows.map((c) => ({
|
||||
name: c.merge_name,
|
||||
scoreID: c.score_id,
|
||||
})) as [PBReference, ...PBReference[]])
|
||||
: [{ name: "Primary", scoreID: "unknown" }];
|
||||
|
||||
@@ -23,7 +23,7 @@ try {
|
||||
} else {
|
||||
transporter.verify((err) => {
|
||||
if (err) {
|
||||
// Do NOT throw here — this is an async callback and the throw would
|
||||
// Do NOT throw here - this is an async callback and the throw would
|
||||
// become an uncaught exception, crashing the process. Log and exit instead.
|
||||
log.fatal({ err }, `Could not connect to email server.`);
|
||||
process.exit(1);
|
||||
@@ -65,7 +65,7 @@ export function SendEmail(
|
||||
headers: transporter.options.headers,
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
log.info(
|
||||
log.error(
|
||||
{
|
||||
err,
|
||||
subject,
|
||||
@@ -74,5 +74,6 @@ export function SendEmail(
|
||||
},
|
||||
`Failed to send email to ${to}.`,
|
||||
);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type Kysely, sql } from "kysely";
|
||||
|
||||
/**
|
||||
* Folder membership SQL (see tests in `folders.test.ts`).
|
||||
* Does not import the global `DB` module — safe for scripts that only set `POSTGRES_URL`.
|
||||
* Does not import the global `DB` module - safe for scripts that only set `POSTGRES_URL`.
|
||||
*/
|
||||
export async function BuildFolderQuery(folderID: string, db: Kysely<Database>) {
|
||||
const folder = await db
|
||||
@@ -75,7 +75,7 @@ export async function GetFolderChartIDs(folderID: string, db: Kysely<Database>)
|
||||
|
||||
/**
|
||||
* Evaluates `folder.where` SQL (same as a full rebuild). Used by
|
||||
* {@link rebuildFolderChartLookup}; do not call for normal reads — use {@link GetFolderChartIDs}.
|
||||
* {@link rebuildFolderChartLookup}; do not call for normal reads - use {@link GetFolderChartIDs}.
|
||||
*/
|
||||
export async function computeFolderChartIdsFromFolderSql(folderID: string, db: Kysely<Database>) {
|
||||
const { folderQuery } = await BuildFolderQuery(folderID, db);
|
||||
|
||||
@@ -46,7 +46,7 @@ function buildList(): Array<CronTaskDef> {
|
||||
},
|
||||
{
|
||||
id: "drain_stats_queues",
|
||||
schedule: "* * * * *",
|
||||
schedule: "* * * * *", // TODO(zk): really? this is so lazy
|
||||
description:
|
||||
"Drain score_rederive, pb_dirty, session_dirty, game_profile_dirty (ordered)",
|
||||
run: drainStatsQueuesInOrder,
|
||||
|
||||
@@ -291,7 +291,7 @@ export async function drainStatsQueuesInOrder(): Promise<void> {
|
||||
/**
|
||||
* Drain `score_rederive`, then `pb_dirty`, `session_dirty`, and `game_profile_dirty`,
|
||||
* repeating until a full pass moves nothing. No per-tick row cap (unlike the cron
|
||||
* drain) — intended for admin synchronous recalc.
|
||||
* drain) - intended for admin synchronous recalc.
|
||||
*/
|
||||
export async function drainStatsQueuesFully(): Promise<void> {
|
||||
for (;;) {
|
||||
@@ -345,7 +345,7 @@ export async function drainStatsQueuesFully(): Promise<void> {
|
||||
|
||||
/**
|
||||
* Drain `pb_dirty` then `session_dirty` and `game_profile_dirty`, repeating until
|
||||
* idle. No per-tick row cap — intended for admin synchronous PB recalc.
|
||||
* idle. No per-tick row cap - intended for admin synchronous PB recalc.
|
||||
*/
|
||||
export async function drainPbDirtyAndDownstream(): Promise<void> {
|
||||
for (;;) {
|
||||
|
||||
@@ -192,7 +192,7 @@ export class TypedRouter<S extends AnyRouterSpec> {
|
||||
if (!outputParseResult.success) {
|
||||
log.error(
|
||||
{ route, issues: outputParseResult.error.issues },
|
||||
"TypedRouter: output validation failed — handler returned body not matching spec output schema",
|
||||
"TypedRouter: output validation failed - handler returned body not matching spec output schema",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ export async function deleteOrphanScoreForUser(
|
||||
|
||||
/**
|
||||
* Lists orphan_score rows for a user, newest first, with keyset pagination on `row_id` + `time_inserted`.
|
||||
* @param afterRowID — `row_id` of the last item from the previous page (omit on first page).
|
||||
* @param afterRowID - `row_id` of the last item from the previous page (omit on first page).
|
||||
*/
|
||||
export async function listOrphanScoresForUser(opts: {
|
||||
afterRowID?: string;
|
||||
|
||||
@@ -103,6 +103,7 @@ export async function upsertPbFromMongoDoc(
|
||||
pbDoc.composedFrom.map((ref) => ({
|
||||
pb_id: pbId,
|
||||
score_id: ref.scoreID,
|
||||
merge_name: ref.name,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
|
||||
@@ -99,6 +99,7 @@ export async function finalizeImportToPostgres(
|
||||
await db
|
||||
.insertInto("import_game")
|
||||
.values(games.map((game) => ({ id: importID, game })))
|
||||
.onConflict((oc) => oc.columns(["id", "game"]).doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -113,6 +114,7 @@ export async function finalizeImportToPostgres(
|
||||
message: err.message,
|
||||
})),
|
||||
)
|
||||
.onConflict((oc) => oc.column("row_id").doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -130,6 +132,7 @@ export async function finalizeImportToPostgres(
|
||||
new: d.new,
|
||||
})),
|
||||
)
|
||||
.onConflict((oc) => oc.column("row_id").doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -145,6 +148,7 @@ export async function finalizeImportToPostgres(
|
||||
type: s.type.toLowerCase() as "appended" | "created",
|
||||
})),
|
||||
)
|
||||
.onConflict((oc) => oc.columns(["import_id", "session_id"]).doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -169,6 +173,7 @@ export async function finalizeImportToPostgres(
|
||||
new_progress_human: g.new.progressHuman,
|
||||
})),
|
||||
)
|
||||
.onConflict((oc) => oc.column("row_id").doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -187,6 +192,7 @@ export async function finalizeImportToPostgres(
|
||||
new_progress: q.new.progress,
|
||||
})),
|
||||
)
|
||||
.onConflict((oc) => oc.column("row_id").doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -211,23 +217,6 @@ export async function finalizeImportToPostgres(
|
||||
quest_secs: timing.questMs,
|
||||
total_secs: timing.totalMs,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.column("id").doUpdateSet({
|
||||
timestamp: tsNow,
|
||||
import_secs_avg: timing.importMs / n,
|
||||
import_parse_secs_avg: timing.importParseMs / n,
|
||||
pb_secs_avg: timing.pbMs / n,
|
||||
session_secs_avg: timing.sessionMs / n,
|
||||
parse_secs: timing.parseMs,
|
||||
import_secs: timing.importMs,
|
||||
import_parse_secs: timing.importParseMs,
|
||||
session_secs: timing.sessionMs,
|
||||
pb_secs: timing.pbMs,
|
||||
ugs_secs: timing.ugsMs,
|
||||
goal_secs: timing.goalMs,
|
||||
quest_secs: timing.questMs,
|
||||
total_secs: timing.totalMs,
|
||||
}),
|
||||
)
|
||||
.onConflict((oc) => oc.column("id").doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -2,8 +2,14 @@ import { seedUser } from "#actions/test-utils/api-tokens";
|
||||
import { CDNRetrieve } from "#lib/cdn/cdn";
|
||||
import { LoadImportDocumentById } from "#lib/db-formats/import-document";
|
||||
import DB from "#services/pg/db";
|
||||
import { FakeSmallBatchManual, Testing511Song, Testing511SPA } from "#test-utils/test-data";
|
||||
import {
|
||||
FakeSmallBatchManual,
|
||||
Testing511Song,
|
||||
Testing511SPA,
|
||||
TestingJubeatSong,
|
||||
} from "#test-utils/test-data";
|
||||
import { Sleep } from "#utils/misc";
|
||||
import { UnixMillisecondsToISO8601 } from "#utils/time";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { MakeScoreImport } from "./score-import";
|
||||
@@ -110,3 +116,273 @@ describe("MakeScoreImport (ported from score-import.oldtest.ts)", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const JUBEAT_ARRAY_IG_FIRST = 80000037;
|
||||
|
||||
function mkBatchManualMulterFile(body: object): Express.Multer.File {
|
||||
return {
|
||||
buffer: Buffer.from(JSON.stringify(body), "utf-8"),
|
||||
} as Express.Multer.File;
|
||||
}
|
||||
|
||||
function jubeatScoreLine(opts: {
|
||||
identifier: number | string;
|
||||
musicRate?: number;
|
||||
score?: number;
|
||||
timeAchieved: number;
|
||||
}) {
|
||||
return {
|
||||
difficulty: "ADV" as const,
|
||||
identifier: String(opts.identifier),
|
||||
judgements: {
|
||||
good: 0,
|
||||
great: 0,
|
||||
miss: 0,
|
||||
perfect: 100,
|
||||
poor: 0,
|
||||
},
|
||||
lamp: "CLEAR" as const,
|
||||
matchType: "inGameID" as const,
|
||||
musicRate: opts.musicRate ?? 96.5,
|
||||
score: opts.score ?? 920_000,
|
||||
timeAchieved: opts.timeAchieved,
|
||||
};
|
||||
}
|
||||
|
||||
async function seedJubeatChartArrayInGameID(opts: { chartId: string; legacyId: number }) {
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: `${TestingJubeatSong.id}-smoke-${opts.chartId}`,
|
||||
legacy_id: opts.legacyId,
|
||||
game_group: "jubeat",
|
||||
title: TestingJubeatSong.title,
|
||||
artist: TestingJubeatSong.artist,
|
||||
search_terms: TestingJubeatSong.searchTerms,
|
||||
alt_titles: TestingJubeatSong.altTitles,
|
||||
data: TestingJubeatSong.data,
|
||||
fts_document: "",
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("chart")
|
||||
.values({
|
||||
id: opts.chartId,
|
||||
legacy_id: String(opts.legacyId),
|
||||
game: "jubeat",
|
||||
song_id: `${TestingJubeatSong.id}-smoke-${opts.chartId}`,
|
||||
difficulty: "ADV",
|
||||
level: "6",
|
||||
level_num: 6,
|
||||
is_primary: true,
|
||||
versions: ["festo"],
|
||||
data: JSON.stringify({
|
||||
inGameID: [JUBEAT_ARRAY_IG_FIRST, 50_000_020],
|
||||
noteCount: 100,
|
||||
musicBar: [0, 1, 2, 3],
|
||||
}),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function seedJubeatChartScalarInGameID(opts: {
|
||||
chartId: string;
|
||||
inGameID: number;
|
||||
legacyId: number;
|
||||
songIdSuffix: string;
|
||||
}) {
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: `${TestingJubeatSong.id}-${opts.songIdSuffix}`,
|
||||
legacy_id: opts.legacyId,
|
||||
game_group: "jubeat",
|
||||
title: TestingJubeatSong.title,
|
||||
artist: TestingJubeatSong.artist,
|
||||
search_terms: TestingJubeatSong.searchTerms,
|
||||
alt_titles: TestingJubeatSong.altTitles,
|
||||
data: TestingJubeatSong.data,
|
||||
fts_document: "",
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("chart")
|
||||
.values({
|
||||
id: opts.chartId,
|
||||
legacy_id: String(opts.legacyId),
|
||||
game: "jubeat",
|
||||
song_id: `${TestingJubeatSong.id}-${opts.songIdSuffix}`,
|
||||
difficulty: "ADV",
|
||||
level: "6",
|
||||
level_num: 6,
|
||||
is_primary: true,
|
||||
versions: ["festo"],
|
||||
data: JSON.stringify({
|
||||
inGameID: opts.inGameID,
|
||||
noteCount: 100,
|
||||
musicBar: [0, 1, 2, 3],
|
||||
}),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe("batch-manual score import (smoke)", () => {
|
||||
beforeEach(async () => {
|
||||
await seedUser({
|
||||
username: "test_batch_manual_smoke",
|
||||
email: "batch-manual-smoke@example.com",
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("file/batch-manual: jubeat inGameID matches chart with array inGameID and commits scores", async () => {
|
||||
const chartId = "chart-smoke-jubeat-array-ingameid";
|
||||
await seedJubeatChartArrayInGameID({ chartId, legacyId: 9_001 });
|
||||
|
||||
const baseMs = Date.UTC(2024, 5, 1, 12, 0, 0, 0);
|
||||
const batch = {
|
||||
meta: { game: "jubeat", playtype: "Single", service: "smoke-test" },
|
||||
scores: [jubeatScoreLine({ identifier: JUBEAT_ARRAY_IG_FIRST, timeAchieved: baseMs })],
|
||||
};
|
||||
|
||||
const importID = "import-smoke-jubeat-array";
|
||||
const doc = await MakeScoreImport({
|
||||
importID,
|
||||
importType: "file/batch-manual",
|
||||
parserArguments: [mkBatchManualMulterFile(batch), {}],
|
||||
userID: 1,
|
||||
userIntent: true,
|
||||
});
|
||||
|
||||
expect(doc.scoreIDs).toHaveLength(1);
|
||||
expect(doc.errors).toHaveLength(0);
|
||||
|
||||
const nCommitted = await DB.selectFrom("score")
|
||||
.select((eb) => eb.fn.countAll<number>().as("c"))
|
||||
.where("import_id", "=", importID)
|
||||
.where("committed", "=", true)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(Number(nCommitted?.c)).toBe(1);
|
||||
});
|
||||
|
||||
it("second import of identical data reports no sessions", async () => {
|
||||
await seedJubeatChartScalarInGameID({
|
||||
chartId: "chart-dedup-sessions",
|
||||
inGameID: 20_000_001,
|
||||
legacyId: 9_010,
|
||||
songIdSuffix: "dedup-sessions-song",
|
||||
});
|
||||
|
||||
const baseMs = Date.UTC(2024, 3, 1, 14, 0, 0, 0);
|
||||
const batch = {
|
||||
meta: { game: "jubeat", playtype: "Single", service: "dedup-test" },
|
||||
scores: [jubeatScoreLine({ identifier: 20_000_001, timeAchieved: baseMs })],
|
||||
};
|
||||
|
||||
const doc1 = await MakeScoreImport({
|
||||
importID: "import-dedup-sessions-1",
|
||||
importType: "file/batch-manual",
|
||||
parserArguments: [mkBatchManualMulterFile(batch), {}],
|
||||
userID: 1,
|
||||
userIntent: true,
|
||||
});
|
||||
|
||||
// Sanity check: first import should have created exactly one session
|
||||
expect(doc1.createdSessions).toHaveLength(1);
|
||||
expect(doc1.createdSessions[0]?.type).toBe("Created");
|
||||
|
||||
const doc2 = await MakeScoreImport({
|
||||
importID: "import-dedup-sessions-2",
|
||||
importType: "file/batch-manual",
|
||||
parserArguments: [mkBatchManualMulterFile(batch), {}],
|
||||
userID: 1,
|
||||
userIntent: true,
|
||||
});
|
||||
|
||||
// Second import of identical data should claim no sessions — no new scores
|
||||
// were actually committed, so no session was touched.
|
||||
expect(doc2.createdSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("file/batch-manual: two session groups appending the same nearby session finalizes import_session without error", async () => {
|
||||
await seedJubeatChartScalarInGameID({
|
||||
chartId: "chart-smoke-jubeat-dup-a",
|
||||
inGameID: 10_000_001,
|
||||
legacyId: 9_002,
|
||||
songIdSuffix: "smoke2-song-a",
|
||||
});
|
||||
await seedJubeatChartScalarInGameID({
|
||||
chartId: "chart-smoke-jubeat-dup-b",
|
||||
inGameID: 10_000_002,
|
||||
legacyId: 9_003,
|
||||
songIdSuffix: "smoke2-song-b",
|
||||
});
|
||||
|
||||
const baseMs = Date.UTC(2024, 5, 10, 8, 0, 0, 0);
|
||||
const sessionId = `Q${"a".repeat(40)}`;
|
||||
|
||||
const sixHoursMs = 6 * 60 * 60 * 1000;
|
||||
const oneHourMs = 60 * 60 * 1000;
|
||||
|
||||
await DB.insertInto("session")
|
||||
.values({
|
||||
id: sessionId,
|
||||
user_id: 1,
|
||||
game: "jubeat",
|
||||
name: "preseed-smoke",
|
||||
description: null,
|
||||
time_inserted: UnixMillisecondsToISO8601(Date.now()),
|
||||
time_started: UnixMillisecondsToISO8601(baseMs),
|
||||
time_ended: UnixMillisecondsToISO8601(baseMs + sixHoursMs),
|
||||
calculated_data: JSON.stringify({ jubility: null }),
|
||||
highlight: false,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const batch = {
|
||||
meta: { game: "jubeat", playtype: "Single", service: "smoke-test" },
|
||||
scores: [
|
||||
jubeatScoreLine({
|
||||
identifier: 10_000_001,
|
||||
musicRate: 95.0,
|
||||
score: 910_000,
|
||||
timeAchieved: baseMs + oneHourMs,
|
||||
}),
|
||||
jubeatScoreLine({
|
||||
identifier: 10_000_002,
|
||||
musicRate: 97.0,
|
||||
score: 925_000,
|
||||
timeAchieved: baseMs + 5 * oneHourMs,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
const importID = "import-smoke-jubeat-session-meta";
|
||||
const doc = await MakeScoreImport({
|
||||
importID,
|
||||
importType: "file/batch-manual",
|
||||
parserArguments: [mkBatchManualMulterFile(batch), {}],
|
||||
userID: 1,
|
||||
userIntent: true,
|
||||
});
|
||||
|
||||
expect(doc.scoreIDs).toHaveLength(2);
|
||||
expect(doc.errors).toHaveLength(0);
|
||||
|
||||
const nCommitted = await DB.selectFrom("score")
|
||||
.select((eb) => eb.fn.countAll<number>().as("c"))
|
||||
.where("import_id", "=", importID)
|
||||
.where("committed", "=", true)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(Number(nCommitted?.c)).toBe(2);
|
||||
|
||||
const importSessionRows = await DB.selectFrom("import_session")
|
||||
.select("session_id")
|
||||
.where("import_id", "=", importID)
|
||||
.execute();
|
||||
|
||||
expect(importSessionRows).toHaveLength(1);
|
||||
expect(importSessionRows[0]?.session_id).toBe(sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import ScoreImportFatalError from "./score-importing/score-import-error";
|
||||
* Makes a score import given ScoreImportJobData (same process as the API).
|
||||
*
|
||||
* When `USE_EXTERNAL_SCORE_IMPORT_WORKER` is true, HTTP routes call
|
||||
* {@link EnqueueScoreImportJob} instead — this function is only used for the
|
||||
* {@link EnqueueScoreImportJob} instead - this function is only used for the
|
||||
* inline (non-queued) path.
|
||||
*/
|
||||
export async function MakeScoreImport<I extends ImportTypes>(
|
||||
|
||||
@@ -155,7 +155,7 @@ describe("CreateScoreID", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("is deterministic (canary — changing the algorithm is a breaking change)", () => {
|
||||
it("is deterministic (canary - changing the algorithm is a breaking change)", () => {
|
||||
const scoreID = CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.chartID);
|
||||
|
||||
expect(scoreID).toBe("T5d669c4d5d6ca80761e87698acd77c51d2bed95b64ab76e65952dbca7c26bc81");
|
||||
|
||||
+1
-1
@@ -303,7 +303,7 @@ export async function HandlePostImportSteps(
|
||||
// best score).
|
||||
const pbTimeStart = process.hrtime.bigint();
|
||||
|
||||
// processing PBs is a playtype-specific action. As such, we need to split chartIDs
|
||||
// processing PBs is a game-specific action. As such, we need to split chartIDs
|
||||
// accordingly
|
||||
const chartIDsSeparatedByGame: ChartIDGameMap = {};
|
||||
|
||||
|
||||
@@ -407,7 +407,7 @@ async function HydrateCheckAndInsertScore(
|
||||
}
|
||||
} else {
|
||||
if (importId === null && !directCommit) {
|
||||
log.debug(`Skipped score — missing import id.`);
|
||||
log.debug(`Skipped score - missing import id.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ const TWO_HOURS = ONE_HOUR * 2;
|
||||
export async function CreateSessions(userID: integer, scoreGameMap: ScoreGameMap, log: KtLogger) {
|
||||
const allSessionInfo = [];
|
||||
|
||||
console.dir(scoreGameMap);
|
||||
|
||||
for (const [game, scores] of Object.entries(scoreGameMap)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const sessionInfo = await LoadScoresIntoSessions(userID, scores, game as V3Game, log);
|
||||
@@ -36,6 +38,8 @@ export async function CreateSessions(userID: integer, scoreGameMap: ScoreGameMap
|
||||
allSessionInfo.push(...sessionInfo);
|
||||
}
|
||||
|
||||
console.dir(allSessionInfo);
|
||||
|
||||
return allSessionInfo;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,7 +126,6 @@ describe("UpdateUsersGamePlaytypeStats (ported from update-ugpt-stats.oldtest.ts
|
||||
const ratings = typeof gp.ratings === "string" ? JSON.parse(gp.ratings) : gp.ratings;
|
||||
expect(ratings).toMatchObject({
|
||||
ktLampRating: expect.any(Number),
|
||||
ktLampRatingNC: expect.any(Number),
|
||||
ktLampRatingHC: expect.any(Number),
|
||||
ktLampRatingEXHC: expect.any(Number),
|
||||
});
|
||||
@@ -175,7 +174,6 @@ describe("UpdateUsersGamePlaytypeStats (ported from update-ugpt-stats.oldtest.ts
|
||||
calculatedData: {
|
||||
BPI: 10.1,
|
||||
ktLampRating: e,
|
||||
ktLampRatingNC: e,
|
||||
ktLampRatingHC: e,
|
||||
ktLampRatingEXHC: e,
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ async function* streamPlaylog(apiId: string, log: KtLogger): AsyncIterable<MytWa
|
||||
try {
|
||||
for await (const item of client.getPlaylog(request)) {
|
||||
if (!item.info) {
|
||||
log.warn(`Received WACCA playlog stream item with no info — skipping.`);
|
||||
log.warn(`Received WACCA playlog stream item with no info - skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -96,7 +96,7 @@ describe("#ParserFn", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("No Playtype — game group without playtype is not a V3 game", () => {
|
||||
it("No Playtype - game group without playtype is not a V3 game", () => {
|
||||
expectThrowsFatal(
|
||||
() =>
|
||||
ParserFn(
|
||||
|
||||
@@ -47,7 +47,7 @@ async function finalizeHits(
|
||||
|
||||
/**
|
||||
* Search a user's sessions for one GPT: `websearch_to_tsquery` on `session.textsearch`, optional
|
||||
* short-query exact match, then pg_trgm / ILIKE — same strategy as
|
||||
* short-query exact match, then pg_trgm / ILIKE - same strategy as
|
||||
* {@link SearchSongsForGameFtsAndTrgm} / {@link SearchFoldersForGameFtsAndTrgm}.
|
||||
*
|
||||
* Returns full {@link SessionDocument}s (no follow-up `WHERE id IN (...)` on `session`).
|
||||
|
||||
@@ -31,23 +31,23 @@ describe("Postgres song search (full seeds)", () => {
|
||||
await importSeeds(DB, SEEDS_DIR);
|
||||
}, 600_000);
|
||||
|
||||
bench("iidx FTS — gradius (title)", async () => {
|
||||
bench("iidx FTS - gradius (title)", async () => {
|
||||
await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "gradius", 50);
|
||||
});
|
||||
|
||||
bench("iidx FTS — taka (artist)", async () => {
|
||||
bench("iidx FTS - taka (artist)", async () => {
|
||||
await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "taka", 50);
|
||||
});
|
||||
|
||||
bench("iidx short query — ab (FTS + trgm)", async () => {
|
||||
bench("iidx short query - ab (FTS + trgm)", async () => {
|
||||
await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "ab", 50);
|
||||
});
|
||||
|
||||
bench("iidx sparse — xyzunlikely (mostly trgm / empty FTS)", async () => {
|
||||
bench("iidx sparse - xyzunlikely (mostly trgm / empty FTS)", async () => {
|
||||
await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "xyzunlikely", 50);
|
||||
});
|
||||
|
||||
bench("bms FTS — fezike (artist)", async () => {
|
||||
bench("bms FTS - fezike (artist)", async () => {
|
||||
await SearchSongsForGameFtsAndTrgm(GAME_BMS, "fezike", 50);
|
||||
});
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export type SongSearchRow = {
|
||||
|
||||
/**
|
||||
* Indexed song search: PostgreSQL FTS (tsvector) plus optional pg_trgm / ILIKE fallback
|
||||
* (Zenith-style — no full-table load, no huge IN lists).
|
||||
* (Zenith-style - no full-table load, no huge IN lists).
|
||||
*
|
||||
* Queries with length ≤ {@link SHORT_QUERY_STRICT_MAX_LEN} also run an **exact** match pass
|
||||
* (title, artist, `search_terms`, `alt_titles`) with a boosted rank; substring
|
||||
|
||||
@@ -7,10 +7,12 @@ describe("buildEmailConfig", () => {
|
||||
expect(() => buildEmailConfig({})).toThrow(/TACHI_EMAIL_FROM is required/u);
|
||||
});
|
||||
|
||||
it("builds Postmark transport when TACHI_EMAIL_AUTH_POSTMARK is true", () => {
|
||||
it("builds Postmark transport when host is smtp.postmarkapp.com", () => {
|
||||
const cfg = buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "from@example.com",
|
||||
TACHI_EMAIL_AUTH_POSTMARK: "true",
|
||||
TACHI_EMAIL_HOST: "smtp.postmarkapp.com",
|
||||
TACHI_EMAIL_PORT: "587",
|
||||
TACHI_EMAIL_SECURE: "false",
|
||||
TACHI_EMAIL_AUTH_PASS: "pm-token",
|
||||
});
|
||||
expect(cfg.TRANSPORT_OPS).toMatchObject({
|
||||
@@ -21,10 +23,11 @@ describe("buildEmailConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses TACHI_EMAIL_AUTH_USER when POSTMARK and only user is set", () => {
|
||||
it("uses TACHI_EMAIL_AUTH_USER for Postmark when only user is set", () => {
|
||||
const cfg = buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "from@example.com",
|
||||
TACHI_EMAIL_AUTH_POSTMARK: "1",
|
||||
TACHI_EMAIL_HOST: "smtp.postmarkapp.com",
|
||||
TACHI_EMAIL_PORT: "587",
|
||||
TACHI_EMAIL_AUTH_USER: "only-user-token",
|
||||
});
|
||||
expect(cfg.TRANSPORT_OPS).toMatchObject({
|
||||
@@ -32,19 +35,32 @@ describe("buildEmailConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when POSTMARK is true but no token is given", () => {
|
||||
it("matches Postmark host case-insensitively for auth token rules", () => {
|
||||
const cfg = buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "from@example.com",
|
||||
TACHI_EMAIL_HOST: "SMTP.POSTMARKAPP.COM",
|
||||
TACHI_EMAIL_PORT: "587",
|
||||
TACHI_EMAIL_AUTH_PASS: "pm-token",
|
||||
});
|
||||
expect(cfg.TRANSPORT_OPS).toMatchObject({
|
||||
host: "SMTP.POSTMARKAPP.COM",
|
||||
auth: { user: "pm-token", pass: "pm-token" },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when Postmark host is set but no token is given", () => {
|
||||
expect(() =>
|
||||
buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "a@b.com",
|
||||
TACHI_EMAIL_AUTH_POSTMARK: "true",
|
||||
TACHI_EMAIL_HOST: "smtp.postmarkapp.com",
|
||||
TACHI_EMAIL_PORT: "587",
|
||||
}),
|
||||
).toThrow(/TACHI_EMAIL_AUTH_PASS or TACHI_EMAIL_AUTH_USER/u);
|
||||
});
|
||||
|
||||
it("builds generic SMTP without auth when POSTMARK is false", () => {
|
||||
it("builds generic SMTP without auth", () => {
|
||||
const cfg = buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "dev@localhost",
|
||||
TACHI_EMAIL_AUTH_POSTMARK: "false",
|
||||
TACHI_EMAIL_HOST: "tachi-mailpit",
|
||||
TACHI_EMAIL_PORT: "1025",
|
||||
TACHI_EMAIL_SECURE: "false",
|
||||
@@ -59,7 +75,6 @@ describe("buildEmailConfig", () => {
|
||||
it("includes auth when user and/or pass are set for generic SMTP", () => {
|
||||
const cfg = buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "a@b.com",
|
||||
TACHI_EMAIL_AUTH_POSTMARK: "false",
|
||||
TACHI_EMAIL_HOST: "smtp.example",
|
||||
TACHI_EMAIL_PORT: "587",
|
||||
TACHI_EMAIL_SECURE: "false",
|
||||
@@ -77,7 +92,6 @@ describe("buildEmailConfig", () => {
|
||||
it("defaults TACHI_EMAIL_SECURE to false", () => {
|
||||
const cfg = buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "a@b.com",
|
||||
TACHI_EMAIL_AUTH_POSTMARK: "false",
|
||||
TACHI_EMAIL_HOST: "h",
|
||||
TACHI_EMAIL_PORT: "25",
|
||||
});
|
||||
@@ -88,7 +102,6 @@ describe("buildEmailConfig", () => {
|
||||
expect(() =>
|
||||
buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "a@b.com",
|
||||
TACHI_EMAIL_AUTH_POSTMARK: "false",
|
||||
TACHI_EMAIL_PORT: "1025",
|
||||
}),
|
||||
).toThrow(/TACHI_EMAIL_HOST is required/u);
|
||||
@@ -98,7 +111,6 @@ describe("buildEmailConfig", () => {
|
||||
expect(() =>
|
||||
buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "a@b.com",
|
||||
TACHI_EMAIL_AUTH_POSTMARK: "false",
|
||||
TACHI_EMAIL_HOST: "localhost",
|
||||
}),
|
||||
).toThrow(/TACHI_EMAIL_PORT is required/u);
|
||||
@@ -108,7 +120,6 @@ describe("buildEmailConfig", () => {
|
||||
expect(() =>
|
||||
buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "a@b.com",
|
||||
TACHI_EMAIL_AUTH_POSTMARK: "false",
|
||||
TACHI_EMAIL_HOST: "localhost",
|
||||
TACHI_EMAIL_PORT: "nope",
|
||||
}),
|
||||
|
||||
@@ -82,6 +82,7 @@ const configSchema = z.object({
|
||||
BETA_USER_BONUS: z.number().int().nonnegative(),
|
||||
})
|
||||
.optional(),
|
||||
INVITE_ADMIN_INITIAL_INVITE_CODE: z.string().optional(),
|
||||
TACHI_CONFIG: z.object({
|
||||
NAME: z.string(),
|
||||
TYPE: z.enum(["kamai", "boku", "omni"]),
|
||||
@@ -263,14 +264,17 @@ function envBoolFrom(env: NodeJS.ProcessEnv, key: string, defaultVal: boolean):
|
||||
return defaultVal;
|
||||
}
|
||||
|
||||
const POSTMARK_SMTP_HOST = "smtp.postmarkapp.com";
|
||||
|
||||
/**
|
||||
* SMTP settings from env. Required for every deployment.
|
||||
*
|
||||
* - `TACHI_EMAIL_FROM` — `From` header (must match Postmark sender when using Postmark).
|
||||
* - `TACHI_EMAIL_AUTH_POSTMARK` — if `true`, uses Postmark SMTP; set `TACHI_EMAIL_AUTH_USER` /
|
||||
* `TACHI_EMAIL_AUTH_PASS` to your server token (both are the token for Postmark).
|
||||
* - Otherwise: `TACHI_EMAIL_HOST`, `TACHI_EMAIL_PORT`, `TACHI_EMAIL_SECURE`, and optionally
|
||||
* `TACHI_EMAIL_AUTH_USER` / `TACHI_EMAIL_AUTH_PASS` (e.g. Mailpit: no auth).
|
||||
* - `TACHI_EMAIL_FROM` - `From` header (must match a verified sender when using Postmark).
|
||||
* - `TACHI_EMAIL_HOST`, `TACHI_EMAIL_PORT`, `TACHI_EMAIL_SECURE` (`true` / `false`).
|
||||
* - Optionally `TACHI_EMAIL_AUTH_USER` / `TACHI_EMAIL_AUTH_PASS` when the server needs SMTP auth
|
||||
* (Mailpit locally typically needs none).
|
||||
* - For Postmark, set host to `smtp.postmarkapp.com` (commonly port `587`, `TACHI_EMAIL_SECURE=false`).
|
||||
* Either auth field may hold the server token; the other is filled with the same value.
|
||||
*
|
||||
* @internal Exported for unit tests.
|
||||
*/
|
||||
@@ -280,39 +284,14 @@ export function buildEmailConfig(env: NodeJS.ProcessEnv): TachiServerConfig["EMA
|
||||
throw new Error(`TACHI_EMAIL_FROM is required.`);
|
||||
}
|
||||
|
||||
if (envBoolFrom(env, "TACHI_EMAIL_AUTH_POSTMARK", false)) {
|
||||
const pass = envOptFrom(env, "TACHI_EMAIL_AUTH_PASS");
|
||||
const user = envOptFrom(env, "TACHI_EMAIL_AUTH_USER");
|
||||
const token = pass ?? user;
|
||||
if (token === undefined) {
|
||||
throw new Error(
|
||||
`TACHI_EMAIL_AUTH_PASS or TACHI_EMAIL_AUTH_USER is required when TACHI_EMAIL_AUTH_POSTMARK is true.`,
|
||||
);
|
||||
}
|
||||
const authUser = user ?? token;
|
||||
const authPass = pass ?? token;
|
||||
return {
|
||||
FROM: from,
|
||||
TRANSPORT_OPS: {
|
||||
host: "smtp.postmarkapp.com",
|
||||
port: 587,
|
||||
secure: false,
|
||||
auth: {
|
||||
user: authUser,
|
||||
pass: authPass,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const host = envOptFrom(env, "TACHI_EMAIL_HOST");
|
||||
if (host === undefined) {
|
||||
throw new Error(`TACHI_EMAIL_HOST is required when TACHI_EMAIL_AUTH_POSTMARK is false.`);
|
||||
throw new Error(`TACHI_EMAIL_HOST is required.`);
|
||||
}
|
||||
|
||||
const portRaw = envOptFrom(env, "TACHI_EMAIL_PORT");
|
||||
if (portRaw === undefined) {
|
||||
throw new Error(`TACHI_EMAIL_PORT is required when TACHI_EMAIL_AUTH_POSTMARK is false.`);
|
||||
throw new Error(`TACHI_EMAIL_PORT is required.`);
|
||||
}
|
||||
const port = Number.parseInt(portRaw, 10);
|
||||
if (Number.isNaN(port)) {
|
||||
@@ -320,8 +299,19 @@ export function buildEmailConfig(env: NodeJS.ProcessEnv): TachiServerConfig["EMA
|
||||
}
|
||||
|
||||
const secure = envBoolFrom(env, "TACHI_EMAIL_SECURE", false);
|
||||
const authUser = envOptFrom(env, "TACHI_EMAIL_AUTH_USER");
|
||||
const authPass = envOptFrom(env, "TACHI_EMAIL_AUTH_PASS");
|
||||
let authUser = envOptFrom(env, "TACHI_EMAIL_AUTH_USER");
|
||||
let authPass = envOptFrom(env, "TACHI_EMAIL_AUTH_PASS");
|
||||
|
||||
if (host.toLowerCase() === POSTMARK_SMTP_HOST) {
|
||||
const token = authPass ?? authUser;
|
||||
if (token === undefined) {
|
||||
throw new Error(
|
||||
`TACHI_EMAIL_AUTH_PASS or TACHI_EMAIL_AUTH_USER is required when TACHI_EMAIL_HOST is ${POSTMARK_SMTP_HOST}.`,
|
||||
);
|
||||
}
|
||||
authUser = authUser ?? token;
|
||||
authPass = authPass ?? token;
|
||||
}
|
||||
|
||||
const transportOps: Record<string, unknown> = {
|
||||
host,
|
||||
@@ -393,6 +383,7 @@ const eagOauth = oauth2Optional("EAG");
|
||||
const minOauth = oauth2Optional("MIN");
|
||||
const emailCfg = emailConfig();
|
||||
const inviteCfg = inviteCodeConfig();
|
||||
const bootstrapInvite = opt("TACHI_INVITE_ADMIN_INITIAL_INVITE_CODE")?.trim() || undefined;
|
||||
const seedsCfg = seedsConfig();
|
||||
const clientDev = clientDevServer();
|
||||
const extWorkerConc = opt("TACHI_EXTERNAL_SCORE_IMPORT_WORKER_CONCURRENCY");
|
||||
@@ -437,6 +428,7 @@ const configFromEnv: unknown = {
|
||||
MAX_RIVALS: parseIntEnv("TACHI_MAX_RIVALS", 5),
|
||||
OUR_URL: req("TACHI_OUR_URL"),
|
||||
...(inviteCfg !== undefined ? { INVITE_CODE_CONFIG: inviteCfg } : {}),
|
||||
...(bootstrapInvite !== undefined ? { INVITE_ADMIN_INITIAL_INVITE_CODE: bootstrapInvite } : {}),
|
||||
TACHI_CONFIG: {
|
||||
NAME: req("TACHI_NAME"),
|
||||
TYPE: req("TACHI_TYPE"),
|
||||
@@ -526,14 +518,14 @@ if (!MIGRATIONS_DIR) {
|
||||
let version = process.env.VERSION;
|
||||
|
||||
if (!version) {
|
||||
log.error(`No VERSION specified in environment. defaulting to 0.0.0.`);
|
||||
log.warn(`No VERSION specified in environment. defaulting to 0.0.0.`);
|
||||
version = "0.0.0";
|
||||
}
|
||||
|
||||
let commitHash = process.env.COMMIT_HASH;
|
||||
|
||||
if (!commitHash) {
|
||||
log.error(`No COMMIT_HASH specified in environment. defaulting to unknown commit.`);
|
||||
log.warn(`No COMMIT_HASH specified in environment. defaulting to unknown commit.`);
|
||||
commitHash = "unknown";
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Stress **`POST /api/v1/import/file`** against a **running** Tachi instance using
|
||||
|
||||
- Server reachable (e.g. `http://127.0.0.1:8080` when using local `PORT`).
|
||||
- Accounts with **`submit_score`**:
|
||||
- **Session:** login cookie (see below) — **one in-flight import per user**, so keep **`--concurrency 1`** unless you only care about rate limits.
|
||||
- **Session:** login cookie (see below) - **one in-flight import per user**, so keep **`--concurrency 1`** unless you only care about rate limits.
|
||||
- **API tokens:** one token per parallel slot. Generate many tokens with the seeder (below).
|
||||
|
||||
**Rate limiting:** In dev, score imports are limited to **5 per minute per IP** unless you set:
|
||||
@@ -96,5 +96,5 @@ just load-test-score-import -- \
|
||||
|
||||
## Files
|
||||
|
||||
- `score-import-load-cli.ts` — multipart client.
|
||||
- `seed-stress-api-tokens.ts` — bulk token seeder for dev DBs.
|
||||
- `score-import-load-cli.ts` - multipart client.
|
||||
- `seed-stress-api-tokens.ts` - bulk token seeder for dev DBs.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* HTTP stress harness: multipart POST /api/v1/import/file against a live Tachi instance.
|
||||
*
|
||||
* Uses real fixtures under src/test-utils/test-data/ (CSV, XML, JSON) — not MER.
|
||||
* Uses real fixtures under src/test-utils/test-data/ (CSV, XML, JSON) - not MER.
|
||||
*
|
||||
* One in-flight import per user (409 if you exceed). Use a token pool sized ≥ concurrency.
|
||||
*
|
||||
@@ -155,7 +155,7 @@ function maybeMutateFileBody(
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
// XML / others: suffix a harmless byte that most XML parsers ignore after root — skip
|
||||
// XML / others: suffix a harmless byte that most XML parsers ignore after root - skip
|
||||
return buf;
|
||||
}
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ function tsReq(ms: number): string {
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// API permission helpers
|
||||
// (Old MongoDB data uses dash-separated permission names, not the underscore
|
||||
// form in the current APIPermissions type — kept as plain string lookups.)
|
||||
// form in the current APIPermissions type - kept as plain string lookups.)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Extract one Postgres pm_* column from a MongoDB requestedPermissions array. */
|
||||
@@ -371,7 +371,7 @@ async function main(): Promise<void> {
|
||||
const chartIdMap = buildChartIdMap(SEEDS_DIR);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// LEVEL 0 — No FK dependencies
|
||||
// LEVEL 0 - No FK dependencies
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n── Level 0 ──────────────────────────────────────────────────────");
|
||||
@@ -489,7 +489,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// LEVEL 1 — Depend on account and/or level-0 tables
|
||||
// LEVEL 1 - Depend on account and/or level-0 tables
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n── Level 1 ──────────────────────────────────────────────────────");
|
||||
@@ -894,7 +894,7 @@ async function main(): Promise<void> {
|
||||
game: mongoGameToPg(a.game, (a as { playtype?: string }).playtype),
|
||||
user_id: a.userID,
|
||||
class_set: a.classSet as string,
|
||||
// classOldValue can be null in Mongo; Postgres requires a string — use empty string.
|
||||
// classOldValue can be null in Mongo; Postgres requires a string - use empty string.
|
||||
class_prev_value: a.classOldValue ?? "",
|
||||
class_value: a.classValue,
|
||||
timestamp: tsReq(a.timeAchieved),
|
||||
@@ -1049,18 +1049,18 @@ async function main(): Promise<void> {
|
||||
|
||||
if (skipped > 0) {
|
||||
console.warn(
|
||||
` [import_session] Skipped ${skipped} row(s) — session not found`,
|
||||
` [import_session] Skipped ${skipped} row(s) - session not found`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Base rows first — children have FK references to import(id).
|
||||
// Base rows first - children have FK references to import(id).
|
||||
await batchInsert("import", importRows);
|
||||
await batchInsert("import_game", importGameRows);
|
||||
await batchInsert("import_error", importErrorRows);
|
||||
await batchInsert("import_class", importClassRows);
|
||||
await batchInsert("import_session", importSessionRows);
|
||||
// import_goal and import_quest are intentionally skipped — the
|
||||
// import_goal and import_quest are intentionally skipped - the
|
||||
// historical data doesn't align with reality.
|
||||
},
|
||||
"import + children",
|
||||
@@ -1088,7 +1088,7 @@ async function main(): Promise<void> {
|
||||
|
||||
for (const gs of goalSubs) {
|
||||
if (!existingGoalIds.has(gs.goalID)) {
|
||||
console.warn(` [goal_sub] Skipping — goal ${gs.goalID} not found in DB`);
|
||||
console.warn(` [goal_sub] Skipping - goal ${gs.goalID} not found in DB`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1170,7 +1170,7 @@ async function main(): Promise<void> {
|
||||
const skipped = folderViews.length - viewRows.length;
|
||||
|
||||
if (skipped > 0) {
|
||||
console.warn(` [folder_view] Skipping ${skipped} row(s) — folder not found in DB`);
|
||||
console.warn(` [folder_view] Skipping ${skipped} row(s) - folder not found in DB`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < viewRows.length; i = i + INSERT_CHUNK) {
|
||||
@@ -1207,7 +1207,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// LEVEL 2 — Depend on level 1 tables
|
||||
// LEVEL 2 - Depend on level 1 tables
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n── Level 2 ──────────────────────────────────────────────────────");
|
||||
@@ -1244,7 +1244,7 @@ async function main(): Promise<void> {
|
||||
console.warn(
|
||||
` ${
|
||||
apiTokens.length - validTokens.length
|
||||
} API tokens skipped — null token/userID or deleted oauth2 client`,
|
||||
} API tokens skipped - null token/userID or deleted oauth2 client`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1332,7 +1332,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
if (skipped > 0) {
|
||||
console.warn(` [import_timing] Skipped ${skipped} row(s) — import not found`);
|
||||
console.warn(` [import_timing] Skipped ${skipped} row(s) - import not found`);
|
||||
}
|
||||
|
||||
await batchInsert("import_timing", rows);
|
||||
@@ -1341,7 +1341,7 @@ async function main(): Promise<void> {
|
||||
);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// LEVEL 3 — Large collections: scores and PBs (cursor-streamed)
|
||||
// LEVEL 3 - Large collections: scores and PBs (cursor-streamed)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n── Level 3 (streaming) ──────────────────────────────────────────");
|
||||
@@ -1397,7 +1397,7 @@ async function main(): Promise<void> {
|
||||
|
||||
if (chartSid === undefined) {
|
||||
throw new Error(
|
||||
` [score] Skipping score ${s.scoreID} — no sid for chartID ${s.chartID}`,
|
||||
` [score] Skipping score ${s.scoreID} - no sid for chartID ${s.chartID}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ import { collectDefaultMetrics, Counter, Histogram, Registry } from "prom-client
|
||||
/** Dedicated listener for `GET /metrics` (see `main.ts`). */
|
||||
export const METRICS_PORT = 9779;
|
||||
|
||||
/** Seconds — aligns with typical Prometheus HTTP latency buckets, with extra resolution under 100ms. */
|
||||
/** Seconds - aligns with typical Prometheus HTTP latency buckets, with extra resolution under 100ms. */
|
||||
const HTTP_DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
|
||||
|
||||
/** Seconds — score imports can run from sub-second to tens of minutes. */
|
||||
/** Seconds - score imports can run from sub-second to tens of minutes. */
|
||||
const SCORE_IMPORT_DURATION_BUCKETS = [0.25, 0.5, 1, 2, 5, 10, 30, 60, 120, 300, 600, 1800];
|
||||
|
||||
let scoreImportDurationSeconds: Histogram | null = null;
|
||||
|
||||
@@ -13,7 +13,7 @@ export const GetClientFromID: RequestHandler = async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Strip the client secret — this middleware is used for public lookups.
|
||||
// Strip the client secret - this middleware is used for public lookups.
|
||||
const { clientSecret: _secret, ...publicClient } = client;
|
||||
|
||||
REQ_AssignToReqTachiData(req, { apiClientDoc: publicClient });
|
||||
|
||||
@@ -244,7 +244,7 @@ describe("GET /api/v1/clients/:clientID", () => {
|
||||
// ─── PATCH /api/v1/clients/:clientID ─────────────────────────────────────────
|
||||
|
||||
describe("PATCH /api/v1/clients/:clientID", () => {
|
||||
/** Prudence `?string` fields — omit vs null is not optional; send null when not updating. */
|
||||
/** Prudence `?string` fields - omit vs null is not optional; send null when not updating. */
|
||||
const nullTemplateAndUris = {
|
||||
apiKeyTemplate: null,
|
||||
redirectUri: null,
|
||||
|
||||
+1
-1
@@ -91,5 +91,5 @@ describe("POST /api/v1/games/:game/charts/resolve", () => {
|
||||
});
|
||||
|
||||
describe("GET /api/v1/games/:game/charts (popular & search)", () => {
|
||||
it.todo("requires large IIDX chart/PB corpus — port from router.oldtest.ts");
|
||||
it.todo("requires large IIDX chart/PB corpus - port from router.oldtest.ts");
|
||||
});
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ describe("GET /api/v1/games/:game", () => {
|
||||
});
|
||||
|
||||
it.todo(
|
||||
"GET /api/v1/games/:v3Game stats (chart/player/score): blocked — Express matches GET /games/:gameGroup before GET /games/:game, so iidx-sp is rejected as a game group",
|
||||
"GET /api/v1/games/:v3Game stats (chart/player/score): blocked - Express matches GET /games/:gameGroup before GET /games/:game, so iidx-sp is rejected as a game group",
|
||||
);
|
||||
|
||||
it("returns 400 for an unsupported game slug", async () => {
|
||||
|
||||
+1
-1
@@ -141,7 +141,7 @@ describe("GET /api/v1/games/iidx-sp/songs/:songID", () => {
|
||||
});
|
||||
|
||||
it("only returns charts for the requested playtype", async () => {
|
||||
// Insert a DP chart for the same song — should not appear in the SP response.
|
||||
// Insert a DP chart for the same song - should not appear in the SP response.
|
||||
await DB.insertInto("chart")
|
||||
.values({
|
||||
id: "C_TEST_IIDX_DP_CHART",
|
||||
|
||||
@@ -167,6 +167,66 @@ describe("GET /api/v1/sessions/:sessionID/folder-raises", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/v1/sessions/:sessionID/adjacent", () => {
|
||||
async function seedThreeSessions() {
|
||||
const { id: userId } = await seedUser({ username: "adjacent_user" });
|
||||
|
||||
const sessionIds = ["adj_session_oldest", "adj_session_middle", "adj_session_newest"];
|
||||
|
||||
for (const [i, id] of sessionIds.entries()) {
|
||||
const t = new Date(2024, 0, 1 + i).toISOString();
|
||||
|
||||
await DB.insertInto("session")
|
||||
.values({
|
||||
id,
|
||||
user_id: userId,
|
||||
game: "iidx-sp",
|
||||
name: `Session ${i}`,
|
||||
description: null,
|
||||
time_inserted: t,
|
||||
time_started: t,
|
||||
time_ended: t,
|
||||
calculated_data: JSON.stringify({}),
|
||||
highlight: false,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
return { userId, sessionIds };
|
||||
}
|
||||
|
||||
it("returns both neighbors for the middle session", async () => {
|
||||
const { sessionIds } = await seedThreeSessions();
|
||||
|
||||
const res = await mockApi.get(`/api/v1/sessions/${sessionIds[1]}/adjacent`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.body.prev?.sessionID).toBe(sessionIds[0]);
|
||||
expect(res.body.body.next?.sessionID).toBe(sessionIds[2]);
|
||||
});
|
||||
|
||||
it("returns null next for the newest session", async () => {
|
||||
const { sessionIds } = await seedThreeSessions();
|
||||
|
||||
const res = await mockApi.get(`/api/v1/sessions/${sessionIds[2]}/adjacent`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.body.next).toBeNull();
|
||||
expect(res.body.body.prev?.sessionID).toBe(sessionIds[1]);
|
||||
});
|
||||
|
||||
it("returns null prev for the oldest session", async () => {
|
||||
const { sessionIds } = await seedThreeSessions();
|
||||
|
||||
const res = await mockApi.get(`/api/v1/sessions/${sessionIds[0]}/adjacent`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.body.prev).toBeNull();
|
||||
expect(res.body.body.next?.sessionID).toBe(sessionIds[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/v1/sessions/:sessionID", () => {
|
||||
it("updates the session name when authorised", async () => {
|
||||
const { sessionId, userId } = await seedSessionFixture();
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { GetSessionData } from "#utils/queries/sessions";
|
||||
import { GetAdjacentSessions, GetSessionData } from "#utils/queries/sessions";
|
||||
import { GetUserWithID } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
@@ -44,6 +44,18 @@ API_V1_ROUTER.add("GET /sessions/:sessionID/folder-raises", withSession, async (
|
||||
return success("Retrieved folder raises.", raises);
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the chronologically adjacent sessions (prev = older, next = newer)
|
||||
* for the same user and game.
|
||||
*
|
||||
* @name GET /api/v1/sessions/:sessionID/adjacent
|
||||
*/
|
||||
API_V1_ROUTER.add("GET /sessions/:sessionID/adjacent", withSession, async ({ ctx }) => {
|
||||
const { prev, next } = await GetAdjacentSessions(ctx.sessionDoc);
|
||||
|
||||
return success("Retrieved adjacent sessions.", { next, prev });
|
||||
});
|
||||
|
||||
/**
|
||||
* Modifies a session.
|
||||
*
|
||||
|
||||
@@ -61,7 +61,7 @@ type AdminJobQueueFilters = { job_kind?: string; scope?: string; status?: number
|
||||
type AdminActionFilters = { kind?: string; username?: string };
|
||||
|
||||
// ─── Output schema helpers ────────────────────────────────────────────────────
|
||||
// Opaque MongoDB document — validates the value is a record but does not
|
||||
// Opaque MongoDB document - validates the value is a record but does not
|
||||
// constrain individual fields. Pass `T` so `z.infer` and handler output types
|
||||
// match the API document type (runtime validation stays shallow).
|
||||
function doc<T = Record<string, unknown>>(): z.ZodType<T> {
|
||||
@@ -72,7 +72,7 @@ function docArray<T = Record<string, unknown>>(): z.ZodType<T[]> {
|
||||
return z.array(z.record(z.string(), z.unknown())) as z.ZodType<T[]>;
|
||||
}
|
||||
|
||||
// Empty success body — mutation endpoints that carry no return payload.
|
||||
// Empty success body - mutation endpoints that carry no return payload.
|
||||
const empty = z.object({});
|
||||
|
||||
export const API_V1_SPEC = {
|
||||
@@ -624,7 +624,7 @@ export const API_V1_SPEC = {
|
||||
// Users /:userID/games/:game (UGPT)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
// User games — literal gameGroup segments (game-specific)
|
||||
// User games - literal gameGroup segments (game-specific)
|
||||
"GET /users/:userID/games/:game/custom-tables/:tableUrlName": {
|
||||
description:
|
||||
"HTML stub for a user-specific custom BMS table; things like their rivals, etc.",
|
||||
@@ -1259,7 +1259,7 @@ export const API_V1_SPEC = {
|
||||
}),
|
||||
},
|
||||
|
||||
// Games — BMS / IIDX literal paths (game-specific)
|
||||
// Games - BMS / IIDX literal paths (game-specific)
|
||||
"GET /games/:game/custom-tables": {
|
||||
description: "List custom BMS tables available.",
|
||||
input: z.object({}),
|
||||
@@ -1622,6 +1622,15 @@ export const API_V1_SPEC = {
|
||||
output: empty,
|
||||
},
|
||||
|
||||
"GET /sessions/:sessionID/adjacent": {
|
||||
description: "Retrieve the chronologically adjacent sessions (prev/next) for this session.",
|
||||
input: z.object({}),
|
||||
output: z.strictObject({
|
||||
prev: doc<SessionDocument>().nullable(),
|
||||
next: doc<SessionDocument>().nullable(),
|
||||
}),
|
||||
},
|
||||
|
||||
"GET /sessions/:sessionID/folder-raises": {
|
||||
description: "Folder raise summary for a session.",
|
||||
input: z.object({}),
|
||||
|
||||
+2
-2
@@ -154,7 +154,7 @@ describe("GET /api/v1/users/:userID/games/:game/folders/:folderID/timeline", ()
|
||||
|
||||
const baseSd = TestingIIDXSPScore.scoreData;
|
||||
|
||||
// Chart A: CLEAR lamp, null time — sorts first in final array (null → 0).
|
||||
// Chart A: CLEAR lamp, null time - sorts first in final array (null → 0).
|
||||
await insertIidxSpScore({
|
||||
id: "tl_s_a",
|
||||
userId,
|
||||
@@ -178,7 +178,7 @@ describe("GET /api/v1/users/:userID/games/:game/folders/:folderID/timeline", ()
|
||||
scoreData: baseSd,
|
||||
timeAchievedMs: 100,
|
||||
});
|
||||
// EASY CLEAR (index 3) — does not satisfy CLEAR floor (index 4).
|
||||
// EASY CLEAR (index 3) - does not satisfy CLEAR floor (index 4).
|
||||
await insertIidxSpScore({
|
||||
id: "tl_s_b_easy",
|
||||
userId,
|
||||
|
||||
+1
-1
@@ -114,7 +114,7 @@ async function seedIidxChartPb(opts: { userId: number; withComposition?: boolean
|
||||
|
||||
if (opts.withComposition) {
|
||||
await DB.insertInto("pb_composed_from")
|
||||
.values({ pb_id: pbIns.row_id, score_id: scoreId })
|
||||
.values({ pb_id: pbIns.row_id, score_id: scoreId, name: "Default" })
|
||||
.execute();
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -146,7 +146,7 @@ describe("GET /api/v1/users/:userID/games/:game/scores/:chartID", () => {
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.body).toEqual([]);
|
||||
|
||||
// Other user may have scores on the same chart — still empty for target.
|
||||
// Other user may have scores on the same chart - still empty for target.
|
||||
await DB.insertInto("score")
|
||||
.values({
|
||||
id: `sc-ugpt-empty-other-${seedCounter}`,
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ describe("PATCH /api/v1/users/:userID/games/:game/settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH UGPT settings — extended cases", () => {
|
||||
describe("PATCH UGPT settings - extended cases", () => {
|
||||
it.todo(
|
||||
"port remaining router.oldtest.ts cases (BPI bounds, defaultTable validation, cross-user 403, …)",
|
||||
);
|
||||
|
||||
@@ -92,7 +92,7 @@ describe("GET /api/v1/users?search=", () => {
|
||||
|
||||
it("treats regex special characters as literals", async () => {
|
||||
// '.*' matches everything as a regex, but is treated as a literal ILIKE
|
||||
// pattern — no usernames contain that substring, so results are empty.
|
||||
// pattern - no usernames contain that substring, so results are empty.
|
||||
const res = await mockApi.get("/api/v1/users?search=.*");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -433,6 +433,6 @@ describe("POST /ir/fervidex/score/submit (Postgres)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip("POST /ir/fervidex/profile/submit — deferred KT bulk seed to Postgres", () => {
|
||||
describe.skip("POST /ir/fervidex/profile/submit - deferred KT bulk seed to Postgres", () => {
|
||||
it.skip("imports fervidex-static profile", () => {});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,16 @@ pg.types.setTypeParser(pg.types.builtins.INT8, (val) => Number(val));
|
||||
|
||||
const pool = new Pool({ connectionString: Env.POSTGRES_URL });
|
||||
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
// Swallow 57P01 (admin_shutdown) errors that arrive on idle pool connections
|
||||
// during test teardown. Prevents some flakiness in CI.
|
||||
pool.on("error", (err: { code?: string } & Error) => {
|
||||
if (err.code !== "57P01") {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const DB = new Kysely<Database>({
|
||||
dialect: new PostgresDialect({ pool }),
|
||||
});
|
||||
|
||||
@@ -594,7 +594,7 @@ export async function importSeeds(pg: Kysely<Database>, seedsDir: string): Promi
|
||||
};
|
||||
});
|
||||
|
||||
// Goals are never updated once created — only new ones are inserted.
|
||||
// Goals are never updated once created - only new ones are inserted.
|
||||
await batchIgnorePg(pg, "goal", goalRows);
|
||||
console.log(` ${goals.length} goals\n`);
|
||||
}
|
||||
|
||||
@@ -61,8 +61,8 @@ interface SeedUserOpts {
|
||||
* Insert an `account` row (and optionally `priv_account_credential` /
|
||||
* `account_settings`) and return the resulting data.
|
||||
*
|
||||
* - `withCredential: true` — also inserts `priv_account_credential`
|
||||
* - `withSettings: true` — also inserts `account_settings`
|
||||
* - `withCredential: true` - also inserts `priv_account_credential`
|
||||
* - `withSettings: true` - also inserts `account_settings`
|
||||
*/
|
||||
export async function seedUser(opts?: SeedUserOpts) {
|
||||
const username = opts?.username ?? "test_user";
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "path";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Default `db/seeds` (collections JSON) relative to the server package — same as `load-seeds-pg.ts`. */
|
||||
/** Default `db/seeds` (collections JSON) relative to the server package - same as `load-seeds-pg.ts`. */
|
||||
export const DEFAULT_SEEDS_DIR = path.resolve(__dirname, "../../../../db/seeds");
|
||||
|
||||
export function resolveSeedsDir(): string {
|
||||
|
||||
@@ -72,7 +72,6 @@ export const TestingIIDXSPScorePB: PBScoreDocument<"iidx-sp"> = {
|
||||
calculatedData: {
|
||||
BPI: 10.1,
|
||||
ktLampRating: 0,
|
||||
ktLampRatingNC: 0,
|
||||
ktLampRatingHC: 0,
|
||||
ktLampRatingEXHC: 0,
|
||||
},
|
||||
@@ -202,7 +201,6 @@ export const TestingIIDXSPScore: ScoreDocument<"iidx-sp"> = {
|
||||
calculatedData: {
|
||||
BPI: null,
|
||||
ktLampRating: 10,
|
||||
ktLampRatingNC: 10,
|
||||
ktLampRatingHC: 0,
|
||||
ktLampRatingEXHC: 0,
|
||||
},
|
||||
|
||||
@@ -147,7 +147,7 @@ function whereUserIdOnQuestSub(userID: unknown) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Next numeric `song.legacy_id` for BMS/PMS — `max(existing) + 1`, or `1` if none.
|
||||
* Next numeric `song.legacy_id` for BMS/PMS - `max(existing) + 1`, or `1` if none.
|
||||
* Replaces Mongo `counters` `*-song-id` documents.
|
||||
*
|
||||
* This shit sucks and should be dropped asap: TODO(zk)
|
||||
|
||||
@@ -56,8 +56,8 @@ describe("IsValidURL", () => {
|
||||
expect(IsValidURL("ftp://example.com")).toBeFalsy();
|
||||
});
|
||||
|
||||
// expect(IsValidURL("http://example")).toBe(false) — lol this is valid???? insane.
|
||||
// expect(IsValidURL("http:/example.com")).toBe(false) — this is also valid, the JS URL parser is ridiculously lenient. Whatever.
|
||||
// expect(IsValidURL("http://example")).toBe(false) - lol this is valid???? insane.
|
||||
// expect(IsValidURL("http:/example.com")).toBe(false) - this is also valid, the JS URL parser is ridiculously lenient. Whatever.
|
||||
});
|
||||
|
||||
describe("RoundToNDecimalPlaces", () => {
|
||||
|
||||
@@ -221,7 +221,7 @@ export async function FindITGChartsByHashGSv3(hash: string): Promise<Array<Chart
|
||||
}
|
||||
|
||||
/**
|
||||
* Beatoraja IR: chart by `data.hashSHA256` — BMS first, then PMS (SHA256 only; not MD5).
|
||||
* Beatoraja IR: chart by `data.hashSHA256` - BMS first, then PMS (SHA256 only; not MD5).
|
||||
*/
|
||||
export async function FindBeatorajaChartOnHashSHA256(
|
||||
hash: string,
|
||||
@@ -283,6 +283,18 @@ export async function SongHasAnyChart(game: GameGroup, songID: string): Promise<
|
||||
return row !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* In Game IDs are sometimes arrays of inGameIDs. I don't personally like this - makes the sql very
|
||||
* complex, but whatever.
|
||||
*/
|
||||
function sqlChartDataInGameIDEquals(inGameID: number) {
|
||||
return sql<boolean>`(
|
||||
(jsonb_typeof(chart.data::jsonb->'inGameID') = 'number' AND (chart.data::jsonb->>'inGameID')::int = ${inGameID})
|
||||
OR
|
||||
(jsonb_typeof(chart.data::jsonb->'inGameID') = 'array' AND (chart.data::jsonb->'inGameID') @> to_jsonb(${inGameID}::int))
|
||||
)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a chart on its in-game-ID, playtype and difficulty.
|
||||
*/
|
||||
@@ -295,7 +307,7 @@ export async function FindChartOnInGameID(
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.where("chart.game", "=", game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
|
||||
.where(sqlChartDataInGameIDEquals(inGameID))
|
||||
.where("chart.difficulty", "=", difficulty as string)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -318,7 +330,7 @@ export async function FindChartOnInGameIDPrimary(
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.where("chart.game", "=", game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
|
||||
.where(sqlChartDataInGameIDEquals(inGameID))
|
||||
.where("chart.difficulty", "=", difficulty as string)
|
||||
.where("chart.is_primary", "=", true)
|
||||
.executeTakeFirst();
|
||||
@@ -342,7 +354,7 @@ export async function FindIIDXChartOnInGameID(
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.where("chart.game", "=", "iidx-sp")
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
|
||||
.where(sqlChartDataInGameIDEquals(inGameID))
|
||||
.where(sql<SqlBool>`(chart.data->>'2dxtraSet') IS NULL`)
|
||||
.where("chart.is_primary", "=", true)
|
||||
.where("chart.difficulty", "=", difficulty as string)
|
||||
@@ -369,7 +381,7 @@ export async function FindIIDXChartOnInGameIDVersion(
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.where("chart.game", "=", game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
|
||||
.where(sqlChartDataInGameIDEquals(inGameID))
|
||||
.where(sql<SqlBool>`(chart.data->>'2dxtraSet') IS NULL`)
|
||||
.where("chart.difficulty", "=", difficulty as string)
|
||||
.where(sql<boolean>`${sql.lit(String(version))} = ANY(chart.versions)`)
|
||||
@@ -395,7 +407,7 @@ export async function FindChartOnInGameIDVersion<TGame extends V3Game = V3Game>(
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.where("chart.game", "=", game)
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
|
||||
.where(sqlChartDataInGameIDEquals(inGameID))
|
||||
.where("chart.difficulty", "=", difficulty as string)
|
||||
.where(sql<boolean>`${sql.lit(String(version))} = ANY(chart.versions)`)
|
||||
.executeTakeFirst();
|
||||
@@ -490,7 +502,7 @@ export async function FindSDVXChartOnInGameID(
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.where("chart.game", "=", "sdvx")
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
|
||||
.where(sqlChartDataInGameIDEquals(inGameID))
|
||||
.where("chart.is_primary", "=", true);
|
||||
|
||||
q =
|
||||
@@ -516,7 +528,7 @@ export async function FindSDVXChartOnInGameIDVersion(
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.select(SELECT_CHART)
|
||||
.where("chart.game", "=", "sdvx")
|
||||
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
|
||||
.where(sqlChartDataInGameIDEquals(inGameID))
|
||||
.where(sql<boolean>`${sql.lit(String(version))} = ANY(chart.versions)`);
|
||||
|
||||
q =
|
||||
@@ -640,7 +652,7 @@ export async function FindUSCChartsByHashSHA1(hash: string): Promise<Array<Chart
|
||||
* Returns the N most popular charts for this game + playtype.
|
||||
* Popularity is determined by how many rows exist in Postgres `score` for each chart.
|
||||
*
|
||||
* @param _scoreCollection — ignored; kept for API compatibility with the old Mongo implementation.
|
||||
* @param _scoreCollection - ignored; kept for API compatibility with the old Mongo implementation.
|
||||
*/
|
||||
export async function FindChartsOnPopularity(
|
||||
game: V3Game,
|
||||
|
||||
@@ -42,7 +42,6 @@ describe("GetAdjacentAbove / GetAdjacentBelow (Postgres)", () => {
|
||||
.execute();
|
||||
|
||||
for (const r of rows) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await DB.insertInto("pb")
|
||||
.values({
|
||||
calculated_data: JSON.stringify({}),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { SELECT_CHART, ToChartDocument } from "#lib/db-formats/chart";
|
||||
import { SELECT_SCORE_DOCUMENT, ToScoreDocument } from "#lib/db-formats/score";
|
||||
import { SELECT_SESSION_DOCUMENT, ToSessionDocument } from "#lib/db-formats/session";
|
||||
import { SELECT_SONG_DOCUMENT, ToSongDocument } from "#lib/db-formats/song";
|
||||
import { GetSessionScoreInfo } from "#lib/score-import/framework/sessions/sessions";
|
||||
import DB from "#services/pg/db";
|
||||
import { UnixMillisecondsToISO8601 } from "#utils/time";
|
||||
import { GetUserWithIDGuaranteed } from "#utils/user";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
@@ -43,6 +45,58 @@ export async function GetScoreIdsGroupedBySessionId(
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the chronologically adjacent sessions (prev = older, next = newer)
|
||||
* for the same user and game, using (time_ended, id) as a deterministic sort key.
|
||||
*/
|
||||
export async function GetAdjacentSessions(
|
||||
session: SessionDocument,
|
||||
): Promise<{ next: SessionDocument | null; prev: SessionDocument | null }> {
|
||||
const timeEnded = UnixMillisecondsToISO8601(session.timeEnded);
|
||||
|
||||
const [newerRow, olderRow] = await Promise.all([
|
||||
DB.selectFrom("session")
|
||||
.select(SELECT_SESSION_DOCUMENT)
|
||||
.where("session.user_id", "=", session.userID)
|
||||
.where("session.game", "=", session.game)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb("session.time_ended", ">", timeEnded),
|
||||
eb.and([
|
||||
eb("session.time_ended", "=", timeEnded),
|
||||
eb("session.id", ">", session.sessionID),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
.orderBy("session.time_ended", "asc")
|
||||
.orderBy("session.id", "asc")
|
||||
.limit(1)
|
||||
.executeTakeFirst(),
|
||||
DB.selectFrom("session")
|
||||
.select(SELECT_SESSION_DOCUMENT)
|
||||
.where("session.user_id", "=", session.userID)
|
||||
.where("session.game", "=", session.game)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb("session.time_ended", "<", timeEnded),
|
||||
eb.and([
|
||||
eb("session.time_ended", "=", timeEnded),
|
||||
eb("session.id", "<", session.sessionID),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
.orderBy("session.time_ended", "desc")
|
||||
.orderBy("session.id", "desc")
|
||||
.limit(1)
|
||||
.executeTakeFirst(),
|
||||
]);
|
||||
|
||||
return {
|
||||
next: newerRow ? ToSessionDocument(newerRow, []) : null,
|
||||
prev: olderRow ? ToSessionDocument(olderRow, []) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GetSessionData(session: SessionDocument): Promise<{
|
||||
charts: Array<ChartDocument>;
|
||||
scoreInfo: Array<SessionScoreInfo>;
|
||||
|
||||
@@ -86,7 +86,7 @@ describe("GetUsersRankingAndOutOf", () => {
|
||||
expect(result).toEqual({ ranking: 3, outOf: 5 });
|
||||
});
|
||||
|
||||
it("handles tied ratings — only strictly greater counts", async () => {
|
||||
it("handles tied ratings - only strictly greater counts", async () => {
|
||||
const user1 = await seedUser({ username: "tied_a" });
|
||||
const user2 = await seedUser({ username: "tied_b" });
|
||||
const user3 = await seedUser({ username: "tied_c" });
|
||||
|
||||
@@ -41,7 +41,7 @@ export default defineConfig({
|
||||
COMMIT_HASH: "test",
|
||||
},
|
||||
|
||||
// Parallel test execution — each worker gets its own isolated Postgres database.
|
||||
// Parallel test execution - each worker gets its own isolated Postgres database.
|
||||
fileParallelism: true,
|
||||
globalSetup: "./vitest.globalSetup.ts",
|
||||
setupFiles: "./vitest.setup.ts",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { execSync } from "node:child_process";
|
||||
import { ensureTestCdnBucket } from "./src/test-utils/ensure-test-cdn-bucket";
|
||||
|
||||
/**
|
||||
* Global vitest setup — runs ONCE before any workers start.
|
||||
* Global vitest setup - runs ONCE before any workers start.
|
||||
*
|
||||
* Creates a fully-migrated template database. Workers clone from it
|
||||
* instead of running migrations themselves, which is much faster.
|
||||
|
||||
@@ -18,7 +18,7 @@ const POSTGRES_HOST = "tachi-postgres";
|
||||
const POSTGRES_USER = "tachi";
|
||||
const POSTGRES_PASS = "tachi";
|
||||
|
||||
// Set POSTGRES_URL before any app code is imported — config.ts reads it at load time.
|
||||
// Set POSTGRES_URL before any app code is imported - config.ts reads it at load time.
|
||||
process.env.POSTGRES_URL = `postgresql://${POSTGRES_USER}:${POSTGRES_PASS}@${POSTGRES_HOST}/${WORKER_DB_NAME}`;
|
||||
|
||||
// Now that env vars are set, we can safely import external packages.
|
||||
@@ -92,7 +92,7 @@ async function resetDatabase() {
|
||||
const { clearGameStatsCacheForTests } = await import("#server/router/api/v1/games/router");
|
||||
clearGameStatsCacheForTests();
|
||||
} catch {
|
||||
// ignore — router not loaded in edge test contexts
|
||||
// ignore - router not loaded in edge test contexts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user