From 830c31d13c2a9f9e2b87712e6bb9f0bc35553b5b Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 8 Aug 2026 14:01:18 +0300 Subject: [PATCH] Bust UserCard cache when banner changed --- .../UserCardRepository.server.test.ts | 33 +++++++++++++------ .../user-card/UserCardRepository.server.ts | 11 +++++-- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/app/features/user-card/UserCardRepository.server.test.ts b/app/features/user-card/UserCardRepository.server.test.ts index e34fedb98..38eabb952 100644 --- a/app/features/user-card/UserCardRepository.server.test.ts +++ b/app/features/user-card/UserCardRepository.server.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import * as ImageFactory from "~/db/seed/factories/ImageFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as XRankPlacementFactory from "~/db/seed/factories/XRankPlacementFactory"; +import { db } from "~/db/sql"; import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server"; import { withNoUser, withUserId } from "~/utils/Test"; import * as UserCardRepository from "./UserCardRepository.server"; @@ -396,15 +397,8 @@ describe("UserCardRepository.findAllByUserIdsCached", () => { const first = await cachedCard(target.id); expect(first.userCards.get(target.id)?.shortBio).toBeNull(); - await withUserId(target.id, () => - UserCardRepository.updateOwnCard({ - shortBio: "edited", - bannerPresetImg: null, - bannerImgId: null, - unverifiedPeakXP: null, - hiddenCardStats: [], - }), - ); + // written past the repository so the cached card is left in place + await updateShortBioDirectly(target.id, "edited"); const { userCards } = await withNoUser(() => UserCardRepository.findAllByUserIdsCached({ @@ -419,16 +413,26 @@ describe("UserCardRepository.findAllByUserIdsCached", () => { it("returns a fresh card once the cached one has expired", async () => { await cachedCard(target.id); + await updateShortBioDirectly(target.id, "edited"); + UserCardRepository.clearUserCardCache(); + + const { userCards } = await cachedCard(target.id); + expect(userCards.get(target.id)?.shortBio).toBe("edited"); + }); + + it("returns a fresh card right after its owner edited it", async () => { + await cachedCard(target.id); + await withUserId(target.id, () => UserCardRepository.updateOwnCard({ shortBio: "edited", bannerPresetImg: null, bannerImgId: null, unverifiedPeakXP: null, + xpDivision: null, hiddenCardStats: [], }), ); - UserCardRepository.clearUserCardCache(); const { userCards } = await cachedCard(target.id); expect(userCards.get(target.id)?.shortBio).toBe("edited"); @@ -446,3 +450,12 @@ describe("UserCardRepository.findAllByUserIdsCached", () => { ); }); }); + +function updateShortBioDirectly(userId: number, shortBio: string) { + // biome-ignore lint/plugin: updateOwnCard invalidates the cached card, which these tests need to leave in place + return db + .updateTable("User") + .set({ shortBio }) + .where("id", "=", userId) + .execute(); +} diff --git a/app/features/user-card/UserCardRepository.server.ts b/app/features/user-card/UserCardRepository.server.ts index 19f1af0c8..52140477d 100644 --- a/app/features/user-card/UserCardRepository.server.ts +++ b/app/features/user-card/UserCardRepository.server.ts @@ -273,8 +273,11 @@ export async function findVerifiedXpByUserId( return verifiedXp(row?.xpPeaks ?? null, xpDivision); } -/** Updates the editable user card fields of the acting user (their own card). */ -export function updateOwnCard(args: { +/** + * Updates the editable user card fields of the acting user (their own card), dropping their + * cached card so they are not shown their pre-edit one by {@link findAllByUserIdsCached}. + */ +export async function updateOwnCard(args: { shortBio: string | null; bannerPresetImg: string | null; bannerImgId: number | null; @@ -284,7 +287,7 @@ export function updateOwnCard(args: { hiddenCardStats: Array; }) { const userId = actorId(); - return db.transaction().execute(async (trx) => { + await db.transaction().execute(async (trx) => { // a removed or replaced uploaded banner is no longer referenced by anything, // so its submitted image row is cleaned up (mirrors custom avatar handling) const current = await trx @@ -318,6 +321,8 @@ export function updateOwnCard(args: { .where("id", "=", userId) .execute(); }); + + cardCache.delete(userId); } /** SQLite `case` expression mapping `User.id % PRESET_COLORS.length` to a preset banner color. */