diff --git a/app/db/seed/core/SplatoonFaker.ts b/app/db/seed/core/SplatoonFaker.ts new file mode 100644 index 000000000..0a02ce7ca --- /dev/null +++ b/app/db/seed/core/SplatoonFaker.ts @@ -0,0 +1,32 @@ +import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes"; +import { stageIds } from "~/modules/in-game-lists/stage-ids"; +import type { ModeWithStage } from "~/modules/in-game-lists/types"; +import { faker } from "./faker"; + +/** A random mode, turf war included. */ +export function mode() { + return faker.helpers.arrayElement(modesShort); +} + +/** A random ranked mode. */ +export function rankedMode() { + return faker.helpers.arrayElement(rankedModesShort); +} + +/** A random stage. */ +export function stageId() { + return faker.helpers.arrayElement(stageIds); +} + +/** + * A map list of `count` maps, rotating through the ranked modes and never repeating + * a stage, the way a real one looks. Callers add whatever `source` their domain uses. + */ +export function mapList(count: number): ModeWithStage[] { + const stages = faker.helpers.arrayElements(stageIds, count); + + return stages.map((stageId, i) => ({ + mode: rankedModesShort[i % rankedModesShort.length], + stageId, + })); +} diff --git a/app/db/seed/core/defineFactory.ts b/app/db/seed/core/defineFactory.ts new file mode 100644 index 000000000..46c210ba0 --- /dev/null +++ b/app/db/seed/core/defineFactory.ts @@ -0,0 +1,144 @@ +import { resetFaker } from "./faker"; + +/** Arguments `defaults` does not supply, which the caller therefore has to pass. */ +type RequiredArgs = Omit; + +type CreateArgs = RequiredArgs & Partial; + +type CreateParams = [ + keyof RequiredArgs, +] extends [never] + ? [overrides?: Partial, options?: Options] + : [overrides: CreateArgs, options?: Options]; + +type CreateManyParams = [ + keyof RequiredArgs, +] extends [never] + ? [ + count: number, + overrides?: ManyOverrides, + options?: Options, + ] + : [ + count: number, + overrides: ManyOverrides, + options?: Options, + ]; + +type ManyOverrides = + | CreateArgs + | ((index: number) => CreateArgs); + +export type Factory = { + /** Inserts one row. Anything not given is defaulted. */ + create: (...args: CreateParams) => Promise; + /** Inserts `count` rows. Overrides may be per-index. */ + createMany: ( + ...args: CreateManyParams + ) => Promise; + /** A preset: the same factory with an extra layer of defaults. */ + variant: >( + defaults: VariantDefaults, + ) => Factory; +}; + +const sequenceResets = new Set<() => void>(); + +/** + * Defines a factory: a thin wrapper around a repository write function that fills + * arguments with a plausible default and lets the caller override any of them. + * + * `Args` is inferred from `insert`, so factories never restate column types. What + * `defaults` leaves out — foreign keys above all, which a factory must not invent — + * becomes a required argument of `create`. + * + * Defaults are drawn eagerly, before overrides are applied, so that which fields a + * caller happens to override does not shift the values every later row gets. + * + * `applyOptions` runs after the insert and is how a factory hands back a row in a + * later state (a concluded match, a finalized tournament). It gets there by running + * the app's own operations, never by writing the resulting rows itself. + */ +export function defineFactory< + Args, + Row, + Defaults extends Partial, + Options = never, +>({ + defaults, + insert, + applyOptions, +}: { + defaults: (ctx: { seq: number }) => Defaults; + insert: (args: Args) => Promise; + applyOptions?: (row: Row, options: Options) => Promise; +}): Factory { + let seq = 0; + sequenceResets.add(() => { + seq = 0; + }); + + const insertOne = async (args: Args, options?: Options) => { + const row = await insert(args); + + if (applyOptions && options) { + await applyOptions(row, options); + } + + return row; + }; + + const factoryWith = >( + presetDefaults: PresetDefaults, + ): Factory => { + // `create` requires everything the two default layers don't supply, so the merge + // is a complete `Args` — something the compiler can't work out from the spread + const build = (overrides: Partial) => + ({ + ...defaults({ seq: ++seq }), + ...presetDefaults, + ...overrides, + }) as Args; + + return { + create: (...args) => insertOne(build(args[0] ?? {}), args[1]), + createMany: async (...args) => { + const [count, overrides, options] = args; + const rows: Row[] = []; + + for (let index = 0; index < count; index++) { + rows.push( + await insertOne(build(overridesAt(overrides, index)), options), + ); + } + + return rows; + }, + variant: (variantDefaults) => + factoryWith({ ...presetDefaults, ...variantDefaults }), + }; + }; + + return factoryWith({}); +} + +/** + * Reseeds faker and zeroes every factory's sequence, so that a run of the dev seed + * or a test starting from an empty database produces the same values as the last. + */ +export function resetFactories() { + resetFaker(); + + for (const reset of sequenceResets) { + reset(); + } +} + +function overridesAt( + overrides: ManyOverrides | undefined, + index: number, +): Partial { + if (!overrides) return {}; + + return typeof overrides === "function" ? overrides(index) : overrides; +} diff --git a/app/db/seed/core/faker.ts b/app/db/seed/core/faker.ts new file mode 100644 index 000000000..c56b900ea --- /dev/null +++ b/app/db/seed/core/faker.ts @@ -0,0 +1,40 @@ +import { en, Faker } from "@faker-js/faker"; + +const FAKER_SEED = 5800; +const MAX_UNIQUE_ATTEMPTS = 100; + +/** + * Faker instance dedicated to seeding. Deliberately not the global singleton, so + * that app code or a test drawing from `faker` cannot shift what the seed produces. + */ +export const faker = new Faker({ locale: en }); +faker.seed(FAKER_SEED); + +const usedUniqueValues = new Set(); + +/** + * Draws from `generate` until it produces a value that has not been drawn before, + * for values that should look real but still be unique (e.g. a Discord name). + * Values with a unique constraint should be derived from the factory's `seq` instead. + */ +export function unique(generate: () => T): T { + for (let attempt = 0; attempt < MAX_UNIQUE_ATTEMPTS; attempt++) { + const value = generate(); + + if (!usedUniqueValues.has(value)) { + usedUniqueValues.add(value); + + return value; + } + } + + throw new Error( + `Could not draw a unique value in ${MAX_UNIQUE_ATTEMPTS} attempts`, + ); +} + +/** Reseeds the faker instance and forgets every value drawn via `unique`. */ +export function resetFaker() { + faker.seed(FAKER_SEED); + usedUniqueValues.clear(); +} diff --git a/app/db/seed/factories/SQGroupFactory.ts b/app/db/seed/factories/SQGroupFactory.ts new file mode 100644 index 000000000..bc7f7902b --- /dev/null +++ b/app/db/seed/factories/SQGroupFactory.ts @@ -0,0 +1,44 @@ +import { db } from "~/db/sql"; +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; +import { defineFactory } from "../core/defineFactory"; + +type Insert = typeof SQGroupRepository.insert; + +type InsertArgs = Parameters[0] & { + additionalMemberUserIds: number[]; +}; + +type Options = { + /** Was the group made in the matchmaking UI? */ + isMatchmade: boolean; +}; + +/** + * Creates SendouQ groups. `userId` is the owner, whose membership the repository + * creates with the group; the members named by `additionalMemberUserIds` join it + * the way they do in production. Invite and chat codes are the repository's own. + */ +export const { create, createMany } = defineFactory({ + defaults: () => ({ + status: "ACTIVE" as const, + additionalMemberUserIds: [], + }), + insert: async ({ additionalMemberUserIds, ...args }: InsertArgs) => { + const group = await SQGroupRepository.insert(args); + + for (const userId of additionalMemberUserIds) { + await SQGroupRepository.insertMember(group.id, { userId }); + } + + return group; + }, + applyOptions: async (group, { isMatchmade }: Options) => { + if (!isMatchmade) return; + + await db + .updateTable("Group") + .set({ matchmade: 1 }) + .where("id", "=", group.id) + .execute(); + }, +}); diff --git a/app/db/seed/factories/SQMatchFactory.ts b/app/db/seed/factories/SQMatchFactory.ts new file mode 100644 index 000000000..f6ac997f3 --- /dev/null +++ b/app/db/seed/factories/SQMatchFactory.ts @@ -0,0 +1,73 @@ +import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants"; +import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; +import invariant from "~/utils/invariant"; +import { defineFactory } from "../core/defineFactory"; +import * as SplatoonFaker from "../core/SplatoonFaker"; + +type Options = { + /** Play the match out, alpha winning every map, up to both teams having agreed + * on the score. Leaves both groups inactive, as a real concluded match does. */ + isConcluded: boolean; +}; + +/** Creates SendouQ matches. Both groups have to be full, as they are when the + * matchmaking UI creates a match. */ +export const { create, createMany } = defineFactory({ + defaults: () => ({ + mapList: SplatoonFaker.mapList(SENDOUQ_BEST_OF).map((map) => ({ + ...map, + source: "BOTH" as const, + })), + memento: { users: {}, groups: {}, pools: [] }, + }), + insert: SQMatchRepository.insert, + applyOptions: async (match, { isConcluded }: Options) => { + if (!isConcluded) return; + + await playOutMatch(match.id); + }, +}); + +async function playOutMatch(matchId: number) { + const match = await SQMatchRepository.findById(matchId); + invariant(match, "Match not found"); + + const winnerId = match.groupAlpha.id; + const reportedByUserId = match.groupAlpha.members[0].id; + const confirmedByUserId = match.groupBravo.members[0].id; + + let reportedCount = 0; + let result = await SQMatchRepository.reportMapWinner({ + matchId, + winnerId, + reportedByUserId, + reportedCount, + }); + + while (result.status === "MAP_REPORTED") { + reportedCount++; + result = await SQMatchRepository.reportMapWinner({ + matchId, + winnerId, + reportedByUserId, + reportedCount, + }); + } + + invariant( + result.status === "MATCH_REPORTED", + `Reporting the deciding map resulted in ${result.status}`, + ); + + const confirmation = await SQMatchRepository.reportMapWinner({ + matchId, + winnerId, + reportedByUserId: confirmedByUserId, + reportedCount: reportedCount + 1, + }); + + invariant( + confirmation.status === "MATCH_FINALIZED", + `Confirming the score resulted in ${confirmation.status}`, + ); +} diff --git a/app/db/seed/factories/UserFactory.ts b/app/db/seed/factories/UserFactory.ts new file mode 100644 index 000000000..8b39ff05a --- /dev/null +++ b/app/db/seed/factories/UserFactory.ts @@ -0,0 +1,18 @@ +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { defineFactory } from "../core/defineFactory"; +import { faker, unique } from "../core/faker"; + +/** Creates users. Columns outside `UserRepository.upsert` (profile fields, patron + * status, plus tier) are set by the repository function that owns them. */ +export const { create, createMany } = defineFactory({ + defaults: ({ seq }) => ({ + discordId: String(seq), + discordName: unique(() => faker.internet.username()), + discordUniqueName: null, + discordAvatar: null, + twitch: null, + youtubeId: null, + bsky: null, + }), + insert: UserRepository.upsert, +}); diff --git a/app/features/auth/LogInLinkRepository.server.test.ts b/app/features/auth/LogInLinkRepository.server.test.ts index d554958fa..0ad0f767a 100644 --- a/app/features/auth/LogInLinkRepository.server.test.ts +++ b/app/features/auth/LogInLinkRepository.server.test.ts @@ -1,10 +1,13 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { dbInsertUsers, dbReset } from "~/utils/Test"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { dbReset } from "~/utils/Test"; import * as LogInLinkRepository from "./LogInLinkRepository.server"; describe("create", () => { + let userId: number; + beforeEach(async () => { - await dbInsertUsers(1); + userId = (await UserFactory.create()).id; }); afterEach(async () => { @@ -12,22 +15,24 @@ describe("create", () => { }); test("creates a login link with correct userId", async () => { - const link = await LogInLinkRepository.insert(1); + const link = await LogInLinkRepository.insert(userId); - expect(link.userId).toBe(1); + expect(link.userId).toBe(userId); }); test("creates a login link with future expiration", async () => { const beforeCreation = Math.floor(Date.now() / 1000); - const link = await LogInLinkRepository.insert(1); + const link = await LogInLinkRepository.insert(userId); expect(link.expiresAt).toBeGreaterThan(beforeCreation); }); }); describe("del", () => { + let userId: number; + beforeEach(async () => { - await dbInsertUsers(1); + userId = (await UserFactory.create()).id; }); afterEach(async () => { @@ -35,7 +40,7 @@ describe("del", () => { }); test("deletes a login link by code", async () => { - const link = await LogInLinkRepository.insert(1); + const link = await LogInLinkRepository.insert(userId); await LogInLinkRepository.deleteByCode(link.code); @@ -45,8 +50,10 @@ describe("del", () => { }); describe("findValidByCode", () => { + let userId: number; + beforeEach(async () => { - await dbInsertUsers(1); + userId = (await UserFactory.create()).id; }); afterEach(async () => { @@ -54,11 +61,11 @@ describe("findValidByCode", () => { }); test("returns userId for valid code", async () => { - const link = await LogInLinkRepository.insert(1); + const link = await LogInLinkRepository.insert(userId); const result = await LogInLinkRepository.findValidByCode(link.code); - expect(result?.userId).toBe(1); + expect(result?.userId).toBe(userId); }); test("returns undefined for non-existent code", async () => { diff --git a/app/features/sendouq/SQGroupRepository.server.test.ts b/app/features/sendouq/SQGroupRepository.server.test.ts index f47fec851..5f93bcae9 100644 --- a/app/features/sendouq/SQGroupRepository.server.test.ts +++ b/app/features/sendouq/SQGroupRepository.server.test.ts @@ -1,168 +1,151 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { db } from "~/db/sql"; -import { dbInsertUsers, dbReset } from "~/utils/Test"; +import { afterEach, describe, expect, test } from "vitest"; +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 * as GroupMatchContinueVoteRepository from "~/features/sendouq-match/GroupMatchContinueVoteRepository.server"; +import { dbReset, withUserId } from "~/utils/Test"; +import { FULL_GROUP_SIZE } from "./q-constants"; import * as SQGroupRepository from "./SQGroupRepository.server"; -const MATCH_CHAT_CODE = "match-chat"; - const setupConcludedMatch = async () => { - const alphaGroup = await db - .insertInto("Group") - .values({ - inviteCode: "inv-alpha", - chatCode: "chat-alpha", - status: "INACTIVE", - matchmade: 1, - }) - .returning("id") - .executeTakeFirstOrThrow(); + const users = await UserFactory.createMany(FULL_GROUP_SIZE * 2); + const alphaMembers = users.slice(0, FULL_GROUP_SIZE); - const bravoGroup = await db - .insertInto("Group") - .values({ - inviteCode: "inv-bravo", - chatCode: "chat-bravo", - status: "INACTIVE", - matchmade: 1, - }) - .returning("id") - .executeTakeFirstOrThrow(); + const alphaGroup = await createMatchmadeGroup(alphaMembers); + const bravoGroup = await createMatchmadeGroup(users.slice(FULL_GROUP_SIZE)); - await db - .insertInto("GroupMember") - .values([ - { groupId: alphaGroup.id, userId: 1, role: "OWNER" }, - { groupId: alphaGroup.id, userId: 2, role: "REGULAR" }, - { groupId: bravoGroup.id, userId: 3, role: "OWNER" }, - { groupId: bravoGroup.id, userId: 4, role: "REGULAR" }, - ]) - .execute(); + const match = await SQMatchFactory.create( + { alphaGroupId: alphaGroup.id, bravoGroupId: bravoGroup.id }, + { isConcluded: true }, + ); - await db - .insertInto("GroupMatch") - .values({ - alphaGroupId: alphaGroup.id, - bravoGroupId: bravoGroup.id, - chatCode: MATCH_CHAT_CODE, - }) - .execute(); - - return { alphaGroupId: alphaGroup.id, bravoGroupId: bravoGroup.id }; + return { + alphaGroupId: alphaGroup.id, + bravoGroupId: bravoGroup.id, + matchChatCode: match.chatCode, + alphaMembers, + }; }; +const createMatchmadeGroup = ([owner, ...members]: Array<{ id: number }>) => + SQGroupFactory.create( + { + userId: owner.id, + additionalMemberUserIds: members.map((member) => member.id), + }, + { isMatchmade: true }, + ); + const fetchVotes = (groupId: number) => - db - .selectFrom("GroupMatchContinueVote") - .selectAll() - .where("groupId", "=", groupId) - .execute(); + GroupMatchContinueVoteRepository.findAllByGroupIds([groupId]); + +const castYesVote = (userId: number, groupId: number) => + withUserId(userId, () => + GroupMatchContinueVoteRepository.castOwnVote({ + groupId, + isContinuing: true, + }), + ); describe("insert", () => { - beforeEach(async () => { - await dbInsertUsers(5); - }); - afterEach(async () => { await dbReset(); }); test("records implicit no-vote on previous matchmade group when user creates a new group", async () => { - const { alphaGroupId } = await setupConcludedMatch(); + const { alphaGroupId, alphaMembers, matchChatCode } = + await setupConcludedMatch(); const votesBefore = await fetchVotes(alphaGroupId); expect(votesBefore).toHaveLength(0); const result = await SQGroupRepository.insert({ status: "ACTIVE", - userId: 1, + userId: alphaMembers[0].id, }); const votes = await fetchVotes(alphaGroupId); expect(votes).toHaveLength(1); - expect(votes[0].userId).toBe(1); - expect(votes[0].isContinuing).toBe(0); - expect(result.chatCodeToRevalidate).toBe(MATCH_CHAT_CODE); + expect(votes[0].userId).toBe(alphaMembers[0].id); + expect(votes[0].isContinuing).toBe(false); + expect(result.chatCodeToRevalidate).toBe(matchChatCode); }); test("preserves existing vote when user already voted yes on previous match", async () => { - const { alphaGroupId } = await setupConcludedMatch(); + const { alphaGroupId, alphaMembers } = await setupConcludedMatch(); - await db - .insertInto("GroupMatchContinueVote") - .values({ groupId: alphaGroupId, userId: 1, isContinuing: 1 }) - .execute(); + await castYesVote(alphaMembers[0].id, alphaGroupId); const result = await SQGroupRepository.insert({ status: "ACTIVE", - userId: 1, + userId: alphaMembers[0].id, }); const votes = await fetchVotes(alphaGroupId); expect(votes).toHaveLength(1); - expect(votes[0].isContinuing).toBe(1); + expect(votes[0].isContinuing).toBe(true); expect(result.chatCodeToRevalidate).toBeNull(); }); test("clears other members' yes votes on the previous group when recording implicit no", async () => { - const { alphaGroupId } = await setupConcludedMatch(); + const { alphaGroupId, alphaMembers } = await setupConcludedMatch(); - await db - .insertInto("GroupMatchContinueVote") - .values({ groupId: alphaGroupId, userId: 2, isContinuing: 1 }) - .execute(); + await castYesVote(alphaMembers[1].id, alphaGroupId); const votesBefore = await fetchVotes(alphaGroupId); - expect(votesBefore[0].userId).toBe(2); + expect(votesBefore[0].userId).toBe(alphaMembers[1].id); - await SQGroupRepository.insert({ status: "ACTIVE", userId: 1 }); + await SQGroupRepository.insert({ + status: "ACTIVE", + userId: alphaMembers[0].id, + }); const votes = await fetchVotes(alphaGroupId); expect(votes).toHaveLength(1); - expect(votes[0].userId).toBe(1); - expect(votes[0].isContinuing).toBe(0); + expect(votes[0].userId).toBe(alphaMembers[0].id); + expect(votes[0].isContinuing).toBe(false); }); test("does not record any vote when user has no previous matchmade group", async () => { + const user = await UserFactory.create(); + const result = await SQGroupRepository.insert({ status: "ACTIVE", - userId: 1, + userId: user.id, }); - const allVotes = await db - .selectFrom("GroupMatchContinueVote") - .selectAll() - .execute(); + const allVotes = await GroupMatchContinueVoteRepository.findAllByGroupIds([ + result.id, + ]); expect(allVotes).toHaveLength(0); expect(result.chatCodeToRevalidate).toBeNull(); }); }); describe("insertMember", () => { - beforeEach(async () => { - await dbInsertUsers(5); - }); - afterEach(async () => { await dbReset(); }); test("records implicit no-vote on previous matchmade group when user joins another group", async () => { - const { alphaGroupId } = await setupConcludedMatch(); + const { alphaGroupId, alphaMembers, matchChatCode } = + await setupConcludedMatch(); + const newOwner = await UserFactory.create(); - const newGroup = await SQGroupRepository.insert({ + const newGroup = await SQGroupFactory.create({ status: "PREPARING", - userId: 5, + userId: newOwner.id, }); const { chatCodeToRevalidate } = await SQGroupRepository.insertMember( newGroup.id, - { userId: 1 }, + { userId: alphaMembers[0].id }, ); const votes = await fetchVotes(alphaGroupId); expect(votes).toHaveLength(1); - expect(votes[0].userId).toBe(1); - expect(votes[0].isContinuing).toBe(0); - expect(chatCodeToRevalidate).toBe(MATCH_CHAT_CODE); + expect(votes[0].userId).toBe(alphaMembers[0].id); + expect(votes[0].isContinuing).toBe(false); + expect(chatCodeToRevalidate).toBe(matchChatCode); }); }); diff --git a/app/utils/Test.ts b/app/utils/Test.ts index 086e90b5a..19ba2d695 100644 --- a/app/utils/Test.ts +++ b/app/utils/Test.ts @@ -7,6 +7,7 @@ import type { import { expect } from "vitest"; import type { z } from "zod"; import { REGULAR_USER_TEST_ID } from "~/db/seed/constants"; +import { resetFactories } from "~/db/seed/core/defineFactory"; import { db } from "~/db/sql"; import { ADMIN_ID } from "~/features/admin/admin-constants"; import { SESSION_KEY } from "~/features/auth/core/authenticator.server"; @@ -223,6 +224,7 @@ async function authHeader( * // tests go here * }); */ +// xxx: make it automatic export const dbReset = async () => { // virtual tables and their shadow tables (e.g. UserSearch_data) can not be // deleted from directly; the fts index stays in sync via the User triggers @@ -244,6 +246,8 @@ export const dbReset = async () => { await sql`DELETE FROM ${sql.table(table.name)}`.execute(db); } await sql`PRAGMA foreign_keys = ON`.execute(db); + + resetFactories(); }; /**