mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 04:05:54 -05:00
Factories 1
This commit is contained in:
32
app/db/seed/core/SplatoonFaker.ts
Normal file
32
app/db/seed/core/SplatoonFaker.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
144
app/db/seed/core/defineFactory.ts
Normal file
144
app/db/seed/core/defineFactory.ts
Normal 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
40
app/db/seed/core/faker.ts
Normal 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();
|
||||
}
|
||||
44
app/db/seed/factories/SQGroupFactory.ts
Normal file
44
app/db/seed/factories/SQGroupFactory.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
73
app/db/seed/factories/SQMatchFactory.ts
Normal file
73
app/db/seed/factories/SQMatchFactory.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
18
app/db/seed/factories/UserFactory.ts
Normal file
18
app/db/seed/factories/UserFactory.ts
Normal 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,
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user