From 84b1f735aec4a15916de1539ccf92c19df63d761 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:15:45 +0300 Subject: [PATCH] Step 11-13 --- app/db/seed/core/backdate.ts | 40 +++ app/db/seed/factories/BadgeFactory.ts | 13 + app/db/seed/factories/ImageFactory.ts | 10 + .../factories/TournamentStreamerFactory.ts | 23 ++ .../seed/factories/XRankPlacementFactory.ts | 36 +++ .../badges/BadgeRepository.server.test.ts | 68 ++--- app/features/badges/BadgeRepository.server.ts | 16 +- .../builds/BuildRepository.server.test.ts | 51 +--- .../LiveStreamRepository.server.ts | 8 +- .../XRankPlacementRepository.server.test.ts | 247 +++++++----------- .../XRankPlacementRepository.server.ts | 64 +++++ .../UserCardRepository.server.test.ts | 49 +--- .../closeExpiredContinueVotes.test.ts | 171 ++++-------- app/routines/syncLiveStreams.test.ts | 6 +- app/routines/syncTournamentVods.test.ts | 10 +- scripts/add-badge.ts | 12 +- scripts/placements/index.ts | 50 +--- scripts/sync-homemade-badges.ts | 10 +- 18 files changed, 414 insertions(+), 470 deletions(-) create mode 100644 app/db/seed/core/backdate.ts create mode 100644 app/db/seed/factories/BadgeFactory.ts create mode 100644 app/db/seed/factories/TournamentStreamerFactory.ts create mode 100644 app/db/seed/factories/XRankPlacementFactory.ts diff --git a/app/db/seed/core/backdate.ts b/app/db/seed/core/backdate.ts new file mode 100644 index 000000000..c65d11328 --- /dev/null +++ b/app/db/seed/core/backdate.ts @@ -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 = 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( + table: T, + id: number, + timestamps: Partial, Date>>, +) { + const assignments: RawBuilder[] = []; + + 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, + ); +} diff --git a/app/db/seed/factories/BadgeFactory.ts b/app/db/seed/factories/BadgeFactory.ts new file mode 100644 index 000000000..b8a7429ec --- /dev/null +++ b/app/db/seed/factories/BadgeFactory.ts @@ -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, +}); diff --git a/app/db/seed/factories/ImageFactory.ts b/app/db/seed/factories/ImageFactory.ts index 47ad5cc27..1179a4c2d 100644 --- a/app/db/seed/factories/ImageFactory.ts +++ b/app/db/seed/factories/ImageFactory.ts @@ -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); + }, }); diff --git a/app/db/seed/factories/TournamentStreamerFactory.ts b/app/db/seed/factories/TournamentStreamerFactory.ts new file mode 100644 index 000000000..46edca17b --- /dev/null +++ b/app/db/seed/factories/TournamentStreamerFactory.ts @@ -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; + +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; + }, +}); diff --git a/app/db/seed/factories/XRankPlacementFactory.ts b/app/db/seed/factories/XRankPlacementFactory.ts new file mode 100644 index 000000000..df2eedc2e --- /dev/null +++ b/app/db/seed/factories/XRankPlacementFactory.ts @@ -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 }; + }, +}); diff --git a/app/features/badges/BadgeRepository.server.test.ts b/app/features/badges/BadgeRepository.server.test.ts index 32e56da74..90b3c3ea3 100644 --- a/app/features/badges/BadgeRepository.server.test.ts +++ b/app/features/badges/BadgeRepository.server.test.ts @@ -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) { diff --git a/app/features/badges/BadgeRepository.server.ts b/app/features/badges/BadgeRepository.server.ts index ae3971d0b..7d49eb6b9 100644 --- a/app/features/badges/BadgeRepository.server.ts +++ b/app/features/badges/BadgeRepository.server.ts @@ -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, 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") diff --git a/app/features/builds/BuildRepository.server.test.ts b/app/features/builds/BuildRepository.server.test.ts index 264b5e387..c66c937ea 100644 --- a/app/features/builds/BuildRepository.server.test.ts +++ b/app/features/builds/BuildRepository.server.test.ts @@ -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, diff --git a/app/features/live-streams/LiveStreamRepository.server.ts b/app/features/live-streams/LiveStreamRepository.server.ts index 2b9cd6501..4c6f80114 100644 --- a/app/features/live-streams/LiveStreamRepository.server.ts +++ b/app/features/live-streams/LiveStreamRepository.server.ts @@ -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[], ) { - 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(); } diff --git a/app/features/top-search/XRankPlacementRepository.server.test.ts b/app/features/top-search/XRankPlacementRepository.server.test.ts index 6608510a0..66ae10392 100644 --- a/app/features/top-search/XRankPlacementRepository.server.test.ts +++ b/app/features/top-search/XRankPlacementRepository.server.test.ts @@ -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); }); }); diff --git a/app/features/top-search/XRankPlacementRepository.server.ts b/app/features/top-search/XRankPlacementRepository.server.ts index 98e2e809e..774e35f42 100644 --- a/app/features/top-search/XRankPlacementRepository.server.ts +++ b/app/features/top-search/XRankPlacementRepository.server.ts @@ -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, +) { + return db + .deleteFrom("XRankPlacement") + .where("month", "=", args.month) + .where("year", "=", args.year) + .execute(); +} + export function unlinkPlayerByUserId(userId: number) { return db .updateTable("SplatoonPlayer") diff --git a/app/features/user-card/UserCardRepository.server.test.ts b/app/features/user-card/UserCardRepository.server.test.ts index 66e4cf6e5..d49c70199 100644 --- a/app/features/user-card/UserCardRepository.server.test.ts +++ b/app/features/user-card/UserCardRepository.server.test.ts @@ -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({ diff --git a/app/routines/closeExpiredContinueVotes.test.ts b/app/routines/closeExpiredContinueVotes.test.ts index 123a326ed..1f11ab213 100644 --- a/app/routines/closeExpiredContinueVotes.test.ts +++ b/app/routines/closeExpiredContinueVotes.test.ts @@ -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(); diff --git a/app/routines/syncLiveStreams.test.ts b/app/routines/syncLiveStreams.test.ts index f2bb8efb0..96e15439a 100644 --- a/app/routines/syncLiveStreams.test.ts +++ b/app/routines/syncLiveStreams.test.ts @@ -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"); }); }); diff --git a/app/routines/syncTournamentVods.test.ts b/app/routines/syncTournamentVods.test.ts index ff83f4ff2..a8b70adb6 100644 --- a/app/routines/syncTournamentVods.test.ts +++ b/app/routines/syncTournamentVods.test.ts @@ -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() { diff --git a/scripts/add-badge.ts b/scripts/add-badge.ts index 16e604109..55ae49314 100644 --- a/scripts/add-badge.ts +++ b/scripts/add-badge.ts @@ -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}`); diff --git a/scripts/placements/index.ts b/scripts/placements/index.ts index 0a673669a..f83a840e2 100644 --- a/scripts/placements/index.ts +++ b/scripts/placements/index.ts @@ -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 & { 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(); -} diff --git a/scripts/sync-homemade-badges.ts b/scripts/sync-homemade-badges.ts index 4970782e9..ef46be01b 100644 --- a/scripts/sync-homemade-badges.ts +++ b/scripts/sync-homemade-badges.ts @@ -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();