From f9192ff3d603d8b096d97faeb09134d398492d45 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 30 Oct 2025 18:52:17 +0200 Subject: [PATCH] Fix editing a Vod not showing the current PoV --- app/db/seed/index.ts | 4 +- .../loaders/u.$identifier.vods.server.ts | 2 +- .../vods/VodRepository.server.test.ts | 460 ++++++++++++++++++ app/features/vods/VodRepository.server.ts | 42 +- app/features/vods/actions/vods.new.server.ts | 4 +- app/utils/kysely.server.ts | 13 +- 6 files changed, 496 insertions(+), 29 deletions(-) create mode 100644 app/features/vods/VodRepository.server.test.ts diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 84b7220be..2e1d0e9e6 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -1730,7 +1730,7 @@ function otherTeams() { async function realVideo() { for (let i = 0; i < 5; i++) { - await VodRepository.createVod({ + await VodRepository.insert({ type: "TOURNAMENT", youtubeUrl: youtubeIdToYoutubeUrl("M4aV-BQWlVg"), date: { day: 2, month: 2, year: 2023 }, @@ -1785,7 +1785,7 @@ async function realVideo() { } async function realVideoCast() { - await VodRepository.createVod({ + await VodRepository.insert({ type: "CAST", youtubeUrl: youtubeIdToYoutubeUrl("M4aV-BQWlVg"), date: { day: 2, month: 2, year: 2023 }, diff --git a/app/features/user-page/loaders/u.$identifier.vods.server.ts b/app/features/user-page/loaders/u.$identifier.vods.server.ts index 077fc5cfc..7dd1ead2d 100644 --- a/app/features/user-page/loaders/u.$identifier.vods.server.ts +++ b/app/features/user-page/loaders/u.$identifier.vods.server.ts @@ -10,6 +10,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { return { // TODO: add pagination instead of not showing oldest vods at all - vods: await VodRepository.findVodsByUserId(userId), + vods: await VodRepository.findByUserId(userId), }; }; diff --git a/app/features/vods/VodRepository.server.test.ts b/app/features/vods/VodRepository.server.test.ts new file mode 100644 index 000000000..829e7b4b3 --- /dev/null +++ b/app/features/vods/VodRepository.server.test.ts @@ -0,0 +1,460 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types"; +import { dbInsertUsers, dbReset } from "~/utils/Test"; +import * as VodRepository from "./VodRepository.server"; + +let vodCounter = 0; + +const createVod = async ({ + submitterUserId, + type = "TOURNAMENT", + povUserId, + povName, + weaponSplIds = [0, 10], + mode = "TW", + stageId = 0, + isValidated = true, +}: { + submitterUserId: number; + type?: "TOURNAMENT" | "CAST" | "SCRIM"; + povUserId?: number; + povName?: string; + weaponSplIds?: MainWeaponId[]; + mode?: "TW" | "SZ" | "TC" | "RM" | "CB"; + stageId?: StageId; + isValidated?: boolean; +}) => { + vodCounter++; + + const result = await VodRepository.insert({ + title: `Test VOD ${vodCounter}`, + youtubeUrl: `https://www.youtube.com/watch?v=test${vodCounter}`, + date: { + day: 1, + month: 0, + year: 2024, + }, + matches: [ + { + mode, + stageId: stageId, + startsAt: "0:00", + weapons: weaponSplIds, + }, + ], + type, + pov: povUserId + ? { type: "USER", userId: povUserId } + : povName + ? { type: "NAME", name: povName } + : undefined, + submitterUserId, + isValidated, + }); + + return result.id; +}; + +describe("findByUserId", () => { + beforeEach(async () => { + vodCounter = 0; + await dbInsertUsers(5); + }); + + afterEach(() => { + dbReset(); + }); + + test("returns vods for a specific user", async () => { + await createVod({ submitterUserId: 1, povUserId: 1 }); + await createVod({ submitterUserId: 1, povUserId: 2 }); + await createVod({ submitterUserId: 1, povUserId: 3 }); + + const result = await VodRepository.findByUserId(2); + + expect(result).toHaveLength(1); + }); + + test("returns empty array when user has no vods", async () => { + await createVod({ submitterUserId: 1, povUserId: 1 }); + + const result = await VodRepository.findByUserId(2); + + expect(result).toHaveLength(0); + }); + + test("respects the limit parameter", async () => { + await createVod({ submitterUserId: 1, povUserId: 1 }); + await createVod({ submitterUserId: 1, povUserId: 1 }); + await createVod({ submitterUserId: 1, povUserId: 1 }); + + const result = await VodRepository.findByUserId(1, 2); + + expect(result).toHaveLength(2); + }); +}); + +describe("findVods", () => { + beforeEach(async () => { + vodCounter = 0; + await dbInsertUsers(5); + }); + + afterEach(() => { + dbReset(); + }); + + test("filters by weapon", async () => { + const vodId = await createVod({ + submitterUserId: 1, + povUserId: 1, + weaponSplIds: [1000], + }); + await createVod({ + submitterUserId: 1, + povUserId: 1, + weaponSplIds: [2000], + }); + + const result = await VodRepository.findVods({ weapon: 1000 }); + + expect(result.length).toBeGreaterThan(0); + expect(result.some((vod) => vod.id === vodId)).toBe(true); + }); + + test("filters by mode", async () => { + await createVod({ submitterUserId: 1, povUserId: 1, mode: "TW" }); + await createVod({ submitterUserId: 1, povUserId: 1, mode: "SZ" }); + await createVod({ submitterUserId: 1, povUserId: 1, mode: "TC" }); + + const result = await VodRepository.findVods({ mode: "SZ" }); + + expect(result).toHaveLength(1); + }); + + test("filters by stageId", async () => { + await createVod({ submitterUserId: 1, povUserId: 1, stageId: 0 }); + await createVod({ submitterUserId: 1, povUserId: 1, stageId: 1 }); + await createVod({ submitterUserId: 1, povUserId: 1, stageId: 2 }); + + const result = await VodRepository.findVods({ stageId: 1 }); + + expect(result).toHaveLength(1); + }); + + test("filters by type", async () => { + await createVod({ submitterUserId: 1, povUserId: 1, type: "TOURNAMENT" }); + await createVod({ submitterUserId: 1, povUserId: 1, type: "CAST" }); + await createVod({ submitterUserId: 1, povUserId: 1, type: "SCRIM" }); + + const result = await VodRepository.findVods({ type: "CAST" }); + + expect(result).toHaveLength(1); + }); + + test("returns all vods when no filters provided", async () => { + await createVod({ submitterUserId: 1, povUserId: 1 }); + await createVod({ submitterUserId: 1, povUserId: 2 }); + await createVod({ submitterUserId: 1, povUserId: 3 }); + + const result = await VodRepository.findVods({}); + + expect(result).toHaveLength(3); + }); + + test("respects limit parameter", async () => { + await createVod({ submitterUserId: 1, povUserId: 1 }); + await createVod({ submitterUserId: 1, povUserId: 1 }); + await createVod({ submitterUserId: 1, povUserId: 1 }); + + const result = await VodRepository.findVods({ limit: 2 }); + + expect(result).toHaveLength(2); + }); +}); + +describe("findVodById", () => { + beforeEach(async () => { + vodCounter = 0; + await dbInsertUsers(5); + }); + + afterEach(() => { + dbReset(); + }); + + test("returns null when vod doesn't exist", async () => { + const result = await VodRepository.findVodById(999); + + expect(result).toBeNull(); + }); + + test("correctly resolves pov from user", async () => { + const vodId = await createVod({ submitterUserId: 1, povUserId: 1 }); + + const result = await VodRepository.findVodById(vodId); + + expect(result).not.toBeNull(); + expect(result?.pov).toBeDefined(); + expect(typeof result?.pov).not.toBe("string"); + }); + + test("correctly resolves pov from player name", async () => { + const vodId = await createVod({ + submitterUserId: 1, + povName: "PlayerName", + }); + + const result = await VodRepository.findVodById(vodId); + + expect(result).not.toBeNull(); + expect(result?.pov).toBe("PlayerName"); + }); +}); + +describe("insert", () => { + beforeEach(async () => { + vodCounter = 0; + await dbInsertUsers(5); + }); + + afterEach(() => { + dbReset(); + }); + + test("inserts vod with all metadata", async () => { + const result = await VodRepository.insert({ + title: "Complete VOD", + youtubeUrl: "https://www.youtube.com/watch?v=abc123", + date: { + day: 15, + month: 5, + year: 2024, + }, + matches: [ + { + mode: "TW", + stageId: 0 as any, + startsAt: "0:00", + weapons: [0, 10, 20] as any, + }, + ], + type: "TOURNAMENT", + pov: { type: "USER", userId: 1 }, + submitterUserId: 1, + isValidated: true, + }); + + const vod = await VodRepository.findVodById(result.id); + + expect(vod).not.toBeNull(); + expect(vod?.title).toBe("Complete VOD"); + expect(vod?.youtubeId).toBe("abc123"); + expect(vod?.type).toBe("TOURNAMENT"); + expect(vod?.matches).toHaveLength(1); + expect(vod?.matches[0].weapons).toHaveLength(3); + }); + + test("extracts YouTube ID from URL correctly", async () => { + const vodId = await createVod({ + submitterUserId: 1, + povUserId: 1, + }); + + const result = await VodRepository.findVodById(vodId); + + expect(result?.youtubeId).toBe("test1"); + }); + + test("handles NAME type pov", async () => { + const result = await VodRepository.insert({ + title: "Test VOD", + youtubeUrl: "https://www.youtube.com/watch?v=test123", + date: { + day: 1, + month: 0, + year: 2024, + }, + matches: [ + { + mode: "TW", + stageId: 0 as any, + startsAt: "0:00", + weapons: [0] as any, + }, + ], + type: "TOURNAMENT", + pov: { type: "NAME", name: "TestPlayer" }, + submitterUserId: 1, + isValidated: true, + }); + + const vod = await VodRepository.findVodById(result.id); + + expect(vod?.pov).toBe("TestPlayer"); + }); + + test("handles USER type pov", async () => { + const result = await VodRepository.insert({ + title: "Test VOD", + youtubeUrl: "https://www.youtube.com/watch?v=test123", + date: { + day: 1, + month: 0, + year: 2024, + }, + matches: [ + { + mode: "TW", + stageId: 0 as any, + startsAt: "0:00", + weapons: [0] as any, + }, + ], + type: "TOURNAMENT", + pov: { type: "USER", userId: 1 }, + submitterUserId: 1, + isValidated: true, + }); + + const vod = await VodRepository.findVodById(result.id); + + expect(vod?.pov).toBeDefined(); + expect(typeof vod?.pov).not.toBe("string"); + }); +}); + +describe("update", () => { + beforeEach(async () => { + vodCounter = 0; + await dbInsertUsers(5); + }); + + afterEach(() => { + dbReset(); + }); + + test("updates vod metadata", async () => { + const vodId = await createVod({ submitterUserId: 1, povUserId: 1 }); + + await VodRepository.update({ + id: vodId, + title: "Updated Title", + youtubeUrl: "https://www.youtube.com/watch?v=updated123", + date: { + day: 1, + month: 0, + year: 2024, + }, + matches: [ + { + mode: "SZ", + stageId: 5 as any, + startsAt: "0:00", + weapons: [50] as any, + }, + ], + type: "CAST", + pov: { type: "USER", userId: 2 }, + submitterUserId: 1, + isValidated: true, + }); + + const result = await VodRepository.findVodById(vodId); + + expect(result?.title).toBe("Updated Title"); + expect(result?.youtubeId).toBe("updated123"); + expect(result?.type).toBe("CAST"); + }); + + test("deletes and recreates matches", async () => { + const result = await VodRepository.insert({ + title: "Test VOD", + youtubeUrl: "https://www.youtube.com/watch?v=test123", + date: { + day: 1, + month: 0, + year: 2024, + }, + matches: [ + { + mode: "TW", + stageId: 0 as any, + startsAt: "0:00", + weapons: [0] as any, + }, + { + mode: "SZ", + stageId: 1 as any, + startsAt: "5:00", + weapons: [10] as any, + }, + ], + type: "TOURNAMENT", + pov: { type: "USER", userId: 1 }, + submitterUserId: 1, + isValidated: true, + }); + + await VodRepository.update({ + id: result.id, + title: "Test VOD", + youtubeUrl: "https://www.youtube.com/watch?v=test123", + date: { + day: 1, + month: 0, + year: 2024, + }, + matches: [ + { + mode: "TC", + stageId: 2 as any, + startsAt: "0:00", + weapons: [20] as any, + }, + ], + type: "TOURNAMENT", + pov: { type: "USER", userId: 1 }, + submitterUserId: 1, + isValidated: true, + }); + + const vod = await VodRepository.findVodById(result.id); + + expect(vod?.matches).toHaveLength(1); + expect(vod?.matches[0].mode).toBe("TC"); + }); +}); + +describe("deleteById", () => { + beforeEach(async () => { + vodCounter = 0; + await dbInsertUsers(5); + }); + + afterEach(() => { + dbReset(); + }); + + test("deletes vod by id", async () => { + const vodId = await createVod({ submitterUserId: 1, povUserId: 1 }); + + await VodRepository.deleteById(vodId); + + const result = await VodRepository.findVodById(vodId); + expect(result).toBeNull(); + }); + + test("only deletes the specified vod", async () => { + const firstVodId = await createVod({ submitterUserId: 1, povUserId: 1 }); + const secondVodId = await createVod({ submitterUserId: 1, povUserId: 1 }); + + await VodRepository.deleteById(firstVodId); + + const firstResult = await VodRepository.findVodById(firstVodId); + const secondResult = await VodRepository.findVodById(secondVodId); + + expect(firstResult).toBeNull(); + expect(secondResult).not.toBeNull(); + }); +}); diff --git a/app/features/vods/VodRepository.server.ts b/app/features/vods/VodRepository.server.ts index 511997162..bea06386c 100644 --- a/app/features/vods/VodRepository.server.ts +++ b/app/features/vods/VodRepository.server.ts @@ -14,6 +14,7 @@ import { dayMonthYearToDatabaseTimestamp, } from "~/utils/dates"; import invariant from "~/utils/invariant"; +import { type CommonUser, commonUserJsonObject } from "~/utils/kysely.server"; import { VODS_PAGE_BATCH_SIZE } from "./vods-constants"; import type { VideoBeingAdded, Vod } from "./vods-types"; import { @@ -21,14 +22,7 @@ import { hoursMinutesSecondsStringToSeconds, } from "./vods-utils"; -export function deleteById(id: number) { - return db.deleteFrom("UnvalidatedVideo").where("id", "=", id).execute(); -} - -export async function findVodsByUserId( - userId: Tables["User"]["id"], - limit = 100, -) { +export async function findByUserId(userId: Tables["User"]["id"], limit = 100) { return findVods({ userId, limit }); } @@ -152,18 +146,14 @@ export async function findVodById(id: Tables["Video"]["id"]) { .as("weapons"), fn .agg("json_group_array", ["VideoMatchPlayer.playerName"]) + .filterWhere("VideoMatchPlayer.playerName", "is not", null) + .$castTo() .as("playerNames"), - jsonArrayFrom( - eb - .selectFrom("User") - .select([ - "User.username", - "User.discordId", - "User.discordAvatar", - "User.customUrl", - ]) - .whereRef("User.id", "=", "VideoMatchPlayer.playerUserId"), - ).as("players"), + fn + .agg("json_group_array", [commonUserJsonObject(eb)]) + .filterWhere("User.username", "is not", null) + .$castTo() + .as("players"), ]) .where("VideoMatch.videoId", "=", id) .groupBy("VideoMatch.id") @@ -181,7 +171,9 @@ export async function findVodById(id: Tables["Video"]["id"]) { return null; } -function resolvePov(matches: any): Vod["pov"] { +function resolvePov( + matches: Array<{ playerNames: string[]; players: CommonUser[] }>, +): Vod["pov"] { for (const match of matches) { if (match.playerNames.length > 0) { return match.playerNames[0]; @@ -195,17 +187,17 @@ function resolvePov(matches: any): Vod["pov"] { return; } -export async function updateVodByReplacing( +export async function update( args: VideoBeingAdded & { submitterUserId: number; isValidated: boolean; id: number; }, ) { - return createVod(args); + return insert(args); } -export async function createVod( +export async function insert( args: VideoBeingAdded & { submitterUserId: number; isValidated: boolean; @@ -276,3 +268,7 @@ export async function createVod( return { ...video, id: videoId }; }); } + +export function deleteById(id: number) { + return db.deleteFrom("UnvalidatedVideo").where("id", "=", id).execute(); +} diff --git a/app/features/vods/actions/vods.new.server.ts b/app/features/vods/actions/vods.new.server.ts index 0fed8cb60..5749d4891 100644 --- a/app/features/vods/actions/vods.new.server.ts +++ b/app/features/vods/actions/vods.new.server.ts @@ -33,14 +33,14 @@ export const action: ActionFunction = async ({ request }) => { throw new Response("no permissions to edit this vod", { status: 401 }); } - video = await VodRepository.updateVodByReplacing({ + video = await VodRepository.update({ ...data.video, submitterUserId: user.id, isValidated: true, id: data.vodToEditId, }); } else { - video = await VodRepository.createVod({ + video = await VodRepository.insert({ ...data.video, submitterUserId: user.id, isValidated: true, diff --git a/app/utils/kysely.server.ts b/app/utils/kysely.server.ts index f82f0a4d8..4c5d90720 100644 --- a/app/utils/kysely.server.ts +++ b/app/utils/kysely.server.ts @@ -1,4 +1,5 @@ -import { type ColumnType, sql } from "kysely"; +import { type ColumnType, type ExpressionBuilder, sql } from "kysely"; +import { jsonBuildObject } from "kysely/helpers/sqlite"; import type { Tables } from "~/db/tables"; export const COMMON_USER_FIELDS = [ @@ -20,6 +21,16 @@ export const userChatNameColor = sql< "chatNameColor", ); +export function commonUserJsonObject(eb: ExpressionBuilder) { + return jsonBuildObject({ + id: eb.ref("User.id"), + username: eb.ref("User.username"), + discordId: eb.ref("User.discordId"), + discordAvatar: eb.ref("User.discordAvatar"), + customUrl: eb.ref("User.customUrl"), + }); +} + /** Prevents ParseJSONResultsPlugin from trying to parse this as JSON */ export function unJsonify(value: T) { if (typeof value !== "string") {