diff --git a/app/features/art/ArtRepository.server.test.ts b/app/features/art/ArtRepository.server.test.ts new file mode 100644 index 000000000..aa1a33153 --- /dev/null +++ b/app/features/art/ArtRepository.server.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { db } from "~/db/sql"; +import { dbInsertUsers, dbReset } from "~/utils/Test"; +import * as ArtRepository from "./ArtRepository.server"; + +let imageCounter = 0; + +const createUserSubmittedImage = async (userId: number) => { + imageCounter++; + + // TODO: instead of using db, use ArtRepository's insert when implemented + const result = await db + .insertInto("UnvalidatedUserSubmittedImage") + .values({ + url: `https://example.com/image-${userId}-${imageCounter}.png`, + submitterUserId: userId, + validatedAt: Date.now(), + }) + .returning("id") + .executeTakeFirstOrThrow(); + + return result.id; +}; + +const createArt = async ({ + authorId, + isShowcase = 0, + createdAt, +}: { + authorId: number; + isShowcase?: 0 | 1; + createdAt?: number; +}) => { + const imgId = await createUserSubmittedImage(authorId); + + await db + .insertInto("Art") + .values({ + authorId, + imgId, + isShowcase, + description: null, + createdAt: createdAt ?? Date.now(), + }) + .execute(); +}; + +describe("findShowcaseArts", () => { + beforeEach(async () => { + imageCounter = 0; + await dbInsertUsers(5); + }); + + afterEach(() => { + dbReset(); + }); + + test("shows one art per artist when all have showcase art", async () => { + await createArt({ authorId: 1, isShowcase: 1 }); + await createArt({ authorId: 2, isShowcase: 1 }); + await createArt({ authorId: 3, isShowcase: 1 }); + + const result = await ArtRepository.findShowcaseArts(); + + expect(result).toHaveLength(3); + const authorIds = result.map((art) => art.author?.discordId); + expect(new Set(authorIds).size).toBe(3); + }); + + test("prioritizes showcase art over regular art for same artist", async () => { + const now = Date.now(); + + await createArt({ authorId: 1, isShowcase: 0, createdAt: now + 1000 }); + await createArt({ authorId: 1, isShowcase: 1, createdAt: now }); + + const result = await ArtRepository.findShowcaseArts(); + + expect(result).toHaveLength(1); + expect(result[0].createdAt).toBe(now); + }); + + test("shows most recent art for artist without showcase art", async () => { + const now = Date.now(); + + await createArt({ authorId: 1, isShowcase: 0, createdAt: now }); + await createArt({ authorId: 1, isShowcase: 0, createdAt: now + 1000 }); + + const result = await ArtRepository.findShowcaseArts(); + + expect(result).toHaveLength(1); + expect(result[0].createdAt).toBe(now + 1000); + }); + + test("shows only one art per artist even with multiple showcase pieces", async () => { + const now = Date.now(); + + await createArt({ authorId: 1, isShowcase: 1, createdAt: now }); + await createArt({ authorId: 1, isShowcase: 1, createdAt: now + 1000 }); + await createArt({ authorId: 1, isShowcase: 1, createdAt: now + 2000 }); + + const result = await ArtRepository.findShowcaseArts(); + + expect(result).toHaveLength(1); + expect(result[0].createdAt).toBe(now + 2000); + }); + + test("handles mix of artists with and without showcase art", async () => { + const now = Date.now(); + + await createArt({ authorId: 1, isShowcase: 1, createdAt: now }); + await createArt({ authorId: 2, isShowcase: 0, createdAt: now + 1000 }); + await createArt({ authorId: 2, isShowcase: 0, createdAt: now + 2000 }); + await createArt({ authorId: 3, isShowcase: 1, createdAt: now + 3000 }); + + const result = await ArtRepository.findShowcaseArts(); + + expect(result).toHaveLength(3); + + const author1Art = result.find((art) => art.author?.discordId === "0"); + const author2Art = result.find((art) => art.author?.discordId === "1"); + + expect(author1Art?.createdAt).toBe(now); + expect(author2Art?.createdAt).toBe(now + 2000); + }); + + test("returns empty array when no art exists", async () => { + const result = await ArtRepository.findShowcaseArts(); + + expect(result).toHaveLength(0); + }); +}); + +describe("findAllTags", () => { + beforeEach(async () => { + await dbInsertUsers(1); + }); + + afterEach(() => { + dbReset(); + }); + + test("returns all art tags", async () => { + await db + .insertInto("ArtTag") + .values([ + { authorId: 1, name: "Character" }, + { authorId: 1, name: "Weapon" }, + { authorId: 1, name: "Landscape" }, + ]) + .execute(); + + const result = await ArtRepository.findAllTags(); + + expect(result).toHaveLength(3); + expect(result.map((t) => t.name).sort()).toEqual([ + "Character", + "Landscape", + "Weapon", + ]); + }); + + test("returns empty array when no tags exist", async () => { + const result = await ArtRepository.findAllTags(); + + expect(result).toHaveLength(0); + }); +}); diff --git a/app/features/art/ArtRepository.server.ts b/app/features/art/ArtRepository.server.ts index ea8fb3ede..a08cae009 100644 --- a/app/features/art/ArtRepository.server.ts +++ b/app/features/art/ArtRepository.server.ts @@ -33,26 +33,41 @@ export async function findShowcaseArts(): Promise { .select([ "Art.id", "Art.createdAt", + "User.id as userId", "User.discordId", "User.username", "User.discordAvatar", "User.commissionsOpen", "UserSubmittedImage.url", ]) - .where("Art.isShowcase", "=", 1) + .orderBy("Art.isShowcase", "desc") + .orderBy("Art.createdAt", "desc") + .orderBy("User.id", "asc") .execute(); - const mappedArts = arts.map((a) => ({ - id: a.id, - createdAt: a.createdAt, - url: a.url, - author: { - commissionsOpen: a.commissionsOpen, - discordAvatar: a.discordAvatar, - discordId: a.discordId, - username: a.username, - }, - })); + const encounteredUserIds = new Set(); + + const mappedArts = arts + .filter((row) => { + if (encounteredUserIds.has(row.userId)) { + return false; + } + + encounteredUserIds.add(row.userId); + + return true; + }) + .map((a) => ({ + id: a.id, + createdAt: a.createdAt, + url: a.url, + author: { + commissionsOpen: a.commissionsOpen, + discordAvatar: a.discordAvatar, + discordId: a.discordId, + username: a.username, + }, + })); const { seededShuffle } = seededRandom(getDailySeed()); return seededShuffle(mappedArts); @@ -136,3 +151,7 @@ export async function findRecentlyUploadedArts(): Promise { }, })); } + +export async function findAllTags() { + return db.selectFrom("ArtTag").select(["id", "name"]).execute(); +} diff --git a/app/features/art/loaders/art.new.server.ts b/app/features/art/loaders/art.new.server.ts index 206e57191..48630ee90 100644 --- a/app/features/art/loaders/art.new.server.ts +++ b/app/features/art/loaders/art.new.server.ts @@ -1,7 +1,7 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; import { requireUser } from "~/features/auth/core/user.server"; +import * as ArtRepository from "../ArtRepository.server"; import { NEW_ART_EXISTING_SEARCH_PARAM_KEY } from "../art-constants"; -import { allArtTags } from "../queries/allArtTags.server"; import { findArtById } from "../queries/findArtById.server"; export const loader = async ({ request }: LoaderFunctionArgs) => { @@ -10,13 +10,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const artIdRaw = new URL(request.url).searchParams.get( NEW_ART_EXISTING_SEARCH_PARAM_KEY, ); - if (!artIdRaw) return { art: null, tags: allArtTags() }; + if (!artIdRaw) return { art: null, tags: await ArtRepository.findAllTags() }; const artId = Number(artIdRaw); const art = findArtById(artId); if (!art || art.authorId !== user.id) { - return { art: null, tags: allArtTags() }; + return { art: null, tags: await ArtRepository.findAllTags() }; } - return { art, tags: allArtTags() }; + return { art, tags: await ArtRepository.findAllTags() }; }; diff --git a/app/features/art/loaders/art.server.ts b/app/features/art/loaders/art.server.ts index 350a7a3e6..63a19f5e5 100644 --- a/app/features/art/loaders/art.server.ts +++ b/app/features/art/loaders/art.server.ts @@ -1,21 +1,35 @@ +import cachified from "@epic-web/cachified"; import type { LoaderFunctionArgs } from "@remix-run/node"; +import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server"; import * as ArtRepository from "../ArtRepository.server"; import { FILTERED_TAG_KEY_SEARCH_PARAM_KEY } from "../art-constants"; -import { allArtTags } from "../queries/allArtTags.server"; export const loader = async ({ request }: LoaderFunctionArgs) => { - const allTags = allArtTags(); + const cachedArts = await cachified({ + key: "arts", + cache, + ttl: ttl(IN_MILLISECONDS.TWO_HOURS), + async getFreshValue() { + return { + showcaseArts: await ArtRepository.findShowcaseArts(), + recentlyUploadedArts: await ArtRepository.findRecentlyUploadedArts(), + allTags: await ArtRepository.findAllTags(), + }; + }, + }); const filteredTagName = new URL(request.url).searchParams.get( FILTERED_TAG_KEY_SEARCH_PARAM_KEY, ); - const filteredTag = allTags.find((t) => t.name === filteredTagName); + + const filteredTag = filteredTagName + ? cachedArts.allTags.find((t) => t.name === filteredTagName) + : null; + + if (!filteredTag) return cachedArts; return { - showcaseArts: filteredTag - ? await ArtRepository.findShowcaseArtsByTag(filteredTag.id) - : await ArtRepository.findShowcaseArts(), - recentlyUploadedArts: await ArtRepository.findRecentlyUploadedArts(), - allTags, + ...cachedArts, + showcaseArts: await ArtRepository.findShowcaseArtsByTag(filteredTag.id), }; }; diff --git a/app/features/art/queries/allArtTags.server.ts b/app/features/art/queries/allArtTags.server.ts deleted file mode 100644 index be6b3f03f..000000000 --- a/app/features/art/queries/allArtTags.server.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { sql } from "~/db/sql"; -import type { Tables } from "~/db/tables"; - -const stm = sql.prepare(/* sql */ ` - select - "id", - "name" - from - "ArtTag" -`); - -export function allArtTags(): Array> { - return stm.all() as any; -}