Step 11-13

This commit is contained in:
Kalle 2026-07-28 15:15:45 +03:00
parent 2282e10adb
commit 84b1f735ae
18 changed files with 414 additions and 470 deletions

View File

@ -0,0 +1,40 @@
import { type RawBuilder, sql } from "kysely";
import { db } from "~/db/sql";
import type { Tables } from "~/db/tables";
import { dateToDatabaseTimestamp } from "~/utils/dates";
/** Tables `backdate` can address: those with an `id` for it to key the update on. */
type BackdatableTable = {
[T in keyof Tables]: "id" extends keyof Tables[T] ? T : never;
}[keyof Tables];
/** The only columns `backdate` may touch. */
type TimestampColumn<T extends BackdatableTable> = Extract<
keyof Tables[T],
`${string}At`
>;
/**
* Moves a row's timestamps into the past. Every production write stamps *now*, so
* a seed that needs rows looking old an expired vote, a season's worth of matches
* has no way to ask for one.
*/
export async function backdate<T extends BackdatableTable>(
table: T,
id: number,
timestamps: Partial<Record<TimestampColumn<T>, Date>>,
) {
const assignments: RawBuilder<unknown>[] = [];
for (const [column, date] of Object.entries(timestamps)) {
assignments.push(
sql`${sql.ref(column)} = ${dateToDatabaseTimestamp(date as Date)}`,
);
}
if (assignments.length === 0) return;
await sql`update ${sql.table(table)} set ${sql.join(assignments)} where "id" = ${id}`.execute(
db,
);
}

View File

@ -0,0 +1,13 @@
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import { defineFactory } from "../core/defineFactory";
import { faker } from "../core/faker";
export const { createMany } = defineFactory({
defaults: ({ seq }) => ({
code: `badge-${seq}`,
displayName: faker.lorem.words(2),
hue: null,
authorId: null,
}),
insert: BadgeRepository.insert,
});

View File

@ -1,6 +1,11 @@
import * as ImageRepository from "~/features/img-upload/ImageRepository.server";
import { defineFactory } from "../core/defineFactory";
type Options = {
/** Has an admin approved the image, making it show wherever it is used? */
isValidated: boolean;
};
/**
* Creates user submitted images, unvalidated as the repository function makes them.
* An image on its own is an orphan the queries counting images for approval only
@ -14,4 +19,9 @@ export const { create } = defineFactory({
validatedAt: null,
}),
insert: ImageRepository.insert,
applyOptions: async (image, { isValidated }: Options) => {
if (!isValidated) return;
await ImageRepository.validateById(image.id);
},
});

View File

@ -0,0 +1,23 @@
import type { Tables } from "~/db/tables";
import * as LiveStreamRepository from "~/features/live-streams/LiveStreamRepository.server";
import invariant from "~/utils/invariant";
import { defineFactory } from "../core/defineFactory";
type InsertArgs = Omit<Tables["TournamentStreamer"], "id">;
export const { create } = defineFactory({
defaults: ({ seq }) => ({
twitchAccount: `streamer_${seq}`,
userId: null,
}),
insert: async (args: InsertArgs) => {
const [row] = await LiveStreamRepository.insertTournamentStreamers([args]);
invariant(
row,
`${args.twitchAccount} is already a streamer of tournament ${args.tournamentId}`,
);
return row;
},
});

View File

@ -0,0 +1,36 @@
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
import { defineFactory } from "../core/defineFactory";
import { faker } from "../core/faker";
import * as SplatoonFaker from "../core/SplatoonFaker";
const PLACED_ON = { month: 1, year: 2024 };
/**
* Creates X Rank placements. `playerSplId` is the in-game id of the player who
* placed the repository creates their `SplatoonPlayer` row if the id is new, so
* repeating an id places the same player again. `playerUserId` is the site user
* whose results they are.
*
* Rank counts up, since a month's leaderboard has no two players at the same rank.
*/
export const { create } = defineFactory({
defaults: ({ seq }) => ({
badges: "",
bannerSplId: 1,
mode: "SZ" as const,
month: PLACED_ON.month,
year: PLACED_ON.year,
name: faker.internet.username(),
nameDiscriminator: String(seq).padStart(4, "0"),
power: faker.number.int({ min: 2000, max: 3300 }),
rank: seq,
region: "WEST" as const,
title: faker.lorem.words(2),
weaponSplId: SplatoonFaker.mainWeapon(),
}),
insert: async (args: XRankPlacementRepository.XRankPlacementInsertArgs) => {
const [id] = await XRankPlacementRepository.insertMany([args]);
return { id };
},
});

View File

@ -1,6 +1,8 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import * as BadgeFactory from "~/db/seed/factories/BadgeFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { db } from "~/db/sql";
import * as XRankPlacementFactory from "~/db/seed/factories/XRankPlacementFactory";
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
import { dbReset } from "~/utils/Test";
import * as BadgeRepository from "./BadgeRepository.server";
import { SPLATOON_3_XP_BADGE_VALUES } from "./badges-constants";
@ -10,7 +12,10 @@ describe("syncXPBadges", () => {
beforeEach(async () => {
user = await UserFactory.create();
await insertXPBadges();
await BadgeFactory.createMany(SPLATOON_3_XP_BADGE_VALUES.length, (i) => ({
code: String(SPLATOON_3_XP_BADGE_VALUES[i]),
displayName: `${SPLATOON_3_XP_BADGE_VALUES[i]}+ XP`,
}));
});
afterEach(async () => {
@ -18,11 +23,7 @@ describe("syncXPBadges", () => {
});
test("assigns badge to user with qualifying peakXp", async () => {
await insertSplatoonPlayer({
splId: "abc123",
userId: user.id,
peakXp: 3000,
});
await givePeakXp(user.id, 3000);
await BadgeRepository.syncXPBadges();
@ -32,11 +33,7 @@ describe("syncXPBadges", () => {
});
test("assigns highest qualifying badge when peakXp exceeds threshold", async () => {
await insertSplatoonPlayer({
splId: "abc123",
userId: user.id,
peakXp: 3250,
});
await givePeakXp(user.id, 3250);
await BadgeRepository.syncXPBadges();
@ -48,11 +45,7 @@ describe("syncXPBadges", () => {
});
test("does not assign badge when peakXp is below minimum threshold", async () => {
await insertSplatoonPlayer({
splId: "abc123",
userId: user.id,
peakXp: 2500,
});
await givePeakXp(user.id, 2500);
await BadgeRepository.syncXPBadges();
@ -61,40 +54,15 @@ describe("syncXPBadges", () => {
});
});
async function insertXPBadges() {
await db
.insertInto("Badge")
.values(
SPLATOON_3_XP_BADGE_VALUES.map((value) => ({
code: String(value),
displayName: `${value}+ XP`,
hue: null,
authorId: null,
})),
)
.execute();
}
/** Gives the user a linked X Rank player whose one placement is worth `power`. */
async function givePeakXp(userId: number, power: number) {
await XRankPlacementFactory.create({
playerSplId: `player-${userId}`,
playerUserId: userId,
power,
});
async function insertSplatoonPlayer(args: {
splId: string;
userId: number | null;
peakXp: number | null;
}) {
await db
.insertInto("SplatoonPlayer")
.values({
splId: args.splId,
userId: args.userId,
peakXp:
args.peakXp === null
? null
: JSON.stringify({
overall: args.peakXp,
tentatek: args.peakXp,
takoroka: null,
}),
})
.execute();
await XRankPlacementRepository.refreshAllPeakXp();
}
async function findBadgeByCode(code: string) {

View File

@ -1,7 +1,7 @@
import type { ExpressionBuilder, NotNull } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { DB } from "~/db/tables";
import type { DB, TablesInsertable } from "~/db/tables";
import { sortBadgesByFavorites } from "~/features/user-page/core/badge-sorting.server";
import invariant from "~/utils/invariant";
import { commonUserSelect, peakXpOverallSql } from "~/utils/kysely.server";
@ -55,6 +55,20 @@ const withOwners = (eb: ExpressionBuilder<DB, "Badge">, badgeId: number) => {
).as("owners");
};
/** Adds a badge. `authorId` is who made it, `null` for a legacy badge. */
export function insert(
args: Pick<
TablesInsertable["Badge"],
"code" | "displayName" | "hue" | "authorId"
>,
) {
return db
.insertInto("Badge")
.values(args)
.returning("id")
.executeTakeFirstOrThrow();
}
export async function findAll() {
const rows = await db
.selectFrom("Badge")

View File

@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import * as BuildFactory from "~/db/seed/factories/BuildFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as XRankPlacementFactory from "~/db/seed/factories/XRankPlacementFactory";
import { db } from "~/db/sql";
import type {
BuildAbilitiesTuple,
@ -57,39 +58,14 @@ const createBuild = (
...overrides,
});
const insertSplatoonPlayer = async (userId: number, splId: string) => {
const { id } = await db
.insertInto("SplatoonPlayer")
.values({ splId, userId })
.returning("id")
.executeTakeFirstOrThrow();
return id;
};
const insertXRankPlacement = async (
playerId: number,
weaponSplId: MainWeaponId,
rank: number,
) => {
await db
.insertInto("XRankPlacement")
.values({
playerId,
weaponSplId,
badges: "[]",
bannerSplId: 1,
mode: "SZ",
month: 1,
year: 2024,
name: "Test Player",
nameDiscriminator: "0000",
power: 2500,
rank,
region: "WEST",
title: "Test",
})
.execute();
};
/** Puts the user in the top 500 with the given weapon, which builds sort by. */
const makeTop500 = (userId: number, weaponSplId: MainWeaponId) =>
XRankPlacementFactory.create({
playerSplId: `player-${userId}`,
playerUserId: userId,
weaponSplId,
rank: 1,
});
const buildById = (id: number) =>
db
@ -244,8 +220,7 @@ describe("BuildRepository.insert — computeBuildData", () => {
});
test("subtracts 1 when the weapon is top500 for the owner", async () => {
const playerId = await insertSplatoonPlayer(owner.id, "owner-spl-id");
await insertXRankPlacement(playerId, SPLATTERSHOT, 1);
await makeTop500(owner.id, SPLATTERSHOT);
const { id } = await BuildRepository.insert(
baseArgs({ weaponSplIds: [SPLATTERSHOT, SPLATTERSHOT_NOUVEAU] }),
@ -263,8 +238,7 @@ describe("BuildRepository.insert — computeBuildData", () => {
test("combines top500 with the owner's PlusTier", async () => {
const plusOwner = await UserFactory.create(null, { plusTier: 1 });
const playerId = await insertSplatoonPlayer(plusOwner.id, "owner-spl-id");
await insertXRankPlacement(playerId, SPLATTERSHOT, 1);
await makeTop500(plusOwner.id, SPLATTERSHOT);
const { id } = await BuildRepository.insert(
baseArgs({ ownerId: plusOwner.id }),
@ -276,8 +250,7 @@ describe("BuildRepository.insert — computeBuildData", () => {
});
test("findAllByWeaponId.weapons[].isTop500 matches the sortValue formula", async () => {
const playerId = await insertSplatoonPlayer(owner.id, "owner-spl-id");
await insertXRankPlacement(playerId, SPLATTERSHOT, 1);
await makeTop500(owner.id, SPLATTERSHOT);
await createBuild({
ownerId: owner.id,

View File

@ -15,10 +15,15 @@ export function replaceAll(
});
}
/**
* Adds the given accounts as streamers of their tournament. Returns the ids of the
* rows actually inserted, in insertion order an account already streaming that
* tournament is skipped and so has no id among them.
*/
export function insertTournamentStreamers(
rows: Omit<Tables["TournamentStreamer"], "id">[],
) {
if (rows.length === 0) return;
if (rows.length === 0) return Promise.resolve([]);
return db
.insertInto("TournamentStreamer")
@ -26,6 +31,7 @@ export function insertTournamentStreamers(
.onConflict((oc) =>
oc.columns(["twitchAccount", "tournamentId"]).doNothing(),
)
.returning("id")
.execute();
}

View File

@ -1,63 +1,28 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as XRankPlacementFactory from "~/db/seed/factories/XRankPlacementFactory";
import { db } from "~/db/sql";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { dbReset } from "~/utils/Test";
import * as XRankPlacementRepository from "./XRankPlacementRepository.server";
let placementCounter = 0;
const SPLATTERSHOT: MainWeaponId = 40;
const SPLATTERSHOT_NOUVEAU: MainWeaponId = 41;
const createUser = async (discordId: string) => {
const result = await db
.insertInto("User")
.values({ discordId, discordName: discordId })
.returning("id")
const PLACED_ON = { month: 1, year: 2024 };
const peakXpOf = (playerSplId: string) =>
db
.selectFrom("SplatoonPlayer")
.select("peakXp")
.where("splId", "=", playerSplId)
.executeTakeFirstOrThrow();
return result.id;
};
const createSplatoonPlayer = async (splId: string, userId?: number | null) => {
const result = await db
.insertInto("SplatoonPlayer")
.values({ splId, userId: userId ?? null })
.returning("id")
.executeTakeFirstOrThrow();
return result.id;
};
const createXRankPlacement = async (args: {
playerId: number;
power: number;
region?: "WEST" | "JPN";
rank?: number;
weaponSplId?: MainWeaponId;
}) => {
placementCounter++;
await db
.insertInto("XRankPlacement")
.values({
playerId: args.playerId,
power: args.power,
badges: "[]",
bannerSplId: 1,
mode: "SZ",
month: 1,
year: 2024,
name: "Test Player",
nameDiscriminator: "0000",
rank: args.rank ?? placementCounter,
region: args.region ?? "WEST",
title: "Test",
weaponSplId: args.weaponSplId ?? (0 as MainWeaponId),
})
.execute();
};
const findTenStarWeapons = () =>
db.selectFrom("TenStarWeapon").selectAll().execute();
describe("refreshAllPeakXp", () => {
beforeEach(async () => {
placementCounter = 0;
await dbReset();
});
@ -66,30 +31,22 @@ describe("refreshAllPeakXp", () => {
});
test("sets peakXp to max power for each player", async () => {
const player1Id = await createSplatoonPlayer("player1");
const player2Id = await createSplatoonPlayer("player2");
for (const power of [2500, 2700, 2600]) {
await XRankPlacementFactory.create({ playerSplId: "player-1", power });
}
await createXRankPlacement({ playerId: player1Id, power: 2500 });
await createXRankPlacement({ playerId: player1Id, power: 2700 });
await createXRankPlacement({ playerId: player1Id, power: 2600 });
await createXRankPlacement({ playerId: player2Id, power: 3000 });
await createXRankPlacement({ playerId: player2Id, power: 2800 });
for (const power of [3000, 2800]) {
await XRankPlacementFactory.create({ playerSplId: "player-2", power });
}
await XRankPlacementRepository.refreshAllPeakXp();
const players = await db
.selectFrom("SplatoonPlayer")
.select(["id", "peakXp"])
.orderBy("id", "asc")
.execute();
expect(players[0].peakXp).toEqual({
expect((await peakXpOf("player-1")).peakXp).toEqual({
overall: 2700,
tentatek: 2700,
takoroka: null,
});
expect(players[1].peakXp).toEqual({
expect((await peakXpOf("player-2")).peakXp).toEqual({
overall: 3000,
tentatek: 3000,
takoroka: null,
@ -97,20 +54,20 @@ describe("refreshAllPeakXp", () => {
});
test("splits peakXp by division (region)", async () => {
const playerId = await createSplatoonPlayer("player1");
await createXRankPlacement({ playerId, power: 2700, region: "WEST" });
await createXRankPlacement({ playerId, power: 2900, region: "JPN" });
await XRankPlacementFactory.create({
playerSplId: "player-1",
power: 2700,
region: "WEST",
});
await XRankPlacementFactory.create({
playerSplId: "player-1",
power: 2900,
region: "JPN",
});
await XRankPlacementRepository.refreshAllPeakXp();
const player = await db
.selectFrom("SplatoonPlayer")
.select("peakXp")
.where("id", "=", playerId)
.executeTakeFirstOrThrow();
expect(player.peakXp).toEqual({
expect((await peakXpOf("player-1")).peakXp).toEqual({
overall: 2900,
tentatek: 2700,
takoroka: 2900,
@ -118,24 +75,26 @@ describe("refreshAllPeakXp", () => {
});
test("sets peakXp to null for player with no placements", async () => {
const playerId = await createSplatoonPlayer("player1");
await XRankPlacementFactory.create({
playerSplId: "player-1",
power: 2500,
...PLACED_ON,
});
await XRankPlacementRepository.refreshAllPeakXp();
const player = await db
.selectFrom("SplatoonPlayer")
.select("peakXp")
.where("id", "=", playerId)
.executeTakeFirstOrThrow();
await XRankPlacementRepository.deleteAllByMonthYear(PLACED_ON);
await XRankPlacementRepository.refreshAllPeakXp();
expect(player.peakXp).toBeNull();
expect((await peakXpOf("player-1")).peakXp).toBeNull();
});
});
describe("refreshTenStarWeapons", () => {
let user: { id: number };
beforeEach(async () => {
placementCounter = 0;
await dbReset();
user = await UserFactory.create();
});
afterEach(async () => {
@ -143,113 +102,97 @@ describe("refreshTenStarWeapons", () => {
});
test("JPN placement qualifies regardless of rank", async () => {
const userId = await createUser("user1");
const playerId = await createSplatoonPlayer("player1", userId);
await createXRankPlacement({
playerId,
await XRankPlacementFactory.create({
playerSplId: "player-1",
playerUserId: user.id,
power: 2500,
region: "JPN",
rank: 450,
weaponSplId: 40 as MainWeaponId,
weaponSplId: SPLATTERSHOT,
});
await XRankPlacementRepository.refreshTenStarWeapons();
const rows = await db.selectFrom("TenStarWeapon").selectAll().execute();
const rows = await findTenStarWeapons();
expect(rows).toHaveLength(1);
expect(rows[0].userId).toBe(userId);
expect(rows[0].weaponSplId).toBe(40);
expect(rows[0].userId).toBe(user.id);
expect(rows[0].weaponSplId).toBe(SPLATTERSHOT);
});
test("WEST placement with rank <= 100 qualifies", async () => {
const userId = await createUser("user1");
const playerId = await createSplatoonPlayer("player1", userId);
await createXRankPlacement({
playerId,
await XRankPlacementFactory.create({
playerSplId: "player-1",
playerUserId: user.id,
power: 3000,
region: "WEST",
rank: 50,
weaponSplId: 40 as MainWeaponId,
weaponSplId: SPLATTERSHOT,
});
await XRankPlacementRepository.refreshTenStarWeapons();
const rows = await db.selectFrom("TenStarWeapon").selectAll().execute();
const rows = await findTenStarWeapons();
expect(rows).toHaveLength(1);
expect(rows[0].weaponSplId).toBe(40);
expect(rows[0].weaponSplId).toBe(SPLATTERSHOT);
});
test("WEST placement with rank > 100 does not qualify", async () => {
const userId = await createUser("user1");
const playerId = await createSplatoonPlayer("player1", userId);
await createXRankPlacement({
playerId,
await XRankPlacementFactory.create({
playerSplId: "player-1",
playerUserId: user.id,
power: 2500,
region: "WEST",
rank: 101,
weaponSplId: 40 as MainWeaponId,
weaponSplId: SPLATTERSHOT,
});
await XRankPlacementRepository.refreshTenStarWeapons();
const rows = await db.selectFrom("TenStarWeapon").selectAll().execute();
expect(rows).toHaveLength(0);
expect(await findTenStarWeapons()).toHaveLength(0);
});
test("unlinked players are excluded", async () => {
const playerId = await createSplatoonPlayer("player1");
await createXRankPlacement({
playerId,
await XRankPlacementFactory.create({
playerSplId: "player-1",
power: 2500,
region: "JPN",
rank: 1,
weaponSplId: 40 as MainWeaponId,
weaponSplId: SPLATTERSHOT,
});
await XRankPlacementRepository.refreshTenStarWeapons();
const rows = await db.selectFrom("TenStarWeapon").selectAll().execute();
expect(rows).toHaveLength(0);
expect(await findTenStarWeapons()).toHaveLength(0);
});
test("duplicate weapon placements produce one row", async () => {
const userId = await createUser("user1");
const playerId = await createSplatoonPlayer("player1", userId);
await createXRankPlacement({
playerId,
await XRankPlacementFactory.create({
playerSplId: "player-1",
playerUserId: user.id,
power: 2500,
region: "JPN",
rank: 100,
weaponSplId: 40 as MainWeaponId,
weaponSplId: SPLATTERSHOT,
});
await createXRankPlacement({
playerId,
await XRankPlacementFactory.create({
playerSplId: "player-1",
playerUserId: user.id,
power: 2700,
region: "JPN",
rank: 50,
weaponSplId: 40 as MainWeaponId,
weaponSplId: SPLATTERSHOT,
});
await XRankPlacementRepository.refreshTenStarWeapons();
const rows = await db.selectFrom("TenStarWeapon").selectAll().execute();
expect(rows).toHaveLength(1);
expect(await findTenStarWeapons()).toHaveLength(1);
});
});
describe("refreshTenStarWeapons with userId", () => {
beforeEach(async () => {
placementCounter = 0;
await dbReset();
});
@ -258,49 +201,37 @@ describe("refreshTenStarWeapons with userId", () => {
});
test("only affects the target user", async () => {
const user1Id = await createUser("user1");
const user2Id = await createUser("user2");
const player1Id = await createSplatoonPlayer("player1", user1Id);
const player2Id = await createSplatoonPlayer("player2", user2Id);
const [user1, user2] = await UserFactory.createMany(2);
await createXRankPlacement({
playerId: player1Id,
await XRankPlacementFactory.create({
playerSplId: "player-1",
playerUserId: user1.id,
power: 2500,
region: "JPN",
rank: 100,
weaponSplId: 40 as MainWeaponId,
weaponSplId: SPLATTERSHOT,
});
await createXRankPlacement({
playerId: player2Id,
await XRankPlacementFactory.create({
playerSplId: "player-2",
playerUserId: user2.id,
power: 2500,
region: "JPN",
rank: 200,
weaponSplId: 50 as MainWeaponId,
weaponSplId: SPLATTERSHOT_NOUVEAU,
});
await XRankPlacementRepository.refreshTenStarWeapons();
const beforeRows = await db
.selectFrom("TenStarWeapon")
.selectAll()
.execute();
expect(beforeRows).toHaveLength(2);
expect(await findTenStarWeapons()).toHaveLength(2);
await db
.updateTable("SplatoonPlayer")
.set({ userId: null })
.where("id", "=", player1Id)
.execute();
await XRankPlacementRepository.unlinkPlayerByUserId(user1.id);
await XRankPlacementRepository.refreshTenStarWeapons(user1Id);
await XRankPlacementRepository.refreshTenStarWeapons(user1.id);
const afterRows = await db
.selectFrom("TenStarWeapon")
.selectAll()
.execute();
const afterRows = await findTenStarWeapons();
expect(afterRows).toHaveLength(1);
expect(afterRows[0].userId).toBe(user2Id);
expect(afterRows[0].weaponSplId).toBe(50);
expect(afterRows[0].userId).toBe(user2.id);
expect(afterRows[0].weaponSplId).toBe(SPLATTERSHOT_NOUVEAU);
});
});

View File

@ -6,6 +6,70 @@ import { modesShort } from "~/modules/in-game-lists/modes";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { peakXpOverallSql } from "~/utils/kysely.server";
export type XRankPlacementInsertArgs = Omit<
Tables["XRankPlacement"],
"id" | "playerId"
> & {
/** In-game id of the player the placement belongs to. */
playerSplId: string;
/**
* Site user whose results these are, for a source that already knows the pairing.
* A player claiming their own results afterwards goes through
* `AdminRepository.linkUserAndPlayer`, which also refreshes what the link derives.
*/
playerUserId?: number;
};
/**
* Adds the given placements, creating a `SplatoonPlayer` row for every in-game id
* not seen before. Returns the placement ids in insertion order.
*/
export function insertMany(placements: XRankPlacementInsertArgs[]) {
return db.transaction().execute(async (trx) => {
const ids: number[] = [];
for (const { playerSplId, playerUserId, ...placement } of placements) {
await trx
.insertInto("SplatoonPlayer")
.values({ splId: playerSplId, userId: playerUserId ?? null })
.onConflict((oc) =>
playerUserId
? oc.column("splId").doUpdateSet({ userId: playerUserId })
: oc.column("splId").doNothing(),
)
.execute();
const inserted = await trx
.insertInto("XRankPlacement")
.values({
...placement,
playerId: (eb) =>
eb
.selectFrom("SplatoonPlayer")
.select("SplatoonPlayer.id")
.where("splId", "=", playerSplId),
})
.returning("id")
.executeTakeFirstOrThrow();
ids.push(inserted.id);
}
return ids;
});
}
/** Removes every placement of the given month, before its results are read in anew. */
export function deleteAllByMonthYear(
args: Pick<Tables["XRankPlacement"], "month" | "year">,
) {
return db
.deleteFrom("XRankPlacement")
.where("month", "=", args.month)
.where("year", "=", args.year)
.execute();
}
export function unlinkPlayerByUserId(userId: number) {
return db
.updateTable("SplatoonPlayer")

View File

@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import * as ImageFactory from "~/db/seed/factories/ImageFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as XRankPlacementFactory from "~/db/seed/factories/XRankPlacementFactory";
import { db } from "~/db/sql";
import { dbReset, withNoUser, withUserId } from "~/utils/Test";
import * as UserCardRepository from "./UserCardRepository.server";
@ -8,35 +10,17 @@ import type { UserCardData } from "./user-card-types";
let owner: { id: number };
let other: { id: number };
const insertVerifiedXp = async (
const insertVerifiedXp = (
userId: number,
power: number,
region: "WEST" | "JPN" = "WEST",
) => {
const player = await db
.insertInto("SplatoonPlayer")
.values({ splId: `spl-${userId}`, userId })
.returning("id")
.executeTakeFirstOrThrow();
await db
.insertInto("XRankPlacement")
.values({
playerId: player.id,
weaponSplId: 0,
badges: "[]",
bannerSplId: 1,
mode: "SZ",
month: 1,
year: 2024,
name: "Test Player",
nameDiscriminator: "0000",
power,
rank: 1,
region,
title: "Test",
})
.execute();
};
) =>
XRankPlacementFactory.create({
playerSplId: `player-${userId}`,
playerUserId: userId,
power,
region,
});
const findXpStat = (card: UserCardData | undefined) =>
card?.stats.find((stat) => stat.type === "XP");
@ -254,15 +238,10 @@ describe("UserCardRepository.findAllByUserIds", () => {
});
it("produces a URL banner when an uploaded banner image is set", async () => {
const image = await db
.insertInto("UnvalidatedUserSubmittedImage")
.values({
url: "banner.webp",
submitterUserId: owner.id,
validatedAt: 1,
})
.returning("id")
.executeTakeFirstOrThrow();
const image = await ImageFactory.create(
{ submitterUserId: owner.id },
{ isValidated: true },
);
await withUserId(owner.id, () =>
UserCardRepository.updateOwnCard({

View File

@ -1,3 +1,4 @@
import { sub } from "date-fns";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
@ -6,66 +7,51 @@ vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
setMetadata: vi.fn(),
}));
import { backdate } from "~/db/seed/core/backdate";
import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory";
import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { db } from "~/db/sql";
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
import { dbReset } from "~/utils/Test";
import * as GroupMatchContinueVoteRepository from "~/features/sendouq-match/GroupMatchContinueVoteRepository.server";
import { dbReset, withUserId } from "~/utils/Test";
import { CloseExpiredContinueVotesRoutine } from "./closeExpiredContinueVotes";
let alphaUserIds: number[];
let bravoUserIds: number[];
const insertGroup = async ({
matchmade,
userIds,
const setupMatch = async ({
isMatchmade,
confirmedAt,
}: {
matchmade: 0 | 1;
userIds: readonly number[];
isMatchmade: boolean;
confirmedAt: Date;
}) => {
const group = await db
.insertInto("Group")
.values({
chatCode: `chat-${Math.random().toString(36).slice(2, 10)}`,
inviteCode: `inv-${Math.random().toString(36).slice(2, 10)}`,
status: "INACTIVE",
matchmade,
})
.returning("id")
.executeTakeFirstOrThrow();
const alphaGroup = await createGroup(alphaUserIds, isMatchmade);
const bravoGroup = await createGroup(bravoUserIds, isMatchmade);
await db
.insertInto("GroupMember")
.values(
userIds.map((userId, i) => ({
groupId: group.id,
userId,
role: i === 0 ? ("OWNER" as const) : ("REGULAR" as const),
})),
)
.execute();
const match = await SQMatchFactory.create(
{ alphaGroupId: alphaGroup.id, bravoGroupId: bravoGroup.id },
{ isConcluded: true },
);
await backdate("GroupMatch", match.id, { confirmedAt });
return group.id;
return { alphaGroupId: alphaGroup.id, bravoGroupId: bravoGroup.id };
};
const insertMatch = async ({
alphaGroupId,
bravoGroupId,
confirmedAtSeconds,
}: {
alphaGroupId: number;
bravoGroupId: number;
confirmedAtSeconds: number;
}) => {
await db
.insertInto("GroupMatch")
.values({
alphaGroupId,
bravoGroupId,
chatCode: "test-match-chat",
confirmedAt: confirmedAtSeconds,
})
.execute();
};
const createGroup = ([owner, ...members]: number[], isMatchmade: boolean) =>
SQGroupFactory.create(
{ userId: owner, additionalMemberUserIds: members },
{ isMatchmade },
);
const castContinueVote = (groupId: number, userId: number) =>
withUserId(userId, () =>
GroupMatchContinueVoteRepository.castOwnVote({
groupId,
isContinuing: true,
}),
);
const fetchVotes = (groupId: number) =>
db
@ -89,29 +75,13 @@ describe("CloseExpiredContinueVotesRoutine", () => {
vi.useRealTimers();
});
const nowSeconds = () => Math.floor(Date.now() / 1000);
test("flips all non-NO members to NO for matchmade groups whose match confirmed over 1h ago", async () => {
const alphaGroupId = await insertGroup({
matchmade: 1,
userIds: alphaUserIds,
const { alphaGroupId, bravoGroupId } = await setupMatch({
isMatchmade: true,
confirmedAt: sub(new Date(), { hours: 2 }),
});
const bravoGroupId = await insertGroup({
matchmade: 1,
userIds: bravoUserIds,
});
await insertMatch({
alphaGroupId,
bravoGroupId,
confirmedAtSeconds: nowSeconds() - 60 * 60 * 2,
});
await db
.insertInto("GroupMatchContinueVote")
.values([
{ groupId: alphaGroupId, userId: alphaUserIds[0], isContinuing: 1 },
{ groupId: alphaGroupId, userId: alphaUserIds[1], isContinuing: 1 },
])
.execute();
await castContinueVote(alphaGroupId, alphaUserIds[0]);
await castContinueVote(alphaGroupId, alphaUserIds[1]);
await CloseExpiredContinueVotesRoutine.run();
@ -129,27 +99,11 @@ describe("CloseExpiredContinueVotesRoutine", () => {
});
test("leaves matches confirmed under 1h ago untouched", async () => {
const alphaGroupId = await insertGroup({
matchmade: 1,
userIds: alphaUserIds,
const { alphaGroupId, bravoGroupId } = await setupMatch({
isMatchmade: true,
confirmedAt: sub(new Date(), { minutes: 30 }),
});
const bravoGroupId = await insertGroup({
matchmade: 1,
userIds: bravoUserIds,
});
await insertMatch({
alphaGroupId,
bravoGroupId,
confirmedAtSeconds: nowSeconds() - 60 * 30,
});
await db
.insertInto("GroupMatchContinueVote")
.values({
groupId: alphaGroupId,
userId: alphaUserIds[0],
isContinuing: 1,
})
.execute();
await castContinueVote(alphaGroupId, alphaUserIds[0]);
await CloseExpiredContinueVotesRoutine.run();
@ -160,18 +114,9 @@ describe("CloseExpiredContinueVotesRoutine", () => {
});
test("does not touch non-matchmade groups even if match confirmed long ago", async () => {
const alphaGroupId = await insertGroup({
matchmade: 0,
userIds: alphaUserIds,
});
const bravoGroupId = await insertGroup({
matchmade: 0,
userIds: bravoUserIds,
});
await insertMatch({
alphaGroupId,
bravoGroupId,
confirmedAtSeconds: nowSeconds() - 60 * 60 * 2,
const { alphaGroupId, bravoGroupId } = await setupMatch({
isMatchmade: false,
confirmedAt: sub(new Date(), { hours: 2 }),
});
await CloseExpiredContinueVotesRoutine.run();
@ -181,29 +126,13 @@ describe("CloseExpiredContinueVotesRoutine", () => {
});
test("skips groups whose cascade is fully resolved (every member already has a vote row)", async () => {
const alphaGroupId = await insertGroup({
matchmade: 1,
userIds: alphaUserIds,
const { alphaGroupId } = await setupMatch({
isMatchmade: true,
confirmedAt: sub(new Date(), { hours: 2 }),
});
const bravoGroupId = await insertGroup({
matchmade: 1,
userIds: bravoUserIds,
});
await insertMatch({
alphaGroupId,
bravoGroupId,
confirmedAtSeconds: nowSeconds() - 60 * 60 * 2,
});
await db
.insertInto("GroupMatchContinueVote")
.values(
alphaUserIds.map((userId) => ({
groupId: alphaGroupId,
userId,
isContinuing: 1 as const,
})),
)
.execute();
for (const userId of alphaUserIds) {
await castContinueVote(alphaGroupId, userId);
}
await CloseExpiredContinueVotesRoutine.run();

View File

@ -256,8 +256,7 @@ describe("syncLiveStreams tournament streamers", () => {
const rowsAfterFirst = await findAllTournamentStreamers();
expect(rowsAfterFirst).toHaveLength(1);
// clear DB and add a different tournament — if throttle works, nothing new is inserted
await db.deleteFrom("TournamentStreamer").execute();
// add a different tournament — if throttle works, nothing new is inserted
RunningTournaments.clear();
mockGetStreams.mockResolvedValue([
@ -296,6 +295,7 @@ describe("syncLiveStreams tournament streamers", () => {
await SyncLiveStreamsRoutine.run();
const rowsAfterSecond = await findAllTournamentStreamers();
expect(rowsAfterSecond).toHaveLength(0);
expect(rowsAfterSecond).toHaveLength(1);
expect(rowsAfterSecond[0].twitchAccount).toBe("streamer_a");
});
});

View File

@ -1,5 +1,6 @@
import { sql } from "kysely";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as TournamentStreamerFactory from "~/db/seed/factories/TournamentStreamerFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { db } from "~/db/sql";
import type { CastedMatchesInfo } from "~/db/tables-json";
@ -360,10 +361,11 @@ async function seedStreamer(
twitchAccount: string,
userId: number | null = null,
) {
await db
.insertInto("TournamentStreamer")
.values({ tournamentId: TOURNAMENT_ID, twitchAccount, userId })
.execute();
await TournamentStreamerFactory.create({
tournamentId: TOURNAMENT_ID,
twitchAccount,
userId,
});
}
function findAllVods() {

View File

@ -1,4 +1,4 @@
import { db } from "~/db/sql";
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
@ -20,9 +20,11 @@ invariant(
"hue must be between -360 and 360",
);
await db
.insertInto("Badge")
.values({ code, displayName, hue: parsedHue ?? null })
.execute();
await BadgeRepository.insert({
code,
displayName,
hue: parsedHue ?? null,
authorId: null,
});
logger.info(`Added new badge: ${displayName}`);

View File

@ -1,5 +1,3 @@
import { db } from "~/db/sql";
import type { Tables } from "~/db/tables";
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
@ -17,9 +15,7 @@ invariant(
"jsonNumber must be an integer (argument 1)",
);
type Placements = Array<
Omit<Tables["XRankPlacement"], "playerId" | "id"> & { playerSplId: string }
>;
type Placements = XRankPlacementRepository.XRankPlacementInsertArgs[];
const modes = ["splatzones", "towercontrol", "rainmaker", "clamblitz"] as const;
const modeToShort = {
@ -35,7 +31,9 @@ void main();
async function main() {
const placements: Placements = [];
await wipeMonthYearPlacements(resolveMonthYear(jsonNumber));
await XRankPlacementRepository.deleteAllByMonthYear(
resolveMonthYear(jsonNumber),
);
for (const mode of modes) {
for (const region of regions) {
for (const includeWeapon of [false]) {
@ -51,7 +49,7 @@ async function main() {
}
}
await addPlacements(placements);
await XRankPlacementRepository.insertMany(placements);
await XRankPlacementRepository.refreshAllPeakXp();
await BadgeRepository.syncXPBadges();
await BuildRepository.recalculateAllSortValues();
@ -139,41 +137,3 @@ function resolveMonthYear(number: number) {
year: start.getFullYear(),
};
}
function addPlacements(placements: Placements) {
return db.transaction().execute(async (trx) => {
for (const { playerSplId, ...placement } of placements) {
await trx
.insertInto("SplatoonPlayer")
.values({ splId: playerSplId })
.onConflict((oc) => oc.column("splId").doNothing())
.execute();
await trx
.insertInto("XRankPlacement")
.values({
...placement,
playerId: (eb) =>
eb
.selectFrom("SplatoonPlayer")
.select("SplatoonPlayer.id")
.where("splId", "=", playerSplId),
})
.execute();
}
});
}
function wipeMonthYearPlacements({
month,
year,
}: {
month: number;
year: number;
}) {
return db
.deleteFrom("XRankPlacement")
.where("month", "=", month)
.where("year", "=", year)
.execute();
}

View File

@ -1,4 +1,5 @@
import { db } from "~/db/sql";
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import { logger } from "~/utils/logger";
const HOMEMADE_BADGES_URL =
@ -162,14 +163,7 @@ async function addBadge(badge: {
displayName: string;
authorId: number;
}) {
return db
.insertInto("Badge")
.values({
code: badge.code,
displayName: badge.displayName,
authorId: badge.authorId,
})
.execute();
return BadgeRepository.insert({ ...badge, hue: null });
}
main();