diff --git a/databases/_private.db.sql b/databases/_private.db.sql index 50cf974..9ada2dd 100644 --- a/databases/_private.db.sql +++ b/databases/_private.db.sql @@ -44,4 +44,15 @@ CREATE TABLE IF NOT EXISTS "thumbnailVotes" ( "type" INTEGER NOT NULL ); +CREATE TABLE IF NOT EXISTS "casualVotes" ( + "UUID" SERIAL PRIMARY KEY, + "videoID" TEXT NOT NULL, + "service" TEXT NOT NULL, + "userID" TEXT NOT NULL, + "hashedIP" TEXT NOT NULL, + "category" TEXT NOT NULL, + "type" INTEGER NOT NULL, + "timeSubmitted" INTEGER NOT NULL +); + COMMIT; diff --git a/databases/_private_indexes.sql b/databases/_private_indexes.sql index 35d3bcc..021db4f 100644 --- a/databases/_private_indexes.sql +++ b/databases/_private_indexes.sql @@ -23,4 +23,11 @@ CREATE INDEX IF NOT EXISTS "categoryVotes_UUID" CREATE INDEX IF NOT EXISTS "ratings_videoID" ON public."ratings" USING btree ("videoID" COLLATE pg_catalog."default" ASC NULLS LAST, service COLLATE pg_catalog."default" ASC NULLS LAST, "userID" COLLATE pg_catalog."default" ASC NULLS LAST, "timeSubmitted" ASC NULLS LAST) + TABLESPACE pg_default; + +-- casualVotes + +CREATE INDEX IF NOT EXISTS "casualVotes_videoID" + ON public."casualVotes" USING btree + ("videoID" COLLATE pg_catalog."default" ASC NULLS LAST, "service" COLLATE pg_catalog."default" ASC NULLS LAST, "userID" COLLATE pg_catalog."default" ASC NULLS LAST) TABLESPACE pg_default; \ No newline at end of file diff --git a/databases/_sponsorTimes.db.sql b/databases/_sponsorTimes.db.sql index 0e87c93..a7f3919 100644 --- a/databases/_sponsorTimes.db.sql +++ b/databases/_sponsorTimes.db.sql @@ -84,6 +84,17 @@ CREATE TABLE IF NOT EXISTS "thumbnailVotes" ( FOREIGN KEY("UUID") REFERENCES "thumbnails"("UUID") ); +CREATE TABLE IF NOT EXISTS "casualVotes" ( + "UUID" TEXT PRIMARY KEY, + "videoID" TEXT NOT NULL, + "service" TEXT NOT NULL, + "hashedVideoID" TEXT NOT NULL, + "category" TEXT NOT NULL, + "upvotes" INTEGER NOT NULL default 0, + "downvotes" INTEGER NOT NULL default 0, + "timeSubmitted" INTEGER NOT NULL +); + CREATE EXTENSION IF NOT EXISTS pgcrypto; --!sqlite-ignore CREATE EXTENSION IF NOT EXISTS pg_trgm; --!sqlite-ignore diff --git a/databases/_sponsorTimes_indexes.sql b/databases/_sponsorTimes_indexes.sql index f707a1f..90ef72e 100644 --- a/databases/_sponsorTimes_indexes.sql +++ b/databases/_sponsorTimes_indexes.sql @@ -173,4 +173,26 @@ CREATE INDEX IF NOT EXISTS "thumbnails_hashedVideoID_2" CREATE INDEX IF NOT EXISTS "thumbnailVotes_votes" ON public."thumbnailVotes" USING btree ("UUID" COLLATE pg_catalog."default" ASC NULLS LAST, "votes" DESC NULLS LAST) + TABLESPACE pg_default; + +-- casualVotes + +CREATE INDEX IF NOT EXISTS "casualVotes_timeSubmitted" + ON public."casualVotes" USING btree + ("timeSubmitted" ASC NULLS LAST) + TABLESPACE pg_default; + +CREATE INDEX IF NOT EXISTS "casualVotes_userID_timeSubmitted" + ON public."casualVotes" USING btree + ("videoID" COLLATE pg_catalog."default" ASC NULLS LAST, "service" COLLATE pg_catalog."default" ASC NULLS LAST, "userID" COLLATE pg_catalog."default" DESC NULLS LAST, "timeSubmitted" DESC NULLS LAST) + TABLESPACE pg_default; + +CREATE INDEX IF NOT EXISTS "casualVotes_videoID" + ON public."casualVotes" USING btree + ("videoID" COLLATE pg_catalog."default" ASC NULLS LAST, "service" COLLATE pg_catalog."default" ASC NULLS LAST) + TABLESPACE pg_default; + +CREATE INDEX IF NOT EXISTS "casualVotes_hashedVideoID_2" + ON public."casualVotes" USING btree + (service COLLATE pg_catalog."default" ASC NULLS LAST, "hashedVideoID" text_pattern_ops ASC NULLS LAST, "timeSubmitted" ASC NULLS LAST) TABLESPACE pg_default; \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index 374dfb7..13106ac 100644 --- a/src/app.ts +++ b/src/app.ts @@ -59,6 +59,7 @@ import { getFeatureFlag } from "./routes/getFeatureFlag"; import { getReady } from "./routes/getReady"; import { getMetrics } from "./routes/getMetrics"; import { getSegmentID } from "./routes/getSegmentID"; +import { postCasual } from "./routes/postCasual"; export function createServer(callback: () => void): Server { // Create a service (the app object is just a callback). @@ -234,6 +235,8 @@ function setupRoutes(router: Router, server: Server) { router.get("/api/branding/:prefix", getBrandingByHashEndpoint); router.post("/api/branding", postBranding); + router.post("/api/casual", postCasual); + /* istanbul ignore next */ if (config.postgres?.enabled) { router.get("/database", (req, res) => dumpDatabase(req, res, true)); diff --git a/src/config.ts b/src/config.ts index 35fb3db..ba84be2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,6 +20,7 @@ addDefaults(config, { readOnly: false, webhooks: [], categoryList: ["sponsor", "selfpromo", "exclusive_access", "interaction", "intro", "outro", "preview", "music_offtopic", "filler", "poi_highlight", "chapter"], + casualCategoryList: ["funny", "creative", "clever", "descriptive", "other"], categorySupport: { sponsor: ["skip", "mute", "full"], selfpromo: ["skip", "mute", "full"], diff --git a/src/routes/getBranding.ts b/src/routes/getBranding.ts index 875e919..06053e2 100644 --- a/src/routes/getBranding.ts +++ b/src/routes/getBranding.ts @@ -3,7 +3,7 @@ import { isEmpty } from "lodash"; import { config } from "../config"; import { db, privateDB } from "../databases/databases"; import { Postgres } from "../databases/Postgres"; -import { BrandingDBSubmission, BrandingDBSubmissionData, BrandingHashDBResult, BrandingResult, BrandingSegmentDBResult, BrandingSegmentHashDBResult, ThumbnailDBResult, ThumbnailResult, TitleDBResult, TitleResult } from "../types/branding.model"; +import { BrandingDBSubmission, BrandingDBSubmissionData, BrandingHashDBResult, BrandingResult, BrandingSegmentDBResult, BrandingSegmentHashDBResult, CasualVoteDBResult, CasualVoteHashDBResult, ThumbnailDBResult, ThumbnailResult, TitleDBResult, TitleResult } from "../types/branding.model"; import { HashedIP, IPAddress, Service, VideoID, VideoIDHash, Visibility } from "../types/segments.model"; import { shuffleArray } from "../utils/array"; import { getHashCache } from "../utils/getHashCache"; @@ -51,10 +51,20 @@ export async function getVideoBranding(res: Response, videoID: VideoID, service: { useReplica: true } ) as Promise; + const getCasualVotes = () => db.prepare( + "all", + `SELECT "category", "upvotes", "downvotes" FROM "casualVotes" + WHERE "videoID" = ? AND "service" = ? + ORDER BY "timeSubmitted" ASC`, + [videoID, service], + { useReplica: true } + ) as Promise; + const getBranding = async () => { const titles = getTitles(); const thumbnails = getThumbnails(); const segments = getSegments(); + const casualVotes = getCasualVotes(); for (const title of await titles) { title.title = title.title.replace("<", "‹"); @@ -63,7 +73,8 @@ export async function getVideoBranding(res: Response, videoID: VideoID, service: return { titles: await titles, thumbnails: await thumbnails, - segments: await segments + segments: await segments, + casualVotes: await casualVotes }; }; @@ -85,7 +96,8 @@ export async function getVideoBranding(res: Response, videoID: VideoID, service: currentIP: null as Promise | null }; - return filterAndSortBranding(videoID, returnUserID, fetchAll, branding.titles, branding.thumbnails, branding.segments, ip, cache); + return filterAndSortBranding(videoID, returnUserID, fetchAll, branding.titles, + branding.thumbnails, branding.segments, branding.casualVotes, ip, cache); } export async function getVideoBrandingByHash(videoHashPrefix: VideoIDHash, service: Service, ip: IPAddress, returnUserID: boolean, fetchAll: boolean): Promise> { @@ -117,12 +129,22 @@ export async function getVideoBrandingByHash(videoHashPrefix: VideoIDHash, servi { useReplica: true } ) as Promise; + const getCasualVotes = () => db.prepare( + "all", + `SELECT "videoID", "category", "upvotes", "downvotes" FROM "casualVotes" + WHERE "hashedVideoID" LIKE ? AND "service" = ? + ORDER BY "timeSubmitted" ASC`, + [`${videoHashPrefix}%`, service], + { useReplica: true } + ) as Promise; + const branding = await QueryCacher.get(async () => { // Make sure they are both called in parallel const branding = { titles: getTitles(), thumbnails: getThumbnails(), - segments: getSegments() + segments: getSegments(), + casualVotes: getCasualVotes() }; const dbResult: Record = {}; @@ -130,7 +152,8 @@ export async function getVideoBrandingByHash(videoHashPrefix: VideoIDHash, servi dbResult[submission.videoID] = dbResult[submission.videoID] || { titles: [], thumbnails: [], - segments: [] + segments: [], + casualVotes: [] }; }; @@ -150,6 +173,11 @@ export async function getVideoBrandingByHash(videoHashPrefix: VideoIDHash, servi dbResult[segment.videoID].segments.push(segment); }); + (await branding.casualVotes).forEach((casualVote) => { + initResult(casualVote); + dbResult[casualVote.videoID].casualVotes.push(casualVote); + }); + return dbResult; }, brandingHashKey(videoHashPrefix, service)); @@ -162,14 +190,14 @@ export async function getVideoBrandingByHash(videoHashPrefix: VideoIDHash, servi await Promise.all(Object.keys(branding).map(async (key) => { const castedKey = key as VideoID; processedResult[castedKey] = await filterAndSortBranding(castedKey, returnUserID, fetchAll, branding[castedKey].titles, - branding[castedKey].thumbnails, branding[castedKey].segments, ip, cache); + branding[castedKey].thumbnails, branding[castedKey].segments, branding[castedKey].casualVotes, ip, cache); })); return processedResult; } async function filterAndSortBranding(videoID: VideoID, returnUserID: boolean, fetchAll: boolean, dbTitles: TitleDBResult[], - dbThumbnails: ThumbnailDBResult[], dbSegments: BrandingSegmentDBResult[], + dbThumbnails: ThumbnailDBResult[], dbSegments: BrandingSegmentDBResult[], dbCasualVotes: CasualVoteDBResult[], ip: IPAddress, cache: { currentIP: Promise | null }): Promise { const shouldKeepTitles = shouldKeepSubmission(dbTitles, BrandingSubmissionType.Title, ip, cache); @@ -202,11 +230,17 @@ async function filterAndSortBranding(videoID: VideoID, returnUserID: boolean, fe })) .filter((a) => (fetchAll && !a.original) || a.votes >= 1 || (a.votes >= 0 && !a.original) || a.locked) as ThumbnailResult[]; + const casualVotes = dbCasualVotes.map((r) => ({ + id: r.category, + count: r.upvotes - r.downvotes + })).filter((a) => a.count > 0); + const videoDuration = dbSegments.filter(s => s.videoDuration !== 0)[0]?.videoDuration ?? null; return { titles, thumbnails, + casualVotes, randomTime: findRandomTime(videoID, dbSegments, videoDuration), videoDuration: videoDuration, }; @@ -303,7 +337,7 @@ export async function getBranding(req: Request, res: Response) { .then(etag => res.set("ETag", etag)) .catch(() => null); - const status = result.titles.length > 0 || result.thumbnails.length > 0 ? 200 : 404; + const status = result.titles.length > 0 || result.thumbnails.length > 0 || result.casualVotes.length > 0 ? 200 : 404; return res.status(status).json(result); } catch (e) { Logger.error(e as string); diff --git a/src/routes/postCasual.ts b/src/routes/postCasual.ts new file mode 100644 index 0000000..1feb494 --- /dev/null +++ b/src/routes/postCasual.ts @@ -0,0 +1,111 @@ +import { Request, Response } from "express"; +import { config } from "../config"; +import { db, privateDB } from "../databases/databases"; + +import { BrandingUUID, CasualCategory, CasualVoteSubmission } from "../types/branding.model"; +import { HashedIP, IPAddress, Service, VideoID } from "../types/segments.model"; +import { HashedUserID } from "../types/user.model"; +import { getHashCache } from "../utils/getHashCache"; +import { getIP } from "../utils/getIP"; +import { getService } from "../utils/getService"; +import { Logger } from "../utils/logger"; +import crypto from "crypto"; +import { QueryCacher } from "../utils/queryCacher"; +import { acquireLock } from "../utils/redisLock"; +import { checkBanStatus } from "../utils/checkBan"; + +enum CasualVoteType { + Upvote = 1, + Downvote = 2 +} + +interface ExistingVote { + UUID: BrandingUUID; + type: number; +} + +export async function postCasual(req: Request, res: Response) { + const { videoID, userID, downvote, category } = req.body as CasualVoteSubmission; + const service = getService(req.body.service); + + if (!videoID || !userID || userID.length < 30 || !service || !category) { + return res.status(400).send("Bad Request"); + } + if (!config.casualCategoryList.includes(category)) { + return res.status(400).send("Invalid category"); + } + + try { + const hashedUserID = await getHashCache(userID); + const hashedVideoID = await getHashCache(videoID, 1); + const hashedIP = await getHashCache(getIP(req) + config.globalSalt as IPAddress); + const isBanned = await checkBanStatus(hashedUserID, hashedIP); + + const lock = await acquireLock(`postCasual:${videoID}.${hashedUserID}`); + if (!lock.status) { + res.status(429).send("Vote already in progress"); + return; + } + + if (isBanned) { + return res.status(200).send("OK"); + } + + const now = Date.now(); + const voteType: CasualVoteType = downvote ? CasualVoteType.Downvote : CasualVoteType.Upvote; + + const existingUUID = (await db.prepare("get", `SELECT "UUID" from "casualVotes" where "videoID" = ? AND "category" = ?`, [videoID, category]))?.UUID; + const UUID = existingUUID || crypto.randomUUID(); + + const alreadyVotedTheSame = await handleExistingVotes(videoID, service, UUID, hashedUserID, hashedIP, category, voteType, now); + if (existingUUID) { + if (!alreadyVotedTheSame) { + if (downvote) { + await db.prepare("run", `UPDATE "casualVotes" SET "downvotes" = "downvotes" + 1 WHERE "UUID" = ?`, [UUID]); + } else { + await db.prepare("run", `UPDATE "casualVotes" SET "upvotes" = "upvotes" + 1 WHERE "UUID" = ?`, [UUID]); + } + } + } else { + if (downvote) { + throw new Error("Title submission doesn't exist"); + } + + await db.prepare("run", `INSERT INTO "casualVotes" ("videoID", "service", "hashedVideoID", "timeSubmitted", "UUID", "category", "upvotes", "downvotes") VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [videoID, service, hashedVideoID, now, UUID, category, downvote ? 0 : 1, downvote ? 1 : 0]); + } + + //todo: cache clearing + QueryCacher.clearBrandingCache({ videoID, hashedVideoID, service }); + + res.status(200).send("OK"); + + lock.unlock(); + } catch (e) { + Logger.error(e as string); + res.status(500).send("Internal Server Error"); + } +} + +async function handleExistingVotes(videoID: VideoID, service: Service, UUID: string, + hashedUserID: HashedUserID, hashedIP: HashedIP, category: CasualCategory, voteType: CasualVoteType, now: number): Promise { + const existingVote = await privateDB.prepare("get", `SELECT "UUID", "type" from "casualVotes" WHERE "videoID" = ? AND "service" = ? AND "userID" = ? AND category = ?`, [videoID, service, hashedUserID, category]) as ExistingVote; + if (existingVote) { + if (existingVote.type === voteType) { + return true; + } + + if (existingVote.type === CasualVoteType.Upvote) { + await db.prepare("run", `UPDATE "casualVotes" SET "upvotes" = "upvotes" - 1 WHERE "UUID" = ?`, [UUID]); + } else { + await db.prepare("run", `UPDATE "casualVotes" SET "downvotes" = "downvotes" - 1 WHERE "UUID" = ?`, [UUID]); + } + + await privateDB.prepare("run", `DELETE FROM "casualVotes" WHERE "UUID" = ?`, [existingVote.UUID]); + } + + await privateDB.prepare("run", `INSERT INTO "casualVotes" ("videoID", "service", "userID", "hashedIP", "category", "type", "timeSubmitted") VALUES (?, ?, ?, ?, ?, ?, ?)`, + [videoID, service, hashedUserID, hashedIP, category, voteType, now]); + + return false; +} \ No newline at end of file diff --git a/src/types/branding.model.ts b/src/types/branding.model.ts index 36d4a8c..2f3df4b 100644 --- a/src/types/branding.model.ts +++ b/src/types/branding.model.ts @@ -3,6 +3,8 @@ import { UserID } from "./user.model"; export type BrandingUUID = string & { readonly __brandingUUID: unique symbol }; +export type CasualCategory = ("funny" | "creative" | "clever" | "descriptive" | "other") & { __casualCategoryBrand: unknown }; + export interface BrandingDBSubmissionData { videoID: VideoID, } @@ -50,17 +52,24 @@ export interface ThumbnailResult { userID?: UserID } +export interface CasualVote { + id: string, + count: number +} + export interface BrandingResult { titles: TitleResult[], thumbnails: ThumbnailResult[], + casualVotes: CasualVote[], randomTime: number, videoDuration: number | null } export interface BrandingHashDBResult { - titles: TitleDBResult[], - thumbnails: ThumbnailDBResult[], - segments: BrandingSegmentDBResult[] + titles: TitleDBResult[]; + thumbnails: ThumbnailDBResult[]; + segments: BrandingSegmentDBResult[]; + casualVotes: CasualVoteDBResult[]; } export interface OriginalThumbnailSubmission { @@ -89,6 +98,15 @@ export interface BrandingSubmission { downvote: boolean | undefined; videoDuration: number | undefined; wasWarned: boolean | undefined; + casualMode: boolean | undefined; +} + +export interface CasualVoteSubmission { + videoID: VideoID; + userID: UserID; + service: Service; + downvote: boolean | undefined; + category: CasualCategory; } export interface BrandingSegmentDBResult { @@ -98,9 +116,21 @@ export interface BrandingSegmentDBResult { videoDuration: number; } +export interface CasualVoteDBResult { + category: CasualCategory; + upvotes: number; + downvotes: number; +} + export interface BrandingSegmentHashDBResult extends BrandingDBSubmissionData { startTime: number; endTime: number; category: Category; videoDuration: number; +} + +export interface CasualVoteHashDBResult extends BrandingDBSubmissionData { + category: CasualCategory; + upvotes: number; + downvotes: number; } \ No newline at end of file diff --git a/src/types/config.model.ts b/src/types/config.model.ts index e8a7924..91bae15 100644 --- a/src/types/config.model.ts +++ b/src/types/config.model.ts @@ -73,6 +73,7 @@ export interface SBSConfig { readOnly: boolean; webhooks: WebhookConfig[]; categoryList: string[]; + casualCategoryList: string[]; deArrowTypes: DeArrowType[]; categorySupport: Record; maxTitleLength: number; diff --git a/src/utils/redisKeys.ts b/src/utils/redisKeys.ts index 9ed7616..0435461 100644 --- a/src/utils/redisKeys.ts +++ b/src/utils/redisKeys.ts @@ -19,13 +19,13 @@ export function skipSegmentsHashKey(hashedVideoIDPrefix: VideoIDHash, service: S } export const brandingKey = (videoID: VideoID, service: Service): string => - `branding.v2.${service}.videoID.${videoID}`; + `branding.v3.${service}.videoID.${videoID}`; export function brandingHashKey(hashedVideoIDPrefix: VideoIDHash, service: Service): string { hashedVideoIDPrefix = hashedVideoIDPrefix.substring(0, 4) as VideoIDHash; if (hashedVideoIDPrefix.length !== 4) Logger.warn(`Redis skip segment hash-prefix key is not length 4! ${hashedVideoIDPrefix}`); - return `branding.v2.${service}.${hashedVideoIDPrefix}`; + return `branding.v3.${service}.${hashedVideoIDPrefix}`; } export const brandingIPKey = (uuid: BrandingUUID): string => diff --git a/test/cases/getBranding.ts b/test/cases/getBranding.ts index c4dd0a0..31b693b 100644 --- a/test/cases/getBranding.ts +++ b/test/cases/getBranding.ts @@ -3,7 +3,7 @@ import assert from "assert"; import { getHash } from "../../src/utils/getHash"; import { db } from "../../src/databases/databases"; import { Service } from "../../src/types/segments.model"; -import { BrandingUUID, ThumbnailResult, TitleResult } from "../../src/types/branding.model"; +import { BrandingUUID, CasualVote, ThumbnailResult, TitleResult } from "../../src/types/branding.model"; import { partialDeepEquals } from "../utils/partialDeepEquals"; describe("getBranding", () => { @@ -14,6 +14,8 @@ describe("getBranding", () => { const videoIDRandomTime = "videoID5"; const videoIDUnverified = "videoID6"; const videoIDvidDuration = "videoID7"; + const videoIDCasual = "videoIDCasual"; + const videoIDCasualDownvoted = "videoIDCasualDownvoted"; const videoID1Hash = getHash(videoID1, 1).slice(0, 4); const videoID2LockedHash = getHash(videoID2Locked, 1).slice(0, 4); @@ -22,6 +24,8 @@ describe("getBranding", () => { const videoIDRandomTimeHash = getHash(videoIDRandomTime, 1).slice(0, 4); const videoIDUnverifiedHash = getHash(videoIDUnverified, 1).slice(0, 4); const videoIDvidDurationHash = getHash(videoIDUnverified, 1).slice(0, 4); + const videoIDCasualHash = getHash(videoIDCasual, 1).slice(0, 4); + const videoIDCasualDownvotedHash = getHash(videoIDCasualDownvoted, 1).slice(0, 4); const endpoint = "/api/branding"; const getBranding = (params: Record) => client({ @@ -43,6 +47,7 @@ describe("getBranding", () => { const thumbnailTimestampsQuery = `INSERT INTO "thumbnailTimestamps" ("UUID", "timestamp") VALUES (?, ?)`; const thumbnailVotesQuery = `INSERT INTO "thumbnailVotes" ("UUID", "votes", "locked", "shadowHidden", "downvotes", "removed") VALUES (?, ?, ?, ?, ?, ?)`; const segmentQuery = 'INSERT INTO "sponsorTimes" ("videoID", "startTime", "endTime", "votes", "locked", "UUID", "userID", "timeSubmitted", "views", "category", "actionType", "service", "videoDuration", "hidden", "shadowHidden", "description", "hashedVideoID") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; + const insertCasualVotesQuery = `INSERT INTO "casualVotes" ("UUID", "videoID", "service", "hashedVideoID", "category", "upvotes", "downvotes", "timeSubmitted") VALUES (?, ?, ?, ?, ?, ?, ?, ?)`; await Promise.all([ db.prepare("run", titleQuery, [videoID1, "title1", 0, "userID1", Service.YouTube, videoID1Hash, 1, "UUID1"]), @@ -143,6 +148,12 @@ describe("getBranding", () => { db.prepare("run", segmentQuery, [videoIDvidDuration, 0, 6, 0, 0, "uuidvd6", "testman", 15, 0, "sponsor", "skip", "YouTube", 21.37, 0, 0, "", videoIDvidDurationHash]), // not the oldest visible db.prepare("run", segmentQuery, [videoIDvidDuration, 0, 7, -2, 0, "uuidvd7", "testman", 16, 0, "sponsor", "skip", "YouTube", 21.38, 0, 0, "", videoIDvidDurationHash]), // downvoted, not the oldest ]); + + await Promise.all([ + db.prepare("run", insertCasualVotesQuery, ["postBrandCasual1", videoIDCasual, Service.YouTube, videoIDCasualHash, "clever", 1, 0, Date.now()]), + db.prepare("run", insertCasualVotesQuery, ["postBrandCasual2", videoIDCasualDownvoted, Service.YouTube, videoIDCasualDownvotedHash, "clever", 1, 1, Date.now()]), + db.prepare("run", insertCasualVotesQuery, ["postBrandCasual3", videoIDCasualDownvoted, Service.YouTube, videoIDCasualDownvotedHash, "other", 4, 1, Date.now()]) + ]); }); it("should get top titles and thumbnails", async () => { @@ -335,9 +346,28 @@ describe("getBranding", () => { assert.strictEqual(result2.data[videoIDvidDuration].videoDuration, correctDuration); }); + it("should get casual votes", async () => { + await checkVideo(videoIDCasual, videoIDCasualHash, true, { + casualVotes: [{ + id: "clever", + count: 1 + }] + }); + }); + + it("should not get casual votes with downvotes", async () => { + await checkVideo(videoIDCasualDownvoted, videoIDCasualDownvotedHash, true, { + casualVotes: [{ + id: "other", + count: 3 + }] + }); + }); + async function checkVideo(videoID: string, videoIDHash: string, fetchAll: boolean, expected: { - titles: TitleResult[], - thumbnails: ThumbnailResult[] + titles?: TitleResult[], + thumbnails?: ThumbnailResult[], + casualVotes?: CasualVote[] }) { const result1 = await getBranding({ videoID, fetchAll }); const result2 = await getBrandingByHash(videoIDHash, { fetchAll }); diff --git a/test/cases/postCasual.ts b/test/cases/postCasual.ts new file mode 100644 index 0000000..b2f999a --- /dev/null +++ b/test/cases/postCasual.ts @@ -0,0 +1,132 @@ +import { db } from "../../src/databases/databases"; +import { client } from "../utils/httpClient"; +import assert from "assert"; +import { Service } from "../../src/types/segments.model"; + +describe("postCasual", () => { + + const userID1 = `PostCasualUser1${".".repeat(16)}`; + const userID2 = `PostCasualUser2${".".repeat(16)}`; + const userID3 = `PostCasualUser3${".".repeat(16)}`; + + const endpoint = "/api/casual"; + const postCasual = (data: Record) => client({ + method: "POST", + url: endpoint, + data + }); + + const queryCasualVotesByVideo = (videoID: string, all = false) => db.prepare(all ? "all" : "get", `SELECT * FROM "casualVotes" WHERE "videoID" = ? ORDER BY "timeSubmitted" DESC`, [videoID]); + + it("submit casual vote", async () => { + const videoID = "postCasual1"; + + const res = await postCasual({ + category: "clever", + userID: userID1, + service: Service.YouTube, + videoID + }); + + assert.strictEqual(res.status, 200); + const dbVotes = await queryCasualVotesByVideo(videoID); + + assert.strictEqual(dbVotes.category, "clever"); + assert.strictEqual(dbVotes.upvotes, 1); + assert.strictEqual(dbVotes.downvotes, 0); + }); + + it("submit same casual vote again", async () => { + const videoID = "postCasual1"; + + const res = await postCasual({ + category: "clever", + userID: userID1, + service: Service.YouTube, + videoID + }); + + assert.strictEqual(res.status, 200); + const dbVotes = await queryCasualVotesByVideo(videoID); + + assert.strictEqual(dbVotes.category, "clever"); + assert.strictEqual(dbVotes.upvotes, 1); + assert.strictEqual(dbVotes.downvotes, 0); + }); + + it("submit casual upvote", async () => { + const videoID = "postCasual1"; + + const res = await postCasual({ + category: "clever", + userID: userID2, + service: Service.YouTube, + videoID + }); + + assert.strictEqual(res.status, 200); + const dbVotes = await queryCasualVotesByVideo(videoID); + + assert.strictEqual(dbVotes.category, "clever"); + assert.strictEqual(dbVotes.upvotes, 2); + assert.strictEqual(dbVotes.downvotes, 0); + }); + + it("submit casual downvote from same user", async () => { + const videoID = "postCasual1"; + + const res = await postCasual({ + category: "clever", + downvote: true, + userID: userID1, + service: Service.YouTube, + videoID + }); + + assert.strictEqual(res.status, 200); + const dbVotes = await queryCasualVotesByVideo(videoID); + + assert.strictEqual(dbVotes.category, "clever"); + assert.strictEqual(dbVotes.upvotes, 1); + assert.strictEqual(dbVotes.downvotes, 1); + }); + + it("submit casual downvote from different user", async () => { + const videoID = "postCasual1"; + + const res = await postCasual({ + category: "clever", + downvote: true, + userID: userID3, + service: Service.YouTube, + videoID + }); + + assert.strictEqual(res.status, 200); + const dbVotes = await queryCasualVotesByVideo(videoID); + + assert.strictEqual(dbVotes.category, "clever"); + assert.strictEqual(dbVotes.upvotes, 1); + assert.strictEqual(dbVotes.downvotes, 2); + }); + + it("submit casual upvote from same user", async () => { + const videoID = "postCasual1"; + + const res = await postCasual({ + category: "clever", + downvote: false, + userID: userID3, + service: Service.YouTube, + videoID + }); + + assert.strictEqual(res.status, 200); + const dbVotes = await queryCasualVotesByVideo(videoID); + + assert.strictEqual(dbVotes.category, "clever"); + assert.strictEqual(dbVotes.upvotes, 2); + assert.strictEqual(dbVotes.downvotes, 1); + }); + +});