From 22bcb19df4b1c5004bb9766ad0171e220ac71761 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:38:43 +0300 Subject: [PATCH] Fix tournament casts appearing for every round vod --- app/routines/syncTournamentVods.test.ts | 295 ++++++++++++++++++++++++ app/routines/syncTournamentVods.ts | 15 +- db-test.sqlite3 | Bin 1232896 -> 1232896 bytes 3 files changed, 303 insertions(+), 7 deletions(-) create mode 100644 app/routines/syncTournamentVods.test.ts diff --git a/app/routines/syncTournamentVods.test.ts b/app/routines/syncTournamentVods.test.ts new file mode 100644 index 000000000..5f35febfb --- /dev/null +++ b/app/routines/syncTournamentVods.test.ts @@ -0,0 +1,295 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { db, sql } from "~/db/sql"; +import type { CastedMatchesInfo } from "~/db/tables"; +import { dbInsertUsers, dbReset } from "~/utils/Test"; + +const { mockGetUsersByLogin, mockGetArchiveVideos } = vi.hoisted(() => ({ + mockGetUsersByLogin: vi.fn(), + mockGetArchiveVideos: vi.fn(), +})); + +vi.mock("~/modules/twitch/vods", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getUsersByLogin: mockGetUsersByLogin, + getArchiveVideos: mockGetArchiveVideos, + }; +}); + +vi.mock("~/modules/twitch/utils", () => ({ + hasTwitchEnvVars: () => true, +})); + +const TOURNAMENT_ID = 1; +const MATCH_START_SECONDS = 1700000000; + +describe("syncTournamentVods", () => { + beforeEach(async () => { + dbReset(); + mockGetUsersByLogin.mockReset(); + mockGetArchiveVideos.mockReset(); + await dbInsertUsers(5); + }); + + afterEach(() => { + dbReset(); + }); + + test("player streamer gets VODs only for matches they participated in", async () => { + await seedTournamentWithMatches(); + await seedStreamer("player_stream", 1); + await seedTournamentTeamAndGameResult(1, [1, 2]); + await seedTournamentTeamAndGameResult(2, [3, 4]); + + mockGetUsersByLogin.mockResolvedValue([ + { id: "twitch-1", login: "player_stream" }, + ]); + mockGetArchiveVideos.mockResolvedValue([twitchVideo()]); + + await runProcessOneTournament(); + + const vods = await findAllVods(); + expect(vods).toHaveLength(1); + expect(vods[0].matchId).toBe(1); + expect(vods[0].userId).toBe(1); + expect(vods[0].account).toBe("player_stream"); + }); + + test("cast account in TournamentStreamer without castedMatchHistory does NOT produce VODs", async () => { + await seedTournamentWithMatches(); + await seedStreamer("caster_stream", null); + + mockGetUsersByLogin.mockResolvedValue([ + { id: "twitch-c", login: "caster_stream" }, + ]); + mockGetArchiveVideos.mockResolvedValue([twitchVideo()]); + + await runProcessOneTournament(); + + const vods = await findAllVods(); + expect(vods).toHaveLength(0); + }); + + test("cast account with castedMatchHistory produces VODs for casted matches only", async () => { + await seedTournamentWithMatches({ + castedMatchesInfo: { + lockedMatches: [], + castedMatches: [], + castedMatchHistory: [ + { + twitchAccount: "caster_stream", + matchId: 2, + timestamp: MATCH_START_SECONDS + 1800, + }, + ], + }, + }); + + mockGetUsersByLogin.mockResolvedValue([ + { id: "twitch-c", login: "caster_stream" }, + ]); + mockGetArchiveVideos.mockResolvedValue([twitchVideo()]); + + await runProcessOneTournament(); + + const vods = await findAllVods(); + expect(vods).toHaveLength(1); + expect(vods[0].matchId).toBe(2); + expect(vods[0].userId).toBeNull(); + expect(vods[0].account).toBe("caster_stream"); + }); + + test("player VOD is preferred over cast VOD for same match and account", async () => { + await seedTournamentWithMatches({ + castedMatchesInfo: { + lockedMatches: [], + castedMatches: [], + castedMatchHistory: [ + { + twitchAccount: "dual_stream", + matchId: 1, + timestamp: MATCH_START_SECONDS, + }, + ], + }, + }); + await seedStreamer("dual_stream", 1); + await seedTournamentTeamAndGameResult(1, [1, 2]); + + mockGetUsersByLogin.mockResolvedValue([ + { id: "twitch-d", login: "dual_stream" }, + ]); + mockGetArchiveVideos.mockResolvedValue([twitchVideo()]); + + await runProcessOneTournament(); + + const vods = await findAllVods(); + expect(vods).toHaveLength(1); + expect(vods[0].userId).toBe(1); + }); + + test("no VODs inserted when no Twitch videos match", async () => { + await seedTournamentWithMatches({ + castedMatchesInfo: { + lockedMatches: [], + castedMatches: [], + castedMatchHistory: [ + { + twitchAccount: "caster_stream", + matchId: 1, + timestamp: MATCH_START_SECONDS, + }, + ], + }, + }); + + mockGetUsersByLogin.mockResolvedValue([ + { id: "twitch-c", login: "caster_stream" }, + ]); + // video ends well before match started + mockGetArchiveVideos.mockResolvedValue([ + twitchVideo({ + createdAt: new Date((MATCH_START_SECONDS - 50000) * 1000).toISOString(), + duration: "1h0m0s", + }), + ]); + + await runProcessOneTournament(); + + const vods = await findAllVods(); + expect(vods).toHaveLength(0); + }); + + test("no matches with startedAt results in no processing", async () => { + await seedTournamentWithMatches(); + + // clear startedAt on all matches + await db.updateTable("TournamentMatch").set({ startedAt: null }).execute(); + + await runProcessOneTournament(); + + const vods = await findAllVods(); + expect(vods).toHaveLength(0); + expect(mockGetUsersByLogin).not.toHaveBeenCalled(); + }); +}); + +function twitchVideo({ + id = "video1", + createdAt = new Date((MATCH_START_SECONDS - 600) * 1000).toISOString(), + duration = "2h0m0s", + viewCount = 100, +} = {}) { + return { + id, + created_at: createdAt, + duration, + view_count: viewCount, + }; +} + +async function seedTournamentWithMatches({ + castedMatchesInfo, +}: { + castedMatchesInfo?: CastedMatchesInfo; +} = {}) { + sql + .prepare( + /*sql*/ `insert into "Tournament" ("id", "mapPickingStyle", "settings", "isFinalized", "castedMatchesInfo") values (?, 'AUTO_SZ', ?, 1, ?)`, + ) + .run( + TOURNAMENT_ID, + JSON.stringify({ + bracketProgression: [ + { type: "double_elimination", name: "Main Bracket" }, + ], + }), + castedMatchesInfo ? JSON.stringify(castedMatchesInfo) : null, + ); + + sql + .prepare( + /*sql*/ `insert into "TournamentStage" ("id", "tournamentId", "name", "type", "settings", "number") values (1, ?, 'Main Bracket', 'double_elimination', '{}', 0)`, + ) + .run(TOURNAMENT_ID); + + sql + .prepare( + /*sql*/ `insert into "TournamentGroup" ("id", "stageId", "number") values (1, 1, 1)`, + ) + .run(); + + sql + .prepare( + /*sql*/ `insert into "TournamentRound" ("id", "stageId", "groupId", "number") values (1, 1, 1, 1)`, + ) + .run(); + + const insertTournamentMatchStm = sql.prepare( + /*sql*/ `insert into "TournamentMatch" ("id", "roundId", "stageId", "groupId", "number", "opponentOne", "opponentTwo", "status", "startedAt") values (?, 1, 1, 1, ?, ?, ?, 4, ?)`, + ); + + insertTournamentMatchStm.run( + 1, + 1, + JSON.stringify({ id: 1 }), + JSON.stringify({ id: 2 }), + MATCH_START_SECONDS, + ); + + insertTournamentMatchStm.run( + 2, + 2, + JSON.stringify({ id: 3 }), + JSON.stringify({ id: 4 }), + MATCH_START_SECONDS + 1800, + ); +} + +async function seedTournamentTeamAndGameResult( + matchId: number, + participantUserIds: number[], +) { + const teamId = matchId * 100; + + sql + .prepare( + /*sql*/ `insert or ignore into "TournamentTeam" ("id", "name", "tournamentId", "inviteCode") values (?, ?, ?, ?)`, + ) + .run(teamId, `Team ${teamId}`, TOURNAMENT_ID, `code-${teamId}`); + + const { lastInsertRowid: gameResultId } = sql + .prepare( + /*sql*/ `insert into "TournamentMatchGameResult" ("matchId", "number", "stageId", "mode", "source", "winnerTeamId", "reporterId") values (?, 1, 1, 'SZ', '{}', ?, 1)`, + ) + .run(matchId, teamId); + + for (const userId of participantUserIds) { + sql + .prepare( + /*sql*/ `insert into "TournamentMatchGameResultParticipant" ("matchGameResultId", "userId", "tournamentTeamId") values (?, ?, ?)`, + ) + .run(Number(gameResultId), userId, teamId); + } +} + +async function seedStreamer( + twitchAccount: string, + userId: number | null = null, +) { + await db + .insertInto("TournamentStreamer") + .values({ tournamentId: TOURNAMENT_ID, twitchAccount, userId }) + .execute(); +} + +function findAllVods() { + return db.selectFrom("TournamentMatchVod").selectAll().execute(); +} + +// lazy-import so mocks are in place before the module loads +async function runProcessOneTournament() { + const { processOneTournament } = await import("./syncTournamentVods"); + return processOneTournament(TOURNAMENT_ID); +} diff --git a/app/routines/syncTournamentVods.ts b/app/routines/syncTournamentVods.ts index b6c42dcd0..770a62e3c 100644 --- a/app/routines/syncTournamentVods.ts +++ b/app/routines/syncTournamentVods.ts @@ -63,6 +63,12 @@ export async function processOneTournament(tournamentId: number) { ); for (const streamer of streamers) { + const dbUserId = + streamerDbUserIds.get(streamer.twitchAccount.toLowerCase()) ?? null; + + // cast accounts (null userId) are handled via castedMatchHistory below + if (dbUserId === null) continue; + const twitchUserId = loginToTwitchId.get( streamer.twitchAccount.toLowerCase(), ); @@ -74,16 +80,11 @@ export async function processOneTournament(tournamentId: number) { ); if (!videos) continue; - const dbUserId = - streamerDbUserIds.get(streamer.twitchAccount.toLowerCase()) ?? null; - for (const match of matches) { if (!match.startedAt) continue; - if (dbUserId !== null) { - const matchParticipants = participantsByMatch.get(match.id); - if (!matchParticipants?.has(dbUserId)) continue; - } + const matchParticipants = participantsByMatch.get(match.id); + if (!matchParticipants?.has(dbUserId)) continue; const vodMatch = findMatchingVod(match.startedAt, match, videos); if (!vodMatch) continue; diff --git a/db-test.sqlite3 b/db-test.sqlite3 index c485ca6d28d9abf460f484dfff52c18da29d2093..a401409cf4cf76d05ffbdd50b0f242b8311892bc 100644 GIT binary patch delta 5014 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