From 7a5c59b8cd33e512875863f2db5cc342c8713f21 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:16:21 +0300 Subject: [PATCH] Add ingest test scenarios --- .../ScannerIngestRepository.server.test.ts | 2 + .../ScannerIngestRepository.server.ts | 91 ++-- .../actions/scanner-ingest.server.ts | 9 +- .../scanner-ingest/core/Scoreboards.test.ts | 53 ++ .../scanner-ingest/core/Scoreboards.ts | 55 +- app/features/scanner-ingest/tests/harness.ts | 511 ++++++++++++++++++ .../tests/ingest-scenarios.test.ts | 453 ++++++++++++++++ app/utils/Test.ts | 28 +- vitest.unit.config.ts | 5 + 9 files changed, 1152 insertions(+), 55 deletions(-) create mode 100644 app/features/scanner-ingest/tests/harness.ts create mode 100644 app/features/scanner-ingest/tests/ingest-scenarios.test.ts diff --git a/app/features/scanner-ingest/ScannerIngestRepository.server.test.ts b/app/features/scanner-ingest/ScannerIngestRepository.server.test.ts index 921f6f108..e437fbd74 100644 --- a/app/features/scanner-ingest/ScannerIngestRepository.server.test.ts +++ b/app/features/scanner-ingest/ScannerIngestRepository.server.test.ts @@ -384,6 +384,8 @@ function sendouqGame(map: { mapIndex: map.index, mode: map.mode, stageId: map.stageId, + winnerUserIds: [], + loserUserIds: [], winnerInGameNames: [], loserInGameNames: [], playedAt: Math.floor(PLAYED_AT / 1000), diff --git a/app/features/scanner-ingest/ScannerIngestRepository.server.ts b/app/features/scanner-ingest/ScannerIngestRepository.server.ts index b8480acd0..4a673a2c8 100644 --- a/app/features/scanner-ingest/ScannerIngestRepository.server.ts +++ b/app/features/scanner-ingest/ScannerIngestRepository.server.ts @@ -94,7 +94,12 @@ export function gamesInTournamentMatch(tournamentMatchId: number) { return tournamentGames({ tournamentMatchIds: [tournamentMatchId] }); } -/** Returns a SendouQ match's games (its whole map list), in map order. */ +/** + * Returns a SendouQ match's reported games, in map order. The match's + * pre-generated unplayed maps are left out: only a reported game is a link + * target, so a match sent before its game's report stays unlinked (and gets + * linked by a later resend instead). + */ export function gamesInGroupMatch(groupMatchId: number) { return sendouqGames({ groupMatchId }); } @@ -782,7 +787,7 @@ async function tournamentGames({ .orderBy("TournamentMatchGameResult.number", "asc") .execute(); - const inGameNamesByTeamId = await teamInGameNames( + const rostersByTeamId = await teamRosters( rows.flatMap((row) => [row.opponentOneId, row.opponentTwoId]), ); const linkedNames = await linkedPlayerNamesByTarget( @@ -798,6 +803,10 @@ async function tournamentGames({ ? row.opponentOneId : null; + const winnerRoster = rostersByTeamId.get(row.winnerTeamId); + const loserRoster = + loserTeamId !== null ? rostersByTeamId.get(loserTeamId) : undefined; + return { target: { type: "tournament", @@ -808,28 +817,33 @@ async function tournamentGames({ mapIndex: row.number - 1, mode: row.mode, stageId: row.stageId, - winnerInGameNames: inGameNamesByTeamId.get(row.winnerTeamId) ?? [], - loserInGameNames: - (loserTeamId !== null - ? inGameNamesByTeamId.get(loserTeamId) - : undefined) ?? [], + winnerUserIds: winnerRoster?.userIds ?? [], + loserUserIds: loserRoster?.userIds ?? [], + winnerInGameNames: winnerRoster?.inGameNames ?? [], + loserInGameNames: loserRoster?.inGameNames ?? [], playedAt: row.playedAt, linkedPlayerNames: linkedNames.get(row.matchGameResultId) ?? null, }; }); } -async function teamInGameNames(teamIds: Array) { +interface Roster { + userIds: number[]; + inGameNames: string[]; +} + +async function teamRosters(teamIds: Array) { const uniqueTeamIds = [ ...new Set(teamIds.filter((id): id is number => id !== null)), ]; - if (uniqueTeamIds.length === 0) return new Map(); + if (uniqueTeamIds.length === 0) return new Map(); const members = await db .selectFrom("TournamentTeamMember") .innerJoin("User", "User.id", "TournamentTeamMember.userId") .select((eb) => [ "TournamentTeamMember.tournamentTeamId", + "TournamentTeamMember.userId", eb.fn .coalesce("TournamentTeamMember.inGameName", "User.inGameName") .as("inGameName"), @@ -837,12 +851,15 @@ async function teamInGameNames(teamIds: Array) { .where("TournamentTeamMember.tournamentTeamId", "in", uniqueTeamIds) .execute(); - const result = new Map(); + const result = new Map(); for (const member of members) { - if (!member.inGameName) continue; - const names = result.get(member.tournamentTeamId) ?? []; - names.push(member.inGameName); - result.set(member.tournamentTeamId, names); + const roster = result.get(member.tournamentTeamId) ?? { + userIds: [], + inGameNames: [], + }; + roster.userIds.push(member.userId); + if (member.inGameName) roster.inGameNames.push(member.inGameName); + result.set(member.tournamentTeamId, roster); } return result; @@ -872,7 +889,9 @@ async function sendouqGames({ "GroupMatch.createdAt as playedAt", ]) .$if(groupMatchId !== undefined, (qb) => - qb.where("GroupMatchMap.matchId", "=", groupMatchId!), + qb + .where("GroupMatchMap.matchId", "=", groupMatchId!) + .where("GroupMatchMap.winnerGroupId", "is not", null), ) // joined (not EXISTS) so the planner drives off the user's own // membership index instead of scanning the whole createdAt window @@ -888,8 +907,6 @@ async function sendouqGames({ ), ), ) - // content resolution walks played games only; a current match's - // pre-generated unplayed maps would flood the candidate sequence .$if(since !== undefined, (qb) => qb .where("GroupMatch.createdAt", ">=", since!) @@ -899,7 +916,7 @@ async function sendouqGames({ .orderBy("GroupMatchMap.index", "asc") .execute(); - const inGameNamesByGroupId = await groupInGameNames( + const rostersByGroupId = await groupRosters( rows.flatMap((row) => [row.alphaGroupId, row.bravoGroupId]), ); const linkedNames = await linkedPlayerNamesByTarget( @@ -915,6 +932,13 @@ async function sendouqGames({ ? row.alphaGroupId : null; + const winnerRoster = + row.winnerGroupId !== null + ? rostersByGroupId.get(row.winnerGroupId) + : undefined; + const loserRoster = + loserGroupId !== null ? rostersByGroupId.get(loserGroupId) : undefined; + return { target: { type: "sendouq", @@ -925,37 +949,36 @@ async function sendouqGames({ mapIndex: row.mapIndex, mode: row.mode, stageId: row.stageId, - winnerInGameNames: - (row.winnerGroupId !== null - ? inGameNamesByGroupId.get(row.winnerGroupId) - : undefined) ?? [], - loserInGameNames: - (loserGroupId !== null - ? inGameNamesByGroupId.get(loserGroupId) - : undefined) ?? [], + winnerUserIds: winnerRoster?.userIds ?? [], + loserUserIds: loserRoster?.userIds ?? [], + winnerInGameNames: winnerRoster?.inGameNames ?? [], + loserInGameNames: loserRoster?.inGameNames ?? [], playedAt: row.playedAt, linkedPlayerNames: linkedNames.get(row.groupMatchMapId) ?? null, }; }); } -async function groupInGameNames(groupIds: number[]) { +async function groupRosters(groupIds: number[]) { const uniqueGroupIds = [...new Set(groupIds)]; - if (uniqueGroupIds.length === 0) return new Map(); + if (uniqueGroupIds.length === 0) return new Map(); const members = await db .selectFrom("GroupMember") .innerJoin("User", "User.id", "GroupMember.userId") - .select(["GroupMember.groupId", "User.inGameName"]) + .select(["GroupMember.groupId", "GroupMember.userId", "User.inGameName"]) .where("GroupMember.groupId", "in", uniqueGroupIds) .execute(); - const result = new Map(); + const result = new Map(); for (const member of members) { - if (!member.inGameName) continue; - const names = result.get(member.groupId) ?? []; - names.push(member.inGameName); - result.set(member.groupId, names); + const roster = result.get(member.groupId) ?? { + userIds: [], + inGameNames: [], + }; + roster.userIds.push(member.userId); + if (member.inGameName) roster.inGameNames.push(member.inGameName); + result.set(member.groupId, roster); } return result; diff --git a/app/features/scanner-ingest/actions/scanner-ingest.server.ts b/app/features/scanner-ingest/actions/scanner-ingest.server.ts index bac03c1fd..db73806c3 100644 --- a/app/features/scanner-ingest/actions/scanner-ingest.server.ts +++ b/app/features/scanner-ingest/actions/scanner-ingest.server.ts @@ -71,6 +71,7 @@ export const action: ActionFunction = async ({ request }) => { const matched = Scoreboards.matchedGames({ matches: effectiveMatches.map((effective) => effective.data), games: resolved.games, + povUserId, }); linkedGamesCount = await ScannerIngestRepository.addLinks({ @@ -228,7 +229,11 @@ async function resolveIngestContext({ } | null = null; for (const candidate of candidates) { const games = await candidate.loadGames(); - const matched = Scoreboards.matchedGames({ matches, games }).length; + const matched = Scoreboards.matchedGames({ + matches, + games, + povUserId, + }).length; if (!best || matched > best.matched) { best = { candidate, games, matched }; } @@ -260,7 +265,7 @@ async function resolveIngestContext({ }), ]) ).flat(); - const context = Scoreboards.resolveContext({ matches, games }); + const context = Scoreboards.resolveContext({ matches, games, povUserId }); if (context) { const key = Scoreboards.contextKey(context); logger.debug( diff --git a/app/features/scanner-ingest/core/Scoreboards.test.ts b/app/features/scanner-ingest/core/Scoreboards.test.ts index 3b74b81bf..68143b709 100644 --- a/app/features/scanner-ingest/core/Scoreboards.test.ts +++ b/app/features/scanner-ingest/core/Scoreboards.test.ts @@ -30,6 +30,8 @@ function testGame( mapIndex: 0, mode: "SZ", stageId: 0 as StageId, + winnerUserIds: [], + loserUserIds: [], winnerInGameNames: [], loserInGameNames: [], playedAt: 1000, @@ -386,6 +388,57 @@ describe("matchedGames", () => { expect(matched.map(tournamentMatchIdOf)).toEqual([2]); }); + test("pins the sides via the POV sender's roster, overruling contradicting names", () => { + const matched = Scoreboards.matchedGames({ + matches: [ + testMatch({ + // names read flipped, but the sender's seat is on the winning rows + names: ["l1", "l2", "l3", "l4", "w1", "w2", "w3", "w4"], + povIndex: 0, + }), + ], + games: [ + testGame({ + winnerUserIds: [77], + loserUserIds: [88], + winnerInGameNames: ["w1", "w2"], + loserInGameNames: ["l1", "l2"], + }), + ], + povUserId: 77, + }); + + expect(matched).toHaveLength(1); + }); + + test("skips a game seating the POV sender on the wrong side", () => { + const matched = Scoreboards.matchedGames({ + matches: [testMatch({ povIndex: 4 })], + games: [testGame({ winnerUserIds: [77], loserUserIds: [88] })], + // the sender won the game, yet the read has their seat on the losing rows + povUserId: 77, + }); + + expect(matched).toHaveLength(0); + }); + + test("falls back to the name check when the sender is in neither roster", () => { + const matched = Scoreboards.matchedGames({ + matches: [testMatch({ povIndex: 0 })], + games: [ + testGame({ + winnerUserIds: [77], + loserUserIds: [88], + winnerInGameNames: ["l1", "l2"], + loserInGameNames: ["w1", "w2"], + }), + ], + povUserId: 99, + }); + + expect(matched).toHaveLength(0); + }); + test("matches known in-game names ignoring discriminator, case and unicode width", () => { const matched = Scoreboards.matchedGames({ matches: [ diff --git a/app/features/scanner-ingest/core/Scoreboards.ts b/app/features/scanner-ingest/core/Scoreboards.ts index 490d94519..61329ecf4 100644 --- a/app/features/scanner-ingest/core/Scoreboards.ts +++ b/app/features/scanner-ingest/core/Scoreboards.ts @@ -53,9 +53,13 @@ export interface IngestableGame { mapIndex: number; mode: ModeShort; stageId: StageId; - /** known in-game names of the winning team's roster, used to validate scoreboard sides */ + /** user ids of the winning team's roster; the POV sender's side pins the scan's sides to the game's teams */ + winnerUserIds: number[]; + /** user ids of the losing team's roster; the POV sender's side pins the scan's sides to the game's teams */ + loserUserIds: number[]; + /** known in-game names of the winning team's roster, the side fallback for reads without a POV seat */ winnerInGameNames: string[]; - /** known in-game names of the losing team's roster, used to validate scoreboard sides */ + /** known in-game names of the losing team's roster, the side fallback for reads without a POV seat */ loserInGameNames: string[]; /** database timestamp used to order games chronologically across matches */ playedAt: number; @@ -97,9 +101,11 @@ export function contextKey(context: IngestContext): string { export function resolveContext({ matches, games, + povUserId = null, }: { matches: ScannerMatch[]; games: IngestableGameWithContext[]; + povUserId?: number | null; }): IngestContext | null { const byContext = new Map(); for (const game of games) { @@ -114,6 +120,7 @@ export function resolveContext({ const matched = matchedGames({ matches, games: contextGames, + povUserId, }).length; if (!best || matched > best.matched) { best = { context: contextGames[0]!.context, matched }; @@ -132,10 +139,13 @@ export function resolveContext({ * minimap-only match can never link — its winner and stats are unread). * Matches and games are both walked in chronological order: each match is * assigned to the next not-yet-assigned game with the same mode and stage - * whose sides don't contradict the teams' known in-game names (the winning - * rows should overlap the game winner's roster, not the loser's). Matches - * from other lobbies, with unreadable mode/stage or duplicated detections - * of the same game are skipped. + * whose sides agree with what is known. The POV seat decides where it can: + * the sender is the POV player, so which of the game's rosters they belong + * to pins the scan's sides to the game's teams — OCR'd names are too + * unreliable to overrule it. Only when no seat can decide (cast footage, no + * POV read) do the known in-game names arbitrate the sides. Matches from + * other lobbies, with unreadable mode/stage or duplicated detections of the + * same game are skipped. * * One session's matches may arrive over many requests (one per game), so * games another ingest already linked to are skipped — unless the incoming @@ -146,9 +156,12 @@ export function resolveContext({ export function matchedGames({ matches, games, + povUserId = null, }: { matches: ScannerMatch[]; games: IngestableGame[]; + /** the sender, who is the POV player of the request's non-cast matches */ + povUserId?: number | null; }): MatchedGame[] { const views = dedupeViews( matches @@ -177,8 +190,12 @@ export function matchedGames({ if (!isLinkedDuplicate(view, game.linkedPlayerNames)) { continue; } - } else if (!sidesMatchKnownPlayers(view, game)) { - continue; + } else { + const agreement = povSideAgreement(view, game, povUserId); + if (agreement === false) continue; + if (agreement === null && !sidesMatchKnownPlayers(view, game)) { + continue; + } } result.push({ matchIndex: view.matchIndex, game }); @@ -509,7 +526,27 @@ function dedupeViews(sorted: IndexedView[]): IndexedView[] { } /** - * Checks that the view's sides don't contradict the teams' known rosters: + * Whether the POV seat's side in the scan agrees with the sender's side in + * the game: the sender is the POV player, so the roster their user id sits + * in says whether their seat should be on the winning rows. Null when the + * check cannot decide — no POV seat read, no sender, or the sender in + * neither roster (cast footage) — leaving the sides to the name fallback. + */ +function povSideAgreement( + view: WinnerFirstView, + game: IngestableGame, + povUserId: number | null, +): boolean | null { + if (povUserId === null || view.povIndex === null) return null; + + const povOnWinningSide = view.povIndex < PLAYERS_PER_TEAM; + if (game.winnerUserIds.includes(povUserId)) return povOnWinningSide; + if (game.loserUserIds.includes(povUserId)) return !povOnWinningSide; + return null; +} + +/** + * The side fallback for reads no POV seat can pin (cast footage above all): * the winning rows should overlap the game winner's in-game names at least * as well as the losing team's (and vice versa). A contradiction means the * match belongs to some other game. No overlap at all (e.g. no in-game diff --git a/app/features/scanner-ingest/tests/harness.ts b/app/features/scanner-ingest/tests/harness.ts new file mode 100644 index 000000000..8db1f2122 --- /dev/null +++ b/app/features/scanner-ingest/tests/harness.ts @@ -0,0 +1,511 @@ +/** + * Everything the ingest scenario cases need: world builders (arrange), the + * ingest wrapper (act), page-loader wrappers and row fetchers (assert). + * Cases import only from here, plus vitest. See README.md for the design. + */ +import { addHours, addMinutes, subDays, subMinutes } from "date-fns"; +import * as R from "remeda"; +import { afterAll, beforeAll } from "vitest"; +import { Config } from "~/config"; +import { backdate } from "~/db/seed/core/backdate"; +import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; +import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { db } from "~/db/sql"; +import type { ScannerMatch } from "~/features/scanner/core/scanner-match"; +import { + loader as qMatchLoader, + type SendouQMatchLoaderData, +} from "~/features/sendouq-match/loaders/q.match.$id.server"; +import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server"; +import { + type TournamentMatchLoaderData, + loader as tournamentMatchLoader, +} from "~/features/tournament-match/loaders/to.$id.matches.$mid.server"; +import type { + MainWeaponId, + ModeShort, + StageId, +} from "~/modules/in-game-lists/types"; +import invariant from "~/utils/invariant"; +import { wrappedAction, wrappedLoader } from "~/utils/Test"; +import { action } from "../actions/scanner-ingest.server"; +import type { + IngestResponse, + ingestBodySchema, +} from "../scanner-ingest-schemas"; + +/** In-game names of the SendouQ world's alpha group, in member order. */ +export const ALPHA_NAMES = ["Alpha1", "Alpha2", "Alpha3", "Alpha4"]; +/** In-game names of the SendouQ world's bravo group, in member order. */ +export const BRAVO_NAMES = ["Bravo1", "Bravo2", "Bravo3", "Bravo4"]; +/** Weapons `scanned()` reads, winner rows first — row 0 is the default POV seat. */ +export const WEAPONS: MainWeaponId[] = [10, 20, 30, 40, 50, 60, 70, 80]; +/** One weapon per tournament-world player, so no two rosters read alike. */ +const TOURNAMENT_WEAPONS: MainWeaponId[] = [ + 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 0, 1, 11, 21, 22, 31, +]; + +const DISCRIMINATOR = "1111"; +const PLAYERS_PER_TEAM = 4; +/** minutes between consecutive tournament sets' backdated timestamps */ +const SET_STAGGER_MINUTES = 10; + +/** A game a `scanned()` read can be derived from, so rosters/mode/stage line up. */ +export interface ScannableGame { + mode: ModeShort; + stage: StageId; + /** chronological position among the world's games, spacing reads apart */ + order: number; + winnerNames: string[]; + loserNames: string[]; + /** + * each player's weapon, keyed by name. Worlds keep this stable per player + * so two games between different opponents never read as the same match. + * Reads fall back to `WEAPONS` by row for games built outside a world. + */ + weaponFor?: (name: string) => MainWeaponId; +} + +export interface ScannedOptions extends Partial { + /** `"loser"` flips the teams array so the read is winner-last (`winner: 1`). */ + seenFrom?: "winner" | "loser"; + /** spectator footage: no POV seat */ + cast?: boolean; + /** minimap-only read: empty rosters, winner/mode/scores unread */ + partial?: boolean; +} + +/** + * Forces the scanner gate open for the whole suite (fallback for + * `import.meta.env` not carrying the config's `test.env`), restoring the + * original value afterwards. Call at the top of the test file. + */ +export function setupScannerGate() { + let original: boolean; + beforeAll(() => { + original = Config.scannerEnabled; + Config.scannerEnabled = true; + }); + afterAll(() => { + Config.scannerEnabled = original; + }); +} + +/** Runs `fn` with the scanner gate closed, restoring it even on a throw. */ +export async function withScannerDisabled(fn: () => Promise) { + const original = Config.scannerEnabled; + Config.scannerEnabled = false; + try { + await fn(); + } finally { + Config.scannerEnabled = original; + } +} + +/** + * 8 users with deterministic in-game names (`Alpha1#1111`, …) and a SendouQ + * match between them. The match starts unreported; `conclude()` plays it out + * the way the teams report it (alpha sweeps), making its maps linkable, and + * returns the refreshed map rows. + */ +export async function sendouqWorld(options: { createdAt?: Date } = {}) { + const users = await createNamedUsers([...ALPHA_NAMES, ...BRAVO_NAMES]); + const alphaUsers = users.slice(0, PLAYERS_PER_TEAM); + const bravoUsers = users.slice(PLAYERS_PER_TEAM); + + const match = await SQMatchFactory.create( + { + alphaUserIds: alphaUsers.map((user) => user.id), + bravoUserIds: bravoUsers.map((user) => user.id), + }, + options.createdAt ? { createdAt: options.createdAt } : undefined, + ); + const maps = await groupMatchMaps(match.id); + + return { + match, + maps, + alphaUsers, + bravoUsers, + povUser: alphaUsers[0]!, + conclude: async () => { + await concludeGroupMatch(match.id); + return groupMatchMaps(match.id); + }, + scanned: ( + map: { mode: ModeShort; stageId: StageId; index: number }, + options?: ScannedOptions, + ) => + scannedGame( + { + mode: map.mode, + stage: map.stageId, + order: map.index, + winnerNames: ALPHA_NAMES, + loserNames: BRAVO_NAMES, + }, + options, + ), + }; +} + +/** + * A played single-elimination tournament: `teams` rosters of 4 users, all + * with deterministic in-game names (`T1P1#1111`, …), played out through + * `playedOut` the way `TournamentFactory.createPlayed` plays brackets. + * Match/game timestamps are staggered into the recent past in play order, so + * activity resolution and chronological walks see an unambiguous timeline. + * Creating a world clears the tournament data caches: SQLite ids restart + * after the db wipe, so a previous test's entries could otherwise serve + * stale data for a reused id. + */ +export async function tournamentWorld( + options: { teams?: number; playedOut?: number } = {}, +) { + clearAllTournamentDataCache(); + + const teamCount = options.teams ?? 4; + const author = await UserFactory.create(); + const names = R.range(0, teamCount).flatMap((teamIdx) => + R.range(0, PLAYERS_PER_TEAM).map( + (playerIdx) => `T${teamIdx + 1}P${playerIdx + 1}`, + ), + ); + const users = await createNamedUsers(names); + const teamRosters = R.chunk(users, PLAYERS_PER_TEAM).map((roster) => + roster.map((user) => user.id), + ); + + const tournament = await TournamentFactory.createPlayed( + { authorId: author.id }, + { teamRosters, playedOut: options.playedOut ?? 0 }, + ); + await staggerTournamentTimeline(tournament.matches); + clearAllTournamentDataCache(); + + const nameByUserId = new Map( + users.map((user, index) => [user.id, names[index]!]), + ); + const namesByTeamId = new Map( + tournament.teams.map((team) => [ + team.id, + team.memberUserIds.map((userId) => nameByUserId.get(userId)!), + ]), + ); + const matches = tournament.matches; + const championTeamId = matches.at(-1)!.winnerTeamId; + const championTeam = tournament.teams.find( + (team) => team.id === championTeamId, + )!; + + return { + tournamentId: tournament.id, + teams: tournament.teams, + matches, + author, + championTeamId, + povUser: users.find((user) => user.id === championTeam.memberUserIds[0])!, + matchesOfTeam: (teamId: number) => + matches.filter( + (match) => + match.winnerTeamId === teamId || match.loserTeamId === teamId, + ), + games: async (matchId: number): Promise => { + const played = matches.find((match) => match.id === matchId); + invariant(played, `Match ${matchId} is not part of the world`); + const matchIdx = matches.indexOf(played); + + const rows = await db + .selectFrom("TournamentMatchGameResult") + .select(["number", "mode", "stageId", "winnerTeamId"]) + .where("matchId", "=", matchId) + .orderBy("number", "asc") + .execute(); + + return rows.map((row) => ({ + mode: row.mode, + stage: row.stageId, + order: matchIdx * SET_STAGGER_MINUTES + row.number, + winnerNames: namesByTeamId.get(row.winnerTeamId)!, + loserNames: namesByTeamId.get( + row.winnerTeamId === played.winnerTeamId + ? played.loserTeamId + : played.winnerTeamId, + )!, + weaponFor: (name: string) => TOURNAMENT_WEAPONS[names.indexOf(name)]!, + })); + }, + scanned: scannedGame, + cast: (matchId: number) => + TournamentFactory.castMatch({ + tournamentId: tournament.id, + matchId, + twitchAccount: "testcaster", + }), + staff: (user: { id: number }) => + TournamentRepository.setStaff({ + tournamentId: tournament.id, + staff: [{ userId: user.id, role: "STREAMER" }], + }), + }; +} + +/** A user outside any world, optionally with an in-game name set. */ +export function createUser(inGameName?: string) { + return UserFactory.create({ + profile: inGameName ? { inGameName } : null, + }); +} + +/** + * Derives a full `ScannerMatch` from a sendou.ink game so mode/stage/rosters + * line up by construction; options spell out a case's deviation. The default + * read is winner-first with the POV on seat 0 of the winning team, played + * "now" (offset by `order` so multi-read requests stay chronological). + */ +export function scannedGame( + game: ScannableGame, + options: ScannedOptions = {}, +): ScannerMatch { + const { + seenFrom = "winner", + cast = false, + partial = false, + ...overrides + } = options; + + const winners = scannedTeam(game.winnerNames, 0, game.weaponFor); + const losers = scannedTeam(game.loserNames, PLAYERS_PER_TEAM, game.weaponFor); + const startsAt = 60 + game.order * 360; + + const base: ScannerMatch = { + startsAt, + endsAt: startsAt + 300, + playedAt: Date.now() + game.order * 60_000, + lobby: "PRIVATE", + mode: game.mode, + stage: game.stage, + matchScores: seenFrom === "loser" ? [48, 100] : [100, 48], + replayCode: null, + cast, + objective: null, + playerStatus: null, + teams: seenFrom === "loser" ? [losers, winners] : [winners, losers], + winner: seenFrom === "loser" ? 1 : 0, + pov: cast ? null : { team: 0, index: 0 }, + }; + + if (partial) { + return { + ...base, + mode: null, + matchScores: null, + teams: [{ players: [] }, { players: [] }], + winner: null, + pov: null, + ...overrides, + }; + } + + return { ...base, ...overrides }; +} + +/** A copy of the match with every read player name passed through `rename`. */ +export function renamed( + match: ScannerMatch, + rename: (name: string, rowIndex: number) => string, +): ScannerMatch { + return { + ...match, + teams: [renamedTeam(match, 0, rename), renamedTeam(match, 1, rename)], + }; +} + +const ingestAction = wrappedAction({ + action, + isJsonSubmission: true, +}); + +/** Sends matches through the real ingest action, authenticated as `user`. */ +export function ingest( + user: { id: number }, + matches: ScannerMatch[], +): Promise { + return ingestAction({ matches }, { user: user.id }); +} + +const qMatchLoaderWrapped = wrappedLoader({ + loader: qMatchLoader, +}); +const tournamentMatchLoaderWrapped = wrappedLoader({ + loader: tournamentMatchLoader, +}); + +/** The real `/q/match/:id` loader's data, as an anonymous visitor sees it. */ +export function qMatchPage(matchId: number) { + return qMatchLoaderWrapped({ params: { id: String(matchId) } }); +} + +/** The real `/to/:id/matches/:mid` loader's data, as an anonymous visitor sees it. */ +export function tournamentMatchPage(tournamentId: number, matchId: number) { + return tournamentMatchLoaderWrapped({ + params: { id: String(tournamentId), mid: String(matchId) }, + }); +} + +export function fetchIngestedMatches() { + return db + .selectFrom("IngestedMatch") + .selectAll() + .orderBy("id", "asc") + .execute(); +} + +export function fetchLinks() { + return db + .selectFrom("IngestedMatchLink") + .selectAll() + .orderBy("id", "asc") + .execute(); +} + +export function fetchReportedWeapons() { + return db.selectFrom("ReportedWeapon").selectAll().execute(); +} + +export function daysAgo(days: number) { + return subDays(new Date(), days); +} + +export function hoursLater(date: Date, hours: number) { + return addHours(date, hours); +} + +async function createNamedUsers(names: string[]) { + const users: Array<{ id: number }> = []; + for (const name of names) { + users.push( + await UserFactory.create({ + profile: { inGameName: `${name}#${DISCRIMINATOR}` }, + }), + ); + } + return users; +} + +function groupMatchMaps(matchId: number) { + return db + .selectFrom("GroupMatchMap") + .selectAll() + .where("matchId", "=", matchId) + .orderBy("index", "asc") + .execute(); +} + +/** Plays the match out, alpha winning every map, both teams agreeing on the score. */ +async function concludeGroupMatch(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; + + 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: match.groupBravo.members[0]!.id, + reportedCount: reportedCount + 1, + }); + invariant( + confirmation.status === "MATCH_FINALIZED", + `Confirming the score resulted in ${confirmation.status}`, + ); +} + +/** + * Backdates match `startedAt`s and game result `createdAt`s into the recent + * past, staggered in play order: everything production stamps within the + * same test second becomes an unambiguous timeline, so "latest match" and + * game order come out the same on every run. + */ +async function staggerTournamentTimeline( + matches: Array<{ id: number }>, +): Promise { + const now = new Date(); + + for (const [matchIdx, match] of matches.entries()) { + const startedAt = subMinutes( + now, + (matches.length - matchIdx) * SET_STAGGER_MINUTES, + ); + await backdate("TournamentMatch", match.id, { startedAt }); + + const games = await db + .selectFrom("TournamentMatchGameResult") + .select(["id", "number"]) + .where("matchId", "=", match.id) + .execute(); + for (const game of games) { + await backdate("TournamentMatchGameResult", game.id, { + createdAt: addMinutes(startedAt, game.number), + }); + } + } +} + +function scannedTeam( + names: string[], + weaponOffset: number, + weaponFor?: (name: string) => MainWeaponId, +) { + return { + players: names.map((name, index) => ({ + name, + weaponId: weaponFor?.(name) ?? WEAPONS[weaponOffset + index]!, + paint: 1000 + (weaponOffset + index) * 100, + ka: 20 - (weaponOffset + index), + d: weaponOffset + index, + s: 8 - (weaponOffset + index), + })), + }; +} + +function renamedTeam( + match: ScannerMatch, + teamIndex: 0 | 1, + rename: (name: string, rowIndex: number) => string, +) { + return { + players: match.teams[teamIndex].players.map((player, playerIndex) => ({ + ...player, + name: + player.name === null + ? null + : rename(player.name, teamIndex * PLAYERS_PER_TEAM + playerIndex), + })), + }; +} diff --git a/app/features/scanner-ingest/tests/ingest-scenarios.test.ts b/app/features/scanner-ingest/tests/ingest-scenarios.test.ts new file mode 100644 index 000000000..dfbfacbb7 --- /dev/null +++ b/app/features/scanner-ingest/tests/ingest-scenarios.test.ts @@ -0,0 +1,453 @@ +import { describe, expect, test } from "vitest"; +import { + ALPHA_NAMES, + BRAVO_NAMES, + createUser, + daysAgo, + fetchIngestedMatches, + fetchLinks, + fetchReportedWeapons, + hoursLater, + ingest, + qMatchPage, + renamed, + scannedGame, + sendouqWorld, + setupScannerGate, + tournamentMatchPage, + tournamentWorld, + WEAPONS, + withScannerDisabled, +} from "./harness"; + +setupScannerGate(); + +describe("gating & request filtering", () => { + test("G1 gate closed: scanner disabled and non-privileged user → 403, nothing stored", async () => { + const w = await sendouqWorld(); + + await withScannerDisabled(async () => { + await expect( + ingest(w.bravoUsers[1]!, [w.scanned(w.maps[0]!)]), + ).rejects.toThrow("403"); + }); + + expect(await fetchIngestedMatches()).toHaveLength(0); + }); + + test("G2 non-private lobby: only X-battle matches in the request → skipped entirely", async () => { + const w = await sendouqWorld(); + await w.conclude(); + + const res = await ingest(w.povUser, [ + w.scanned(w.maps[0]!, { lobby: "X" }), + ]); + + expect(res).toEqual({ + storedMatchesCount: 0, + mergedMatchesCount: 0, + linkedGamesCount: 0, + linkedMatches: [], + contextResolved: false, + }); + expect(await fetchIngestedMatches()).toHaveLength(0); + }); + + test("G3 mixed request keeps indices: only the private match is stored and linked", async () => { + const w = await sendouqWorld(); + await w.conclude(); + + const res = await ingest(w.povUser, [ + w.scanned(w.maps[1]!, { lobby: "X" }), + w.scanned(w.maps[0]!), + ]); + + expect(res.storedMatchesCount).toBe(1); + expect(res.linkedMatches).toEqual([ + { matchIndex: 1, link: { type: "sendouq", groupMatchId: w.match.id } }, + ]); + const rows = await fetchIngestedMatches(); + expect(rows).toHaveLength(1); + expect(rows[0]!.data.lobby).toBe("PRIVATE"); + }); +}); + +describe("SendouQ flow", () => { + test("Q1 live send, map already reported: stored + linked, page shows the scoreboard", async () => { + const w = await sendouqWorld(); + await w.conclude(); + const scan = w.scanned(w.maps[0]!); + + const res = await ingest(w.povUser, [scan]); + expect(res.storedMatchesCount).toBe(1); + expect(res.linkedGamesCount).toBe(1); + expect(res.linkedMatches).toEqual([ + { matchIndex: 0, link: { type: "sendouq", groupMatchId: w.match.id } }, + ]); + + await ingest(w.povUser, [scan]); + + const page = await qMatchPage(w.match.id); + expect(page.ingestedScoreboards).toHaveLength(1); + const scoreboard = page.ingestedScoreboards[0]!; + expect(scoreboard.mapIndex).toBe(0); + expect(scoreboard.data.players.map((p) => p.name)).toEqual([ + ...ALPHA_NAMES, + ...BRAVO_NAMES, + ]); + expect(scoreboard.data.players.map((p) => p.weaponSplId)).toEqual(WEAPONS); + expect(scoreboard.data.players[0]).toMatchObject({ + paint: 1000, + ka: 20, + d: 0, + s: 8, + }); + expect(page.reportedWeapons).toEqual([ + { + groupMatchId: w.match.id, + mapIndex: 0, + userId: w.povUser.id, + weaponSplId: WEAPONS[0], + }, + ]); + }); + + test("Q2 send before report, resend after: hint first, link on the resend", async () => { + const w = await sendouqWorld(); + const scan = w.scanned(w.maps[0]!); + + const first = await ingest(w.povUser, [scan]); + expect(first.storedMatchesCount).toBe(1); + expect(first.contextResolved).toBe(true); + expect(first.linkedGamesCount).toBe(0); + expect(first.linkedMatches).toEqual([]); + expect((await fetchIngestedMatches())[0]!.groupMatchIdHint).toBe( + w.match.id, + ); + expect((await qMatchPage(w.match.id)).ingestedScoreboards).toHaveLength(0); + + await w.conclude(); + const resend = await ingest(w.povUser, [scan]); + expect(resend.storedMatchesCount).toBe(0); + expect(resend.linkedGamesCount).toBe(1); + expect(resend.linkedMatches).toEqual([ + { matchIndex: 0, link: { type: "sendouq", groupMatchId: w.match.id } }, + ]); + expect(await fetchIngestedMatches()).toHaveLength(1); + expect((await qMatchPage(w.match.id)).ingestedScoreboards).toHaveLength(1); + }); + + test("Q3 whole-scan of a set: each played map links in order, unplayed maps stay bare", async () => { + const w = await sendouqWorld(); + const maps = await w.conclude(); + const playedMaps = maps.filter((map) => map.winnerGroupId !== null); + expect(playedMaps).toHaveLength(4); + + const res = await ingest( + w.povUser, + playedMaps.map((map) => w.scanned(map)), + ); + + expect(res.linkedGamesCount).toBe(4); + expect(res.linkedMatches.map((linked) => linked.matchIndex)).toEqual([ + 0, 1, 2, 3, + ]); + expect((await fetchLinks()).map((link) => link.groupMatchMapId)).toEqual( + playedMaps.map((map) => map.id), + ); + const page = await qMatchPage(w.match.id); + expect(page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([ + 0, 1, 2, 3, + ]); + }); + + test("Q4 POV on the losing side: flipped teams still link and derive winner-first", async () => { + const w = await sendouqWorld(); + await w.conclude(); + + const res = await ingest(w.bravoUsers[0]!, [ + w.scanned(w.maps[0]!, { seenFrom: "loser" }), + ]); + expect(res.linkedGamesCount).toBe(1); + + const page = await qMatchPage(w.match.id); + const scoreboard = page.ingestedScoreboards[0]!; + expect(scoreboard.data.scores).toEqual([100, 48]); + expect(scoreboard.data.players.map((p) => p.name)).toEqual([ + ...ALPHA_NAMES, + ...BRAVO_NAMES, + ]); + expect(scoreboard.data.players.map((p) => p.tournamentTeamId)).toEqual([ + ...Array(4).fill(w.match.alphaGroup.id), + ...Array(4).fill(w.match.bravoGroup.id), + ]); + expect(page.reportedWeapons).toEqual([ + { + groupMatchId: w.match.id, + mapIndex: 0, + userId: w.bravoUsers[0]!.id, + weaponSplId: WEAPONS[4], + }, + ]); + }); + + test("Q5 name normalization: a POV-less read links via case/width/discriminator-insensitive names", async () => { + const w = await sendouqWorld(); + await w.conclude(); + + const scan = renamed( + w.scanned(w.maps[0]!, { pov: null }), + (name) => `${toFullWidth(name.toUpperCase())}#9999`, + ); + const res = await ingest(w.povUser, [scan]); + + expect(res.linkedGamesCount).toBe(1); + expect(res.linkedMatches).toEqual([ + { matchIndex: 0, link: { type: "sendouq", groupMatchId: w.match.id } }, + ]); + expect(await fetchReportedWeapons()).toHaveLength(0); + }); + + test("Q6 POV side contradiction: seating the sender on the wrong side blocks the link", async () => { + const w = await sendouqWorld(); + await w.conclude(); + + // bravo lost every map, yet the read claims the sender's seat on the winning rows + const res = await ingest(w.bravoUsers[0]!, [w.scanned(w.maps[0]!)]); + + expect(res.storedMatchesCount).toBe(1); + expect(res.contextResolved).toBe(true); + expect(res.linkedGamesCount).toBe(0); + expect(res.linkedMatches).toEqual([]); + expect((await fetchIngestedMatches())[0]!.groupMatchIdHint).toBe( + w.match.id, + ); + expect((await qMatchPage(w.match.id)).ingestedScoreboards).toHaveLength(0); + }); + + test("Q8 unreliable names: garbage names still link through the sender's POV seat", async () => { + const w = await sendouqWorld(); + await w.conclude(); + + const scan = renamed( + w.scanned(w.maps[0]!), + (_, rowIndex) => `???${rowIndex + 1}`, + ); + const res = await ingest(w.povUser, [scan]); + + expect(res.linkedGamesCount).toBe(1); + expect(res.linkedMatches).toEqual([ + { matchIndex: 0, link: { type: "sendouq", groupMatchId: w.match.id } }, + ]); + const page = await qMatchPage(w.match.id); + const scoreboard = page.ingestedScoreboards[0]!; + expect(scoreboard.data.players.map((p) => p.tournamentTeamId)).toEqual([ + ...Array(4).fill(w.match.alphaGroup.id), + ...Array(4).fill(w.match.bravoGroup.id), + ]); + expect(scoreboard.data.players[0]!.userId).toBe(w.povUser.id); + expect(page.reportedWeapons).toEqual([ + { + groupMatchId: w.match.id, + mapIndex: 0, + userId: w.povUser.id, + weaponSplId: WEAPONS[0], + }, + ]); + }); + + test("Q7 content-based fallback: old match resolves from mode+stage history", async () => { + const createdAt = daysAgo(10); + const w = await sendouqWorld({ createdAt }); + await w.conclude(); + const playedAt = hoursLater(createdAt, 3).getTime(); + + const res = await ingest(w.povUser, [ + w.scanned(w.maps[0]!, { playedAt }), + w.scanned(w.maps[1]!, { playedAt: playedAt + 5 * 60 * 1000 }), + ]); + + expect(res.contextResolved).toBe(true); + expect(res.linkedGamesCount).toBe(2); + expect(res.linkedMatches).toEqual([ + { matchIndex: 0, link: { type: "sendouq", groupMatchId: w.match.id } }, + { matchIndex: 1, link: { type: "sendouq", groupMatchId: w.match.id } }, + ]); + const page = await qMatchPage(w.match.id); + expect(page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([0, 1]); + }); +}); + +describe("tournament flow", () => { + test("T1 live send links inside the current set only", async () => { + const w = await tournamentWorld(); + const sets = w.matchesOfTeam(w.championTeamId); + const round1 = sets[0]!; + const round2 = sets.at(-1)!; + const [game1] = await w.games(round2.id); + + const res = await ingest(w.povUser, [w.scanned(game1!)]); + + expect(res.linkedMatches).toEqual([ + { + matchIndex: 0, + link: { + type: "tournament", + tournamentId: w.tournamentId, + matchId: round2.id, + }, + }, + ]); + const round2Page = await tournamentMatchPage(w.tournamentId, round2.id); + expect(round2Page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([ + 0, + ]); + const round1Page = await tournamentMatchPage(w.tournamentId, round1.id); + expect(round1Page.ingestedScoreboards).toHaveLength(0); + }); + + test("T2 VoD scan spanning two sets links each read into its own set", async () => { + const w = await tournamentWorld(); + const [set1, set2] = w.matchesOfTeam(w.championTeamId); + const games = [...(await w.games(set1!.id)), ...(await w.games(set2!.id))]; + + const res = await ingest( + w.povUser, + games.map((game) => w.scanned(game, { playedAt: null })), + ); + + expect(res.linkedGamesCount).toBe(4); + expect(res.linkedMatches).toEqual( + [set1, set1, set2, set2].map((set, matchIndex) => ({ + matchIndex, + link: { + type: "tournament", + tournamentId: w.tournamentId, + matchId: set!.id, + }, + })), + ); + const set1Page = await tournamentMatchPage(w.tournamentId, set1!.id); + expect(set1Page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([ + 0, 1, + ]); + const set2Page = await tournamentMatchPage(w.tournamentId, set2!.id); + expect(set2Page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([ + 0, 1, + ]); + }); + + test("T3 partial then fuller resend: the replay read merges into the stored partial and links", async () => { + const w = await tournamentWorld(); + const finalMatch = w.matchesOfTeam(w.championTeamId).at(-1)!; + const [game1] = await w.games(finalMatch.id); + const partial = w.scanned(game1!, { partial: true }); + + const first = await ingest(w.povUser, [partial]); + expect(first.storedMatchesCount).toBe(1); + expect(first.contextResolved).toBe(true); + expect(first.linkedGamesCount).toBe(0); + + const full = w.scanned(game1!, { playedAt: partial.playedAt! + 60_000 }); + const second = await ingest(w.povUser, [full]); + + expect(second.storedMatchesCount).toBe(0); + expect(second.mergedMatchesCount).toBe(1); + expect(second.linkedGamesCount).toBe(1); + expect(await fetchIngestedMatches()).toHaveLength(1); + const page = await tournamentMatchPage(w.tournamentId, finalMatch.id); + expect(page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([0]); + }); + + test("T4 cast footage by staff links to the casted set without POV weapons", async () => { + const w = await tournamentWorld(); + const finalMatch = w.matches.at(-1)!; + await w.cast(finalMatch.id); + const caster = await createUser(); + await w.staff(caster); + const games = await w.games(finalMatch.id); + + const res = await ingest( + caster, + games.map((game) => w.scanned(game, { cast: true })), + ); + + expect(res.contextResolved).toBe(true); + expect(res.linkedGamesCount).toBe(2); + expect(res.linkedMatches).toEqual( + games.map((_, matchIndex) => ({ + matchIndex, + link: { + type: "tournament", + tournamentId: w.tournamentId, + matchId: finalMatch.id, + }, + })), + ); + expect(await fetchReportedWeapons()).toHaveLength(0); + const page = await tournamentMatchPage(w.tournamentId, finalMatch.id); + expect(page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([0, 1]); + }); +}); + +describe("response contract & idempotency", () => { + test("R1 no context: a scrim between unknown players is stored without hints or links", async () => { + const user = await createUser("Solo1#1111"); + + const res = await ingest(user, [ + scannedGame({ + mode: "SZ", + stage: 1, + order: 0, + winnerNames: ["Win1", "Win2", "Win3", "Win4"], + loserNames: ["Lose1", "Lose2", "Lose3", "Lose4"], + }), + ]); + + expect(res).toEqual({ + storedMatchesCount: 1, + mergedMatchesCount: 0, + linkedGamesCount: 0, + linkedMatches: [], + contextResolved: false, + }); + const rows = await fetchIngestedMatches(); + expect(rows).toHaveLength(1); + expect(rows[0]!.tournamentIdHint).toBeNull(); + expect(rows[0]!.groupMatchIdHint).toBeNull(); + }); + + test("R2 double-send is idempotent but still reports where each match belongs", async () => { + const w = await sendouqWorld(); + await w.conclude(); + const scan = w.scanned(w.maps[0]!); + await ingest(w.povUser, [scan]); + + const resend = await ingest(w.povUser, [scan]); + + expect(resend).toEqual({ + storedMatchesCount: 0, + mergedMatchesCount: 0, + linkedGamesCount: 0, + linkedMatches: [ + { matchIndex: 0, link: { type: "sendouq", groupMatchId: w.match.id } }, + ], + contextResolved: true, + }); + expect(await fetchLinks()).toHaveLength(1); + const page = await qMatchPage(w.match.id); + expect(page.ingestedScoreboards).toHaveLength(1); + expect(page.reportedWeapons).toHaveLength(1); + }); +}); + +function toFullWidth(name: string) { + return [...name] + .map((character) => { + const codePoint = character.codePointAt(0)!; + return codePoint >= 0x21 && codePoint <= 0x7e + ? String.fromCodePoint(codePoint + 0xfee0) + : character; + }) + .join(""); +} diff --git a/app/utils/Test.ts b/app/utils/Test.ts index 244d3e7c1..de8593adf 100644 --- a/app/utils/Test.ts +++ b/app/utils/Test.ts @@ -16,6 +16,13 @@ import { } from "~/features/auth/core/user-context.server"; import { logger } from "./logger"; +/** + * The user a wrapped action/loader call runs as: one of the pinned seed users, + * or any user's id — scenario tests' users are participants and staff the test + * itself created, not fixed ids. + */ +export type TestUser = "admin" | "regular" | number; + export function arrayContainsSameItems(arr1: T[], arr2: T[]) { return ( arr1.length === arr2.length && arr1.every((item) => arr2.includes(item)) @@ -63,10 +70,7 @@ export function wrappedAction({ }) { return async ( args: z.infer, - { - user, - params = {}, - }: { user?: "admin" | "regular"; params?: Params } = {}, + { user, params = {} }: { user?: TestUser; params?: Params } = {}, ) => { const body = isJsonSubmission ? JSON.stringify(args) @@ -127,7 +131,7 @@ export function wrappedLoader({ user, params = {}, }: { - user?: "admin" | "regular"; + user?: TestUser; params?: Params; } = {}) => { const request = new Request("http://app.com/path", { @@ -182,14 +186,18 @@ export function assertResponseErrored(response: Response, message?: string) { } } -async function authHeader( - user?: "admin" | "regular", -): Promise<[string, string][]> { - if (!user) return []; +async function authHeader(user?: TestUser): Promise<[string, string][]> { + if (user === undefined) return []; const session = await authSessionStorage.getSession(); - session.set(SESSION_KEY, user === "admin" ? ADMIN_ID : REGULAR_USER_TEST_ID); + session.set(SESSION_KEY, testUserId(user)); return [["Cookie", await authSessionStorage.commitSession(session)]]; } + +function testUserId(user: Exclude): number { + if (typeof user === "number") return user; + + return user === "admin" ? ADMIN_ID : REGULAR_USER_TEST_ID; +} diff --git a/vitest.unit.config.ts b/vitest.unit.config.ts index 07f3c020b..75a5e0ba7 100644 --- a/vitest.unit.config.ts +++ b/vitest.unit.config.ts @@ -14,6 +14,11 @@ export default defineConfig({ "app/features/scanner/tests/*.test.{ts,tsx}", ], setupFiles: ["./app/test-setup.ts"], + // the scanner-ingest scenario suite exercises the real ingest action, + // whose gate reads Config.scannerEnabled from this variable + env: { + VITE_SCANNER_ENABLED: "true", + }, }, resolve: { tsconfigPaths: true,