mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
perf(shop): count sold per page instead of aggregating the whole purchases table (#4974)
* perf(shop): count sold per page instead of aggregating the whole purchases table Prisma resolves a relation _count by aggregating all of UserCosmeticShopPurchases once per query, so every shop read paid for the whole table regardless of page size, and MostPopular paid twice (value and orderBy). The display reads now take the count from one query restricted to the ids they returned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(shop): pin the sold-count statement and the call-site selects The fake answered by table name alone, so a renamed alias, a dropped int cast or the wrong WHERE column all stayed green. Rows are now projected onto the statement's SELECT list and the statement is pinned once. Each read that dropped the whole-table _count now asserts it at its call site, and getCreatorShopManageItems gets its first tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(shop): pin per-item sold mapping on multi-item pages and the resale filter Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cosmeticShopItemSelect, withSoldCount } from '~/server/selectors/cosmetic-shop.selector';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { dbMock } from '~/__tests__/mocks/db.mock';
|
||||
import { cosmeticShopItemSelect } from '~/server/selectors/cosmetic-shop.selector';
|
||||
import {
|
||||
getSoldCounts,
|
||||
withSoldCount,
|
||||
withSoldCounts,
|
||||
} from '~/server/services/cosmetic-shop-sold-count';
|
||||
import { soldCountsFake } from '~/test-utils/soldCountsFake';
|
||||
|
||||
/**
|
||||
* The read paths that hand `meta` to the client as-is — /shop's sections, the
|
||||
@@ -18,28 +25,27 @@ describe('withSoldCount writes the row count onto the key clients read', () => {
|
||||
// Prod item 74, "Fairy Pony (Limited Edition)": quantity 20, counter 0,
|
||||
// twenty purchase rows. The counter renders "20 remaining" on a sold-out
|
||||
// item behind a buy button that throws.
|
||||
const out = withSoldCount({ meta: { purchases: 0 }, _count: { purchases: 20 } });
|
||||
const out = withSoldCount({ meta: { purchases: 0 } }, 20);
|
||||
expect(out.meta.purchases).toBe(20);
|
||||
});
|
||||
|
||||
it('overwrites a counter that overstates the rows', () => {
|
||||
const out = withSoldCount({ meta: { purchases: 737 }, _count: { purchases: 732 } });
|
||||
const out = withSoldCount({ meta: { purchases: 737 } }, 732);
|
||||
expect(out.meta.purchases).toBe(732);
|
||||
});
|
||||
|
||||
it('writes the count onto a null meta rather than dropping the key', () => {
|
||||
// Non-zero on purpose: with 0 here the case also passes under a reversed
|
||||
// spread order, which is the mutation that silently restores the counter.
|
||||
const out = withSoldCount({ meta: null, _count: { purchases: 4 } });
|
||||
const out = withSoldCount({ meta: null }, 4);
|
||||
expect(out.meta.purchases).toBe(4);
|
||||
});
|
||||
|
||||
it('keeps the rest of meta, which is what the card and checkout render', () => {
|
||||
const out = withSoldCount({
|
||||
id: 74,
|
||||
meta: { purchases: 0, acceptsBlueBuzz: true, coverUrl: 'cover.png' },
|
||||
_count: { purchases: 3 },
|
||||
});
|
||||
const out = withSoldCount(
|
||||
{ id: 74, meta: { purchases: 0, acceptsBlueBuzz: true, coverUrl: 'cover.png' } },
|
||||
3
|
||||
);
|
||||
expect(out.meta).toEqual({ purchases: 3, acceptsBlueBuzz: true, coverUrl: 'cover.png' });
|
||||
// An impl returning only `{ meta }` and dropping `...item` passes every
|
||||
// assertion above this one.
|
||||
@@ -48,19 +54,69 @@ describe('withSoldCount writes the row count onto the key clients read', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* The helper and the sanitizers read `item._count.purchases`. Nothing else in
|
||||
* the suite checks that the QUERY asks for it: Prisma mocks ignore `select` and
|
||||
* every fixture hand-writes `_count`, so deleting the select line leaves every
|
||||
* test green and throws on six read paths in production.
|
||||
*
|
||||
* TO WHOEVER IS ABOUT TO DELETE THIS: it looks redundant beside the behaviour
|
||||
* tests and is not. Reverting the select line without this reddens nothing.
|
||||
* TO WHOEVER IS ABOUT TO ADD `_count` BACK TO THE SHARED SELECTOR: Prisma
|
||||
* resolves a relation `_count` by aggregating the WHOLE purchases table once per
|
||||
* query, so its cost tracks the table rather than the page. Every read of this
|
||||
* selector gets its sold count from `getSoldCounts`, restricted to the ids it
|
||||
* returned.
|
||||
*/
|
||||
describe('the selects actually ask for the purchase count', () => {
|
||||
it('is on the shared selector, which feeds every storefront and /shop read', () => {
|
||||
expect(cosmeticShopItemSelect._count).toEqual({ select: { purchases: true } });
|
||||
describe('the shared selector carries no whole-table purchase aggregate', () => {
|
||||
it('has no `_count`', () => {
|
||||
expect('_count' in cosmeticShopItemSelect).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSoldCounts', () => {
|
||||
beforeEach(() => {
|
||||
dbMock.dbRead.$queryRaw.mockReset();
|
||||
dbMock.dbRead.$queryRaw.mockImplementation(soldCountsFake({ 1: 3, 2: 9 }));
|
||||
});
|
||||
|
||||
// getPackDetail's own select is pinned in pack-detail-agreement.test.ts, which
|
||||
// already mocks the query it emits.
|
||||
it('asks for exactly the ids it was given, once each', async () => {
|
||||
const sold = await getSoldCounts([2, 1, 2, 5]);
|
||||
|
||||
expect(dbMock.dbRead.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(dbMock.dbRead.$queryRaw.mock.calls[0].slice(1)).toEqual([[2, 1, 5]]);
|
||||
expect([...sold]).toEqual([
|
||||
[2, 9],
|
||||
[1, 3],
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The service tests all answer through `soldCountsFake`, which cannot tell a count
|
||||
* of purchases from a count of buyers, the right column from the wrong one, or an
|
||||
* int4 from Postgres' int8 `COUNT(*)` — which Prisma returns as a BigInt and which
|
||||
* then throws in `availableQuantity - purchases`. This is the one place that sees
|
||||
* the statement itself.
|
||||
*/
|
||||
it('emits exactly the per-item purchase count, cast to int', async () => {
|
||||
await getSoldCounts([1]);
|
||||
|
||||
const strings = dbMock.dbRead.$queryRaw.mock.calls[0][0] as string[];
|
||||
expect(strings.join('$1').replace(/\s+/g, ' ').trim()).toBe(
|
||||
'SELECT "shopItemId", COUNT(*)::int AS sold FROM "UserCosmeticShopPurchases" ' +
|
||||
'WHERE "shopItemId" = ANY($1::int[]) GROUP BY "shopItemId"'
|
||||
);
|
||||
});
|
||||
|
||||
// `withSoldCounts` backs the moderator paged list and the item editor. One item
|
||||
// alone passes an impl that hands every item the first one's count.
|
||||
it('withSoldCounts gives each item its own count, and 0 for none', async () => {
|
||||
const out = await withSoldCounts([
|
||||
{ id: 1, meta: {} },
|
||||
{ id: 2, meta: {} },
|
||||
{ id: 3, meta: { purchases: 6 } },
|
||||
]);
|
||||
expect(out.map((i) => [i.id, i.meta.purchases])).toEqual([
|
||||
[1, 3],
|
||||
[2, 9],
|
||||
[3, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips the query for an empty page', async () => {
|
||||
expect((await getSoldCounts([])).size).toBe(0);
|
||||
expect(dbMock.dbRead.$queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,22 +26,4 @@ export const cosmeticShopItemSelect = Prisma.validator<Prisma.CosmeticShopItemSe
|
||||
// A pack has no cosmetic, so cards attribute it to its lister instead.
|
||||
addedBy: { select: userWithCosmeticsSelect },
|
||||
meta: true,
|
||||
// How many actually sold. `meta.purchases` is a denormalised counter that
|
||||
// drifts — it is bumped outside the purchase transaction, so concurrent buys
|
||||
// lose increments and a rolled-back buy keeps one. The sold-out gate and the
|
||||
// MostPopular sort have always counted rows; this is what lets the displayed
|
||||
// number agree with them.
|
||||
_count: { select: { purchases: true } },
|
||||
});
|
||||
|
||||
/**
|
||||
* For the editor reads, which hand back the whole `meta` rather than the display
|
||||
* list. They read `meta.purchases`, so the row count has to land on that key or
|
||||
* an editor shows the drifting counter while every buyer surface shows rows.
|
||||
*/
|
||||
export const withSoldCount = <T extends { meta: unknown; _count: { purchases: number } }>(
|
||||
item: T
|
||||
) => ({
|
||||
...item,
|
||||
meta: { ...((item.meta ?? {}) as Record<string, unknown>), purchases: item._count.purchases },
|
||||
});
|
||||
|
||||
@@ -264,15 +264,10 @@ describe('upsertCosmeticShopItem — the stored purchase counter', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* The save's response is deliberately NOT passed through `withSoldCount`,
|
||||
* unlike the editor reads. Its only consumer invalidates the paged query and
|
||||
* discards the payload, so mapping it fixed nothing and pinned a value nobody
|
||||
* reads — which would have handed the next person a red test for correctly
|
||||
* deleting dead code.
|
||||
*
|
||||
* Records the decision. It is not the gate: the write path's select has
|
||||
* `_count` destructured off, so passing that row to `withSoldCount` fails its
|
||||
* `_count: { purchases: number }` constraint at compile time.
|
||||
* The save's response is deliberately NOT passed through `withSoldCounts`,
|
||||
* unlike every read path. Its only consumer invalidates the paged query and
|
||||
* discards the payload, so mapping it would spend a query on a value nobody
|
||||
* reads.
|
||||
*/
|
||||
it('hands back what it wrote, not a row-derived count', async () => {
|
||||
const saved = await upsert({ meta: { purchases: 99 } });
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock('~/server/services/user-preferences.service', () => ({
|
||||
|
||||
import { getShopSectionsWithItems } from '../cosmetic-shop.service';
|
||||
import { dbMock } from '~/__tests__/mocks/db.mock';
|
||||
import { soldCountsFake } from '~/test-utils/soldCountsFake';
|
||||
|
||||
dbMock.dbRead.cosmeticShopSection.findMany.mockImplementation((...args: unknown[]) =>
|
||||
(mocks.sectionFindMany as (...a: unknown[]) => unknown)(...args)
|
||||
@@ -75,8 +76,6 @@ const sectionRow = {
|
||||
addedById: 999,
|
||||
cosmetic: { id: 10, createdById: null },
|
||||
meta: storedMeta,
|
||||
// Deliberately not the stored counter (12): the cards show sold rows.
|
||||
_count: { purchases: 5 },
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -93,6 +92,8 @@ describe('the shop section list publishes only the card fields of an item meta',
|
||||
mocks.sectionFindMany.mockResolvedValue([sectionRow]);
|
||||
mocks.getBlockedPairIds.mockReset();
|
||||
mocks.getBlockedPairIds.mockResolvedValue([]);
|
||||
// Deliberately not the stored counter (12): the cards show sold rows.
|
||||
dbMock.dbRead.$queryRaw.mockImplementation(soldCountsFake({ 42: 5 }));
|
||||
});
|
||||
|
||||
it('returns exactly the display keys to an anonymous viewer', async () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock('~/server/prom/client', async (importOriginal) => ({
|
||||
|
||||
import { getPaginatedCosmeticShopItems, getShopItemById } from '../cosmetic-shop.service';
|
||||
import { dbMock } from '~/__tests__/mocks/db.mock';
|
||||
import { soldCountsFake } from '~/test-utils/soldCountsFake';
|
||||
|
||||
const capturedWhere = () =>
|
||||
dbMock.dbRead.cosmeticShopItem.findMany.mock.calls[0][0].where as Record<string, unknown>;
|
||||
@@ -68,7 +69,7 @@ describe('getPaginatedCosmeticShopItems archived filter', () => {
|
||||
|
||||
/**
|
||||
* Both of these return `meta` to the client as-is, so the row count is written
|
||||
* onto `meta.purchases` by `withSoldCount` rather than by a whitelist. Nothing
|
||||
* onto `meta.purchases` by `withSoldCounts` rather than by a whitelist. Nothing
|
||||
* else executes those two call sites: removing either mapping leaves every other
|
||||
* suite green.
|
||||
*
|
||||
@@ -80,7 +81,6 @@ describe('the unsanitized read paths serve the row count', () => {
|
||||
id: 74,
|
||||
title: 'Fairy Pony',
|
||||
meta: { purchases: 0 },
|
||||
_count: { purchases: 20 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -89,6 +89,7 @@ describe('the unsanitized read paths serve the row count', () => {
|
||||
dbMock.dbRead.cosmeticShopItem.count.mockResolvedValue(1);
|
||||
dbMock.dbRead.cosmeticShopItem.findUniqueOrThrow.mockReset();
|
||||
dbMock.dbWrite.cosmeticShopItem.findUniqueOrThrow.mockReset();
|
||||
dbMock.dbRead.$queryRaw.mockImplementation(soldCountsFake({ 74: 20 }));
|
||||
});
|
||||
|
||||
it('getPaginatedCosmeticShopItems reports the rows, not the counter', async () => {
|
||||
@@ -97,12 +98,18 @@ describe('the unsanitized read paths serve the row count', () => {
|
||||
const { items } = await getPaginatedCosmeticShopItems({ page: 1, limit: 60 });
|
||||
|
||||
expect(items[0].meta.purchases).toBe(20);
|
||||
// Mocks ignore `select`: without this, re-adding the whole-table `_count` at this
|
||||
// call site leaves every value assertion green.
|
||||
expect(dbMock.dbRead.cosmeticShopItem.findMany.mock.calls[0][0].select._count).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getShopItemById reports the rows, not the counter', async () => {
|
||||
dbMock.dbRead.cosmeticShopItem.findUniqueOrThrow.mockResolvedValue(drifted);
|
||||
|
||||
expect((await getShopItemById({ id: 74 })).meta.purchases).toBe(20);
|
||||
expect(
|
||||
dbMock.dbRead.cosmeticShopItem.findUniqueOrThrow.mock.calls[0][0].select._count
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
// The mapping sits AFTER the `.catch`, so the replica-fallback result is
|
||||
|
||||
@@ -30,6 +30,7 @@ import { PACK_FILTER_VALUE } from '~/server/schema/creator-shop.schema';
|
||||
import { getSectionById, getShopSectionsWithItems } from '../cosmetic-shop.service';
|
||||
import { loggingMock } from '~/__tests__/mocks/logging.mock';
|
||||
import { dbMock } from '~/__tests__/mocks/db.mock';
|
||||
import { soldCountsFake } from '~/test-utils/soldCountsFake';
|
||||
dbMock.dbRead.cosmeticShopSection.findMany.mockImplementation((...args: unknown[]) =>
|
||||
(mocks.sectionFindMany as (...a: unknown[]) => unknown)(...args)
|
||||
);
|
||||
@@ -49,7 +50,6 @@ const officialItem = {
|
||||
// The counter and the rows disagree on purpose: /shop must publish the row
|
||||
// count, never the stored counter.
|
||||
meta: { purchases: 2 },
|
||||
_count: { purchases: 5 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -109,8 +109,31 @@ describe('getShopSectionsWithItems viewer gating', () => {
|
||||
});
|
||||
|
||||
it('serves the purchase rows as the sold count, not the meta counter', async () => {
|
||||
// Several items across two sections with distinct counts: one item alone
|
||||
// passes an impl that hands every item the first one's count.
|
||||
const withItem = (id: number) => ({
|
||||
...officialItem,
|
||||
shopItem: { ...officialItem.shopItem, id },
|
||||
});
|
||||
mocks.sectionFindMany.mockResolvedValue([
|
||||
{ ...sectionRow, items: [officialItem, withItem(2)] },
|
||||
{ ...sectionRow, id: 6, items: [withItem(3)] },
|
||||
]);
|
||||
dbMock.dbRead.$queryRaw.mockImplementation(soldCountsFake({ 1: 5, 2: 8, 3: 11 }));
|
||||
const sections = await getShopSectionsWithItems({});
|
||||
expect(sections[0].items[0].shopItem.meta.purchases).toBe(5);
|
||||
expect(
|
||||
sections.map((s) => s.items.map((i) => [i.shopItem.id, i.shopItem.meta.purchases]))
|
||||
).toEqual([
|
||||
[
|
||||
[1, 5],
|
||||
[2, 8],
|
||||
],
|
||||
[[3, 11]],
|
||||
]);
|
||||
// Mocks ignore `select`, so only this sees a whole-table `_count` re-added here.
|
||||
expect(
|
||||
mocks.sectionFindMany.mock.calls[0][0].select.items.select.shopItem.select._count
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('non-mod with the creatorShop flag: creator items are not filtered out, status guard stays', async () => {
|
||||
@@ -227,11 +250,19 @@ describe('getSectionById serves the row count too', () => {
|
||||
id: 5,
|
||||
title: 'Badges',
|
||||
image: null,
|
||||
items: [{ shopItem: { id: 74, meta: { purchases: 2 }, _count: { purchases: 5 } } }],
|
||||
items: [
|
||||
{ shopItem: { id: 74, meta: { purchases: 2 } } },
|
||||
{ shopItem: { id: 75, meta: { purchases: 2 } } },
|
||||
],
|
||||
});
|
||||
dbMock.dbRead.$queryRaw.mockImplementation(soldCountsFake({ 74: 5, 75: 9 }));
|
||||
|
||||
const section = await getSectionById({ id: 5 });
|
||||
|
||||
expect(section.items[0].shopItem.meta.purchases).toBe(5);
|
||||
expect(section.items.map((i) => i.shopItem.meta.purchases)).toEqual([5, 9]);
|
||||
expect(
|
||||
dbMock.dbRead.cosmeticShopSection.findUniqueOrThrow.mock.calls.at(-1)?.[0].select.items.select
|
||||
.shopItem.select._count
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock('~/server/services/user-preferences.service', () => ({
|
||||
import { CosmeticShopSort } from '~/server/common/enums';
|
||||
import { getCommunityCosmetics } from '../creator-shop.service';
|
||||
import { dbMock } from '~/__tests__/mocks/db.mock';
|
||||
import { soldCountsFake } from '~/test-utils/soldCountsFake';
|
||||
dbMock.dbRead.cosmeticShopItem.findMany.mockImplementation((...args: unknown[]) =>
|
||||
(mocks.shopItemFindMany as (...a: unknown[]) => unknown)(...args)
|
||||
);
|
||||
@@ -44,9 +45,6 @@ const itemRow = (id: number, meta: Record<string, unknown> = {}) => ({
|
||||
addedById: 11,
|
||||
meta: { purchases: 3, submissionTxId: 'tx-1', sellerShare: 20, imageHash: 'abc', ...meta },
|
||||
cosmetic: { id: id * 10, name: `Cosmetic ${id}`, type: 'Badge', createdById: 11 },
|
||||
// Deliberately disagrees with `meta.purchases` above: the rows are the sold
|
||||
// count, and a fixture where the two agree passes under either derivation.
|
||||
_count: { purchases: 7 },
|
||||
});
|
||||
|
||||
const baseInput = { limit: 40, page: 1, sort: CosmeticShopSort.Newest };
|
||||
@@ -66,22 +64,36 @@ describe('getCommunityCosmetics', () => {
|
||||
it('strips payout/fee internals from item meta', async () => {
|
||||
mocks.shopItemFindMany.mockResolvedValue([itemRow(2), itemRow(1)]);
|
||||
mocks.shopItemCount.mockResolvedValue(2);
|
||||
// Disagrees with the fixture's `meta.purchases` of 3: the rows are the sold
|
||||
// count, and a fixture where the two agree passes under either derivation.
|
||||
dbMock.dbRead.$queryRaw.mockImplementation(soldCountsFake({ 2: 7, 1: 4 }));
|
||||
const { items, totalPages } = await getCommunityCosmetics(baseInput);
|
||||
expect(items.map((i) => i.id)).toEqual([2, 1]);
|
||||
expect(items[0].meta).toEqual({ purchases: 7, acceptsBlueBuzz: false });
|
||||
// `toEqual` skips undefined-valued keys; the key list does not.
|
||||
expect(Object.keys(items[0].meta).sort()).toEqual(['acceptsBlueBuzz', 'purchases']);
|
||||
// `creatorStorefrontItemSelect` inherits `_count` by spreading the shared
|
||||
// selector and never restates it. Redefining `_count` there for another
|
||||
// relation is already a compile error at all three read sites, so this is a
|
||||
// readable second signal rather than the gate — it names the relation the
|
||||
// storefront depends on, which `TS2339` does not.
|
||||
expect(mocks.shopItemFindMany.mock.calls[0][0].select._count).toEqual({
|
||||
select: { purchases: true },
|
||||
});
|
||||
expect(items[1].meta.purchases).toBe(4);
|
||||
expect(totalPages).toBe(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* TO WHOEVER IS ABOUT TO PUT `_count` BACK ON THE SELECT: Prisma resolves a
|
||||
* relation `_count` by aggregating the WHOLE purchases table, once per query.
|
||||
* Under MostPopular the `orderBy` already carries one of those, and Postgres
|
||||
* does not dedupe a second. The value comes from `getSoldCounts`, restricted
|
||||
* to this page's ids.
|
||||
*/
|
||||
it('reads the sold count for the page only, never via a whole-table `_count`', async () => {
|
||||
mocks.shopItemFindMany.mockResolvedValue([itemRow(2), itemRow(1)]);
|
||||
await getCommunityCosmetics({ ...baseInput, sort: CosmeticShopSort.MostPopular });
|
||||
expect(mocks.shopItemFindMany.mock.calls[0][0].select._count).toBeUndefined();
|
||||
const soldCall = dbMock.dbRead.$queryRaw.mock.calls.find((c) =>
|
||||
(c[0] as string[]).join('').includes('"UserCosmeticShopPurchases"')
|
||||
);
|
||||
expect(soldCall).toBeDefined();
|
||||
expect(soldCall?.slice(1)).toEqual([[2, 1]]);
|
||||
});
|
||||
|
||||
it('pages by skip/take and reports the page count from the total', async () => {
|
||||
mocks.shopItemFindMany.mockResolvedValue([itemRow(3), itemRow(2)]);
|
||||
mocks.shopItemCount.mockResolvedValue(7);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('sharp', () => ({ default: vi.fn() }));
|
||||
vi.mock('~/server/services/buzz.service', () => ({
|
||||
createBuzzTransaction: vi.fn(),
|
||||
refundTransaction: vi.fn(),
|
||||
}));
|
||||
vi.mock('~/server/services/creator-program.service', () => ({
|
||||
hasValidCreatorMembership: vi.fn(),
|
||||
}));
|
||||
vi.mock('~/server/services/notification.service', () => ({ createNotification: vi.fn() }));
|
||||
|
||||
import { getCreatorShopManageItems } from '../creator-shop.service';
|
||||
import { dbMock } from '~/__tests__/mocks/db.mock';
|
||||
import { soldCountsFake } from '~/test-utils/soldCountsFake';
|
||||
|
||||
const row = (id: number, availableQuantity: number | null) => ({
|
||||
id,
|
||||
availableQuantity,
|
||||
// The stored counter, deliberately unlike the rows below.
|
||||
meta: { purchases: 1 },
|
||||
_count: { resales: 2 },
|
||||
});
|
||||
|
||||
// The creator's own inventory view: the sold count drives "remaining" and the
|
||||
// sold-out state, so a wrong count here tells a creator stock they don't have.
|
||||
describe('getCreatorShopManageItems', () => {
|
||||
beforeEach(() => {
|
||||
dbMock.dbRead.cosmeticShopItem.findMany.mockReset();
|
||||
dbMock.dbRead.$queryRaw.mockReset();
|
||||
});
|
||||
|
||||
it('derives purchases, remaining and sold-out from the purchase rows', async () => {
|
||||
dbMock.dbRead.cosmeticShopItem.findMany.mockResolvedValue([
|
||||
row(1, 10),
|
||||
row(2, 7),
|
||||
row(3, null),
|
||||
]);
|
||||
dbMock.dbRead.$queryRaw.mockImplementation(soldCountsFake({ 1: 7, 2: 7, 3: 4 }));
|
||||
|
||||
const items = await getCreatorShopManageItems({ userId: 11 });
|
||||
|
||||
expect(
|
||||
items.map(({ id, purchases, remaining, soldOut, resellerCount }) => ({
|
||||
id,
|
||||
purchases,
|
||||
remaining,
|
||||
soldOut,
|
||||
resellerCount,
|
||||
}))
|
||||
).toEqual([
|
||||
{ id: 1, purchases: 7, remaining: 3, soldOut: false, resellerCount: 2 },
|
||||
{ id: 2, purchases: 7, remaining: 0, soldOut: true, resellerCount: 2 },
|
||||
{ id: 3, purchases: 4, remaining: null, soldOut: false, resellerCount: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('never asks Prisma for the whole-table purchase aggregate', async () => {
|
||||
dbMock.dbRead.cosmeticShopItem.findMany.mockResolvedValue([]);
|
||||
await getCreatorShopManageItems({ userId: 11 });
|
||||
|
||||
// Also pins the deleted-account filter: the fixture's `resellerCount` is
|
||||
// hand-written, so nothing else sees it go.
|
||||
expect(dbMock.dbRead.cosmeticShopItem.findMany.mock.calls[0][0].select._count).toEqual({
|
||||
select: { resales: { where: { user: { deletedAt: null } } } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
updateCreatorShopItem,
|
||||
} from '../creator-shop.service';
|
||||
import { dbMock } from '~/__tests__/mocks/db.mock';
|
||||
import { soldCountsFake } from '~/test-utils/soldCountsFake';
|
||||
dbMock.dbRead.cosmeticShopItem.findUnique.mockImplementation((...args: unknown[]) =>
|
||||
(mocks.shopItemFindUnique as (...a: unknown[]) => unknown)(...args)
|
||||
);
|
||||
@@ -119,7 +120,6 @@ const shopItemRow = (id: number) => ({
|
||||
meta: { sellableByOthers: true, sellerShare: 0 },
|
||||
cosmetic: { id: id * 10, name: `Cosmetic ${id}`, type: 'Badge', data: {} },
|
||||
addedBy: { id: CREATOR_ID, username: 'creator', image: null },
|
||||
_count: { purchases: 0 },
|
||||
});
|
||||
|
||||
describe('listing someone else’s item records the terms it was listed under', () => {
|
||||
@@ -527,31 +527,26 @@ describe('getCreatorShop resold section', () => {
|
||||
* asserted on the storefront path.
|
||||
*/
|
||||
it('reports the purchase rows as the sold count, not the meta counter', async () => {
|
||||
const OWN_ITEM_ID = SHOP_ITEM_ID + 1;
|
||||
const withCounterDrift = (id: number) => ({
|
||||
...shopItemRow(id),
|
||||
meta: { sellableByOthers: true, sellerShare: 0, purchases: 3 },
|
||||
_count: { purchases: 7 },
|
||||
});
|
||||
mocks.resaleFindMany.mockResolvedValue([{ shopItemId: SHOP_ITEM_ID, sellerShare: 20 }]);
|
||||
mocks.shopItemFindMany
|
||||
.mockResolvedValueOnce([withCounterDrift(SHOP_ITEM_ID)])
|
||||
.mockResolvedValueOnce([withCounterDrift(OWN_ITEM_ID)])
|
||||
.mockResolvedValueOnce([withCounterDrift(SHOP_ITEM_ID)]);
|
||||
mocks.queryRaw.mockImplementation(soldCountsFake({ [OWN_ITEM_ID]: 7, [SHOP_ITEM_ID]: 5 }));
|
||||
|
||||
const { cosmetics, resold } = await getCreatorShop({
|
||||
userId: RESELLER_ID,
|
||||
viewerId: RESELLER_ID,
|
||||
});
|
||||
|
||||
// Distinct ids and counts, so a count read for only one of the two
|
||||
// queries' items shows up as a 0 on the other.
|
||||
expect(cosmetics[0].meta.purchases).toBe(7);
|
||||
expect(resold[0].meta.purchases).toBe(7);
|
||||
// Both storefront queries carry `_count` by spreading the shared selector.
|
||||
// Redefining it there is already a compile error; this is the readable
|
||||
// version of that failure. Only the first call is checked — both pass the
|
||||
// same `creatorStorefrontItemSelect` object, so a second assertion would be
|
||||
// the identical reference.
|
||||
expect(mocks.shopItemFindMany.mock.calls[0][0].select._count).toEqual({
|
||||
select: { purchases: true },
|
||||
});
|
||||
expect(resold[0].meta.purchases).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { dbRead } from '~/server/db/client';
|
||||
|
||||
/**
|
||||
* Sold counts for exactly the listed shop items, from the purchase rows.
|
||||
*
|
||||
* Prisma's relation `_count` aggregates the WHOLE purchases table once per query and
|
||||
* joins the result. This groups only the ids asked for; an index on `shopItemId` would
|
||||
* also let it read only their rows, but none exists yet, so it still scans the table.
|
||||
* `= ANY(array)` rather than `IN (...)` keeps one statement shape for every page size.
|
||||
*
|
||||
* Items with no purchases are absent from the map; read them as 0.
|
||||
*/
|
||||
export async function getSoldCounts(shopItemIds: number[]): Promise<Map<number, number>> {
|
||||
const ids = [...new Set(shopItemIds)];
|
||||
if (!ids.length) return new Map();
|
||||
const rows = await dbRead.$queryRaw<{ shopItemId: number; sold: number }[]>`
|
||||
SELECT "shopItemId", COUNT(*)::int AS sold
|
||||
FROM "UserCosmeticShopPurchases"
|
||||
WHERE "shopItemId" = ANY(${ids}::int[])
|
||||
GROUP BY "shopItemId"
|
||||
`;
|
||||
return new Map(rows.map((r) => [r.shopItemId, r.sold]));
|
||||
}
|
||||
|
||||
/**
|
||||
* For the editor reads, which hand back the whole `meta` rather than the display
|
||||
* list. They read `meta.purchases`, so the row count has to land on that key or
|
||||
* an editor shows the drifting counter while every buyer surface shows rows.
|
||||
* Buyer surfaces go through `shopItemDisplayMeta` instead, never this.
|
||||
*/
|
||||
export const withSoldCount = <T extends { meta: unknown }>(item: T, sold: number) => ({
|
||||
...item,
|
||||
meta: { ...((item.meta ?? {}) as Record<string, unknown>), purchases: sold },
|
||||
});
|
||||
|
||||
export async function withSoldCounts<T extends { id: number; meta: unknown }>(items: T[]) {
|
||||
const sold = await getSoldCounts(items.map((i) => i.id));
|
||||
return items.map((item) => withSoldCount(item, sold.get(item.id) ?? 0));
|
||||
}
|
||||
@@ -31,7 +31,12 @@ import type {
|
||||
} from '~/server/schema/cosmetic-shop.schema';
|
||||
import { computeCreatorShopSplit, PACK_FILTER_VALUE } from '~/server/schema/creator-shop.schema';
|
||||
import type { ImageMetaProps } from '~/server/schema/image.schema';
|
||||
import { cosmeticShopItemSelect, withSoldCount } from '~/server/selectors/cosmetic-shop.selector';
|
||||
import { cosmeticShopItemSelect } from '~/server/selectors/cosmetic-shop.selector';
|
||||
import {
|
||||
getSoldCounts,
|
||||
withSoldCount,
|
||||
withSoldCounts,
|
||||
} from '~/server/services/cosmetic-shop-sold-count';
|
||||
import { imageSelect } from '~/server/selectors/image.selector';
|
||||
import {
|
||||
createBuzzTransaction,
|
||||
@@ -95,7 +100,7 @@ export const getShopItemById = async ({ id }: GetByIdInput) => {
|
||||
dbReadFallbackCounter.inc({ entity: 'cosmeticShopItem', caller: 'getShopItemById' });
|
||||
return dbWrite.cosmeticShopItem.findUniqueOrThrow(shopItemFindArgs);
|
||||
})
|
||||
.then(withSoldCount);
|
||||
.then(async (item) => (await withSoldCounts([item]))[0]);
|
||||
};
|
||||
|
||||
export const getPaginatedCosmeticShopItems = async (input: GetPaginatedCosmeticShopItemInput) => {
|
||||
@@ -145,13 +150,12 @@ export const getPaginatedCosmeticShopItems = async (input: GetPaginatedCosmeticS
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const count = await dbRead.cosmeticShopItem.count({ where });
|
||||
const [withSold, count] = await Promise.all([
|
||||
withSoldCounts(items),
|
||||
dbRead.cosmeticShopItem.count({ where }),
|
||||
]);
|
||||
|
||||
return getPagingData(
|
||||
{ items: items.map(withSoldCount), count: (count as number) ?? 0 },
|
||||
limit,
|
||||
page
|
||||
);
|
||||
return getPagingData({ items: withSold, count: (count as number) ?? 0 }, limit, page);
|
||||
};
|
||||
|
||||
export const upsertCosmetic = async (input: UpsertCosmeticInput) => {
|
||||
@@ -314,18 +318,13 @@ export const upsertCosmeticShopItem = async ({
|
||||
archivedAt: archived ? new Date() : null,
|
||||
};
|
||||
|
||||
// Without `_count`: the response is not mapped through `withSoldCount` (see the
|
||||
// return below), so nothing reads it — and selecting it here would run a
|
||||
// whole-table aggregate on the PRIMARY inside an open write transaction.
|
||||
const { _count: _unusedOnWrite, ...writeSelect } = cosmeticShopItemSelect;
|
||||
|
||||
const item = await dbWrite.$transaction(
|
||||
async (tx) => {
|
||||
const saved = id
|
||||
? await tx.cosmeticShopItem.update({
|
||||
where: { id },
|
||||
data,
|
||||
select: writeSelect,
|
||||
select: cosmeticShopItemSelect,
|
||||
})
|
||||
: await tx.cosmeticShopItem.create({
|
||||
data: {
|
||||
@@ -336,7 +335,7 @@ export const upsertCosmeticShopItem = async ({
|
||||
purchases: 0,
|
||||
},
|
||||
},
|
||||
select: writeSelect,
|
||||
select: cosmeticShopItemSelect,
|
||||
});
|
||||
|
||||
if (expansion.evaluated)
|
||||
@@ -488,10 +487,14 @@ export const getSectionById = async ({ id }: GetByIdInput) => {
|
||||
dbReadFallbackCounter.inc({ entity: 'cosmeticShopSection', caller: 'getSectionById' });
|
||||
return dbWrite.cosmeticShopSection.findUniqueOrThrow(sectionFindArgs);
|
||||
});
|
||||
const sold = await getSoldCounts(section.items.map((i) => i.shopItem.id));
|
||||
|
||||
return {
|
||||
...section,
|
||||
items: section.items.map((i) => ({ ...i, shopItem: withSoldCount(i.shopItem) })),
|
||||
items: section.items.map((i) => ({
|
||||
...i,
|
||||
shopItem: withSoldCount(i.shopItem, sold.get(i.shopItem.id) ?? 0),
|
||||
})),
|
||||
image: !!section.image
|
||||
? {
|
||||
...section.image,
|
||||
@@ -797,6 +800,7 @@ export const getShopSectionsWithItems = async ({
|
||||
placement: 'asc',
|
||||
},
|
||||
});
|
||||
const sold = await getSoldCounts(sections.flatMap((s) => s.items.map((i) => i.shopItem.id)));
|
||||
|
||||
return (
|
||||
sections
|
||||
@@ -812,7 +816,7 @@ export const getShopSectionsWithItems = async ({
|
||||
...item.shopItem,
|
||||
meta: shopItemDisplayMeta(
|
||||
item.shopItem.meta as CosmeticShopItemMeta | null,
|
||||
item.shopItem._count.purchases
|
||||
sold.get(item.shopItem.id) ?? 0
|
||||
),
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -57,6 +57,7 @@ import type {
|
||||
CosmeticShopItemMeta,
|
||||
} from '~/server/schema/cosmetic-shop.schema';
|
||||
import { cosmeticShopItemSelect } from '~/server/selectors/cosmetic-shop.selector';
|
||||
import { getSoldCounts } from '~/server/services/cosmetic-shop-sold-count';
|
||||
import { delistPacksContaining } from '~/server/services/creator-shop-pack.service';
|
||||
import { simpleCosmeticSelect } from '~/server/selectors/cosmetic.selector';
|
||||
import { userWithCosmeticsSelect } from '~/server/selectors/user.selector';
|
||||
@@ -171,8 +172,7 @@ type CreatorShopItemRow = Prisma.CosmeticShopItemGetPayload<{
|
||||
select: typeof creatorShopItemSelect;
|
||||
}>;
|
||||
|
||||
const withRemaining = (item: CreatorShopItemRow) => {
|
||||
const purchases = item._count.purchases;
|
||||
const withRemaining = (item: Omit<CreatorShopItemRow, '_count'>, purchases: number) => {
|
||||
const remaining = item.availableQuantity != null ? item.availableQuantity - purchases : null;
|
||||
return { ...item, purchases, remaining, soldOut: remaining != null && remaining <= 0 };
|
||||
};
|
||||
@@ -1016,17 +1016,13 @@ export const getCreatorShopManageItems = async ({ userId }: { userId: number })
|
||||
// count disagrees with the list the creator opens from it.
|
||||
select: {
|
||||
...creatorShopItemSelect,
|
||||
_count: {
|
||||
select: {
|
||||
...creatorShopItemSelect._count.select,
|
||||
resales: { where: { user: { deletedAt: null } } },
|
||||
},
|
||||
},
|
||||
_count: { select: { resales: { where: { user: { deletedAt: null } } } } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const sold = await getSoldCounts(items.map((i) => i.id));
|
||||
return items.map(({ _count, ...item }) => ({
|
||||
...withRemaining({ ...item, _count }),
|
||||
...withRemaining(item, sold.get(item.id) ?? 0),
|
||||
resellerCount: _count.resales,
|
||||
}));
|
||||
};
|
||||
@@ -1142,11 +1138,13 @@ export const getCreatorShop = async ({
|
||||
`.then((r) => r[0]?.count ?? 0),
|
||||
]);
|
||||
|
||||
const sold = await getSoldCounts([...items, ...resoldItems].map((i) => i.id));
|
||||
|
||||
// Sanitize meta to what the card/checkout needs — never the creator
|
||||
// payout/fee internals.
|
||||
const sanitize = (item: (typeof items)[number]) => ({
|
||||
...item,
|
||||
meta: shopItemDisplayMeta(item.meta as CosmeticShopItemMeta | null, item._count.purchases),
|
||||
meta: shopItemDisplayMeta(item.meta as CosmeticShopItemMeta | null, sold.get(item.id) ?? 0),
|
||||
});
|
||||
const cosmetics = items.map(sanitize);
|
||||
// Resold items keep the seller share so the buyer can see the split at
|
||||
@@ -1154,7 +1152,7 @@ export const getCreatorShop = async ({
|
||||
const sanitizeResold = (item: (typeof resoldItems)[number]) => ({
|
||||
...item,
|
||||
meta: {
|
||||
...shopItemDisplayMeta(item.meta as CosmeticShopItemMeta | null, item._count.purchases),
|
||||
...shopItemDisplayMeta(item.meta as CosmeticShopItemMeta | null, sold.get(item.id) ?? 0),
|
||||
sellerShare:
|
||||
resaleShares.get(item.id) ?? (item.meta as CosmeticShopItemMeta)?.sellerShare ?? 0,
|
||||
},
|
||||
@@ -1345,10 +1343,11 @@ export const getCommunityCosmetics = async ({
|
||||
}),
|
||||
dbRead.cosmeticShopItem.count({ where }),
|
||||
]);
|
||||
const sold = await getSoldCounts(raw.map((i) => i.id));
|
||||
// Same meta sanitation as the storefront.
|
||||
const items = raw.map((item) => ({
|
||||
...item,
|
||||
meta: shopItemDisplayMeta(item.meta as CosmeticShopItemMeta | null, item._count.purchases),
|
||||
meta: shopItemDisplayMeta(item.meta as CosmeticShopItemMeta | null, sold.get(item.id) ?? 0),
|
||||
}));
|
||||
return getPagingData({ items, count }, limit, page);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { projectOntoSelect } from '~/test-utils/queryRawProjection';
|
||||
|
||||
/**
|
||||
* A `$queryRaw` fake for `getSoldCounts`, answering only for the ids the statement was
|
||||
* actually given. A fake that returned every fixture count regardless would pass a caller
|
||||
* that queried the wrong ids, or none; this one reads that caller's page back as 0.
|
||||
*
|
||||
* Rows are projected onto the statement's SELECT list, so a renamed column fails loudly
|
||||
* instead of every sold count silently reading `undefined`.
|
||||
*
|
||||
* Statements that are not the sold-count query resolve to `other(...)`, `[]` by default.
|
||||
*/
|
||||
export const soldCountsFake =
|
||||
(counts: Record<number, number>, other: (...args: unknown[]) => unknown = () => []) =>
|
||||
async (strings: readonly string[], ...values: unknown[]) => {
|
||||
if (!strings.join('?').includes('"UserCosmeticShopPurchases"'))
|
||||
return other(strings, ...values);
|
||||
const ids = values.find(Array.isArray) as number[] | undefined;
|
||||
if (!ids) throw new Error('soldCountsFake: the sold-count statement carried no id array');
|
||||
return projectOntoSelect(
|
||||
strings,
|
||||
ids
|
||||
.filter((id) => counts[id] !== undefined)
|
||||
.map((id) => ({ shopItemId: id, sold: counts[id] }))
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user