Fix artists missing from showcase if no showcase art set, caching & Kysely migration

Closes #2591
This commit is contained in:
Kalle 2025-10-27 21:00:23 +02:00
parent b059de162f
commit a2d8cb2936
5 changed files with 224 additions and 38 deletions

View File

@ -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);
});
});

View File

@ -33,26 +33,41 @@ export async function findShowcaseArts(): Promise<ListedArt[]> {
.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<number>();
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<ListedArt[]> {
},
}));
}
export async function findAllTags() {
return db.selectFrom("ArtTag").select(["id", "name"]).execute();
}

View File

@ -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() };
};

View File

@ -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),
};
};

View File

@ -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<Pick<Tables["ArtTag"], "id" | "name">> {
return stm.all() as any;
}