Factories 1

This commit is contained in:
Kalle
2026-07-27 17:00:44 +03:00
parent 2c9cc7502a
commit 5a467a5a1e
9 changed files with 445 additions and 100 deletions

View File

@@ -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,
}));
}

View File

@@ -0,0 +1,144 @@
import { resetFaker } from "./faker";
/** Arguments `defaults` does not supply, which the caller therefore has to pass. */
type RequiredArgs<Args, Defaults> = Omit<Args, keyof Defaults>;
type CreateArgs<Args, Defaults> = RequiredArgs<Args, Defaults> & Partial<Args>;
type CreateParams<Args, Defaults, Options> = [
keyof RequiredArgs<Args, Defaults>,
] extends [never]
? [overrides?: Partial<Args>, options?: Options]
: [overrides: CreateArgs<Args, Defaults>, options?: Options];
type CreateManyParams<Args, Defaults, Options> = [
keyof RequiredArgs<Args, Defaults>,
] extends [never]
? [
count: number,
overrides?: ManyOverrides<Args, Defaults>,
options?: Options,
]
: [
count: number,
overrides: ManyOverrides<Args, Defaults>,
options?: Options,
];
type ManyOverrides<Args, Defaults> =
| CreateArgs<Args, Defaults>
| ((index: number) => CreateArgs<Args, Defaults>);
export type Factory<Args, Row, Defaults, Options> = {
/** Inserts one row. Anything not given is defaulted. */
create: (...args: CreateParams<Args, Defaults, Options>) => Promise<Row>;
/** Inserts `count` rows. Overrides may be per-index. */
createMany: (
...args: CreateManyParams<Args, Defaults, Options>
) => Promise<Row[]>;
/** A preset: the same factory with an extra layer of defaults. */
variant: <VariantDefaults extends Partial<Args>>(
defaults: VariantDefaults,
) => Factory<Args, Row, Defaults & VariantDefaults, Options>;
};
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<Args>,
Options = never,
>({
defaults,
insert,
applyOptions,
}: {
defaults: (ctx: { seq: number }) => Defaults;
insert: (args: Args) => Promise<Row>;
applyOptions?: (row: Row, options: Options) => Promise<void>;
}): Factory<Args, Row, Defaults, Options> {
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 extends Partial<Args>>(
presetDefaults: PresetDefaults,
): Factory<Args, Row, Defaults & PresetDefaults, Options> => {
// `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<Args>) =>
({
...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<Args, Defaults>(
overrides: ManyOverrides<Args, Defaults> | undefined,
index: number,
): Partial<Args> {
if (!overrides) return {};
return typeof overrides === "function" ? overrides(index) : overrides;
}

40
app/db/seed/core/faker.ts Normal file
View File

@@ -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<unknown>();
/**
* 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<T>(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();
}

View File

@@ -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<Insert>[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();
},
});

View File

@@ -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}`,
);
}

View File

@@ -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,
});

View File

@@ -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 () => {

View File

@@ -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);
});
});

View File

@@ -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();
};
/**