diff --git a/src/modules/creators/creator-list-price-sort-stability.integration.test.ts b/src/modules/creators/creator-list-price-sort-stability.integration.test.ts new file mode 100644 index 0000000..de60517 --- /dev/null +++ b/src/modules/creators/creator-list-price-sort-stability.integration.test.ts @@ -0,0 +1,92 @@ +import supertest from 'supertest'; +import app from '../../app'; +import { prisma } from '../../utils/prisma.utils'; +import { seedCreatorMarketFixture } from '../../utils/test/seeded-creator-fixtures.utils'; + +describe('GET /api/v1/creators - price sort stability across pages', () => { + const PRICE = 1_000_000n; + const PAGE_SIZE = 2; + const PREFIX = 'creator-price-sort-stability'; + const SEED_ORDER = [3, 1, 2]; + + const seededFixtures: Array<{ userId: string; creatorId: string }> = []; + + beforeAll(async () => { + await prisma.creatorPriceSnapshot.deleteMany({ + where: { creatorId: { startsWith: `${PREFIX}-creator-` } }, + }); + await prisma.creatorProfile.deleteMany({ + where: { handle: { startsWith: `${PREFIX}-handle-` } }, + }); + await prisma.user.deleteMany({ + where: { id: { startsWith: `${PREFIX}-user-` } }, + }); + + for (const seed of SEED_ORDER) { + const fixture = await seedCreatorMarketFixture(prisma, seed, { + prefix: PREFIX, + price: PRICE, + displayName: `Price Sort Creator ${seed}`, + }); + seededFixtures.push(fixture); + } + }); + + afterAll(async () => { + const creatorIds = seededFixtures.map(fixture => fixture.creatorId); + const userIds = seededFixtures.map(fixture => fixture.userId); + + await prisma.creatorPriceSnapshot.deleteMany({ + where: { creatorId: { in: creatorIds } }, + }); + await prisma.creatorProfile.deleteMany({ + where: { id: { in: creatorIds } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: userIds } }, + }); + await prisma.$disconnect(); + }); + + async function fetchPage(offset: number) { + const res = await supertest(app).get( + `/api/v1/creators?sort=price&order=desc&limit=${PAGE_SIZE}&offset=${offset}` + ); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + return res.body.data; + } + + it('returns each tied creator exactly once across pages in a stable order', async () => { + const firstPassPageOne = await fetchPage(0); + const firstPassPageTwo = await fetchPage(PAGE_SIZE); + + const secondPassPageOne = await fetchPage(0); + const secondPassPageTwo = await fetchPage(PAGE_SIZE); + + expect(firstPassPageOne.meta.hasMore).toBe(true); + expect(firstPassPageTwo.meta.hasMore).toBe(false); + + expect(firstPassPageOne.items.map((item: any) => item.id)).toEqual( + secondPassPageOne.items.map((item: any) => item.id) + ); + expect(firstPassPageTwo.items.map((item: any) => item.id)).toEqual( + secondPassPageTwo.items.map((item: any) => item.id) + ); + + const combinedIds = [ + ...firstPassPageOne.items.map((item: any) => item.id), + ...firstPassPageTwo.items.map((item: any) => item.id), + ]; + + expect(combinedIds).toHaveLength(3); + expect(new Set(combinedIds).size).toBe(3); + expect(combinedIds).toEqual( + seededFixtures + .map(fixture => fixture.creatorId) + .sort((a, b) => a.localeCompare(b)) + ); + }); +}); diff --git a/src/modules/creators/creators.utils.ts b/src/modules/creators/creators.utils.ts index 29b5d0e..fe76060 100644 --- a/src/modules/creators/creators.utils.ts +++ b/src/modules/creators/creators.utils.ts @@ -1,3 +1,4 @@ +import { Prisma } from '@prisma/client'; import { prisma } from '../../utils/prisma.utils'; import { CreatorProfile } from '../../types/profile.types'; import { CreatorListQueryType } from './creators.schemas'; @@ -40,7 +41,12 @@ export async function fetchCreatorList( minPrice, maxPrice, }); - const orderBy = mapCreatorListSort(sort, order); + const orderBy: Prisma.CreatorProfileOrderByWithRelationInput[] = [ + mapCreatorListSort(sort, order), + // Apply a deterministic tie-breaker so pagination stays stable when + // multiple creators share the same primary sort value. + { id: 'asc' }, + ]; // Fetch creators and total count in parallel const start = Date.now(); diff --git a/src/modules/wallets/__tests__/wallet-holdings-multiple-snapshots.integration.test.ts b/src/modules/wallets/__tests__/wallet-holdings-multiple-snapshots.integration.test.ts index 8406c87..f506e2f 100644 --- a/src/modules/wallets/__tests__/wallet-holdings-multiple-snapshots.integration.test.ts +++ b/src/modules/wallets/__tests__/wallet-holdings-multiple-snapshots.integration.test.ts @@ -1,103 +1,97 @@ import supertest from 'supertest'; import app from '../../../app'; import { prisma } from '../../../utils/prisma.utils'; +import { + seedCreatorMarketFixture, + upsertCreatorPriceSnapshot, +} from '../../../utils/test/seeded-creator-fixtures.utils'; describe('GET /api/v1/wallets/:address/holdings - multiple price snapshot updates', () => { - const WALLET_ADDRESS = 'GCZURJAWEEAYDCIIUFMCGVDIKBASNKQQ7ZCX33BP2DZHFF52SG6BLW6J'; - const USER_ID = 'wallet-multi-snap-user'; - const CREATOR_ID = 'wallet-multi-snap-creator'; - const HOLDING_BALANCE = 5.0; // 5 keys held + const WALLET_ADDRESS = + 'GCZURJAWEEAYDCIIUFMCGVDIKBASNKQQ7ZCX33BP2DZHFF52SG6BLW6J'; + const HOLDING_BALANCE = 5.0; // 5 keys held + const SEED_PREFIX = 'wallet-multi-snap'; - beforeAll(async () => { - // Clean up database tables to avoid tests leaking into each other - await prisma.keyOwnership.deleteMany({}); - await prisma.creatorPriceSnapshot.deleteMany({}); - await prisma.creatorProfile.deleteMany({}); - await prisma.user.deleteMany({}); + let creatorId = ''; + let userId = ''; - // Seed a User - await prisma.user.create({ - data: { - id: USER_ID, - email: 'wallet-multi-snap@example.test', - passwordHash: 'dummy-hash', - firstName: 'Wallet', - lastName: 'Multi Snap', - }, - }); + beforeAll(async () => { + // Clean up database tables to avoid tests leaking into each other + await prisma.keyOwnership.deleteMany({ + where: { ownerAddress: WALLET_ADDRESS }, + }); + await prisma.creatorPriceSnapshot.deleteMany({ + where: { creatorId: { startsWith: SEED_PREFIX } }, + }); + await prisma.creatorProfile.deleteMany({ + where: { handle: { startsWith: `${SEED_PREFIX}-handle-` } }, + }); + await prisma.user.deleteMany({ + where: { id: { startsWith: `${SEED_PREFIX}-user-` } }, + }); - // Seed a Creator - await prisma.creatorProfile.create({ - data: { - id: CREATOR_ID, - userId: USER_ID, - handle: 'wallet_multi_snap_creator', - displayName: 'Wallet Multi Snap Creator', - }, - }); + const seededCreator = await seedCreatorMarketFixture(prisma, 1, { + prefix: SEED_PREFIX, + displayName: 'Wallet Multi Snap Creator', + walletAddress: WALLET_ADDRESS, + balance: HOLDING_BALANCE, + }); - // Seed KeyOwnership - await prisma.keyOwnership.create({ - data: { - ownerAddress: WALLET_ADDRESS, - creatorId: CREATOR_ID, - balance: HOLDING_BALANCE, - }, - }); - }); + creatorId = seededCreator.creatorId; + userId = seededCreator.userId; + }); - afterAll(async () => { - // Clean up seeded database tables - await prisma.keyOwnership.deleteMany({}); - await prisma.creatorPriceSnapshot.deleteMany({}); - await prisma.creatorProfile.deleteMany({}); - await prisma.user.deleteMany({}); - }); + afterAll(async () => { + // Clean up seeded database tables + await prisma.keyOwnership.deleteMany({ + where: { ownerAddress: WALLET_ADDRESS }, + }); + if (creatorId) { + await prisma.creatorPriceSnapshot.deleteMany({ + where: { creatorId }, + }); + await prisma.creatorProfile.deleteMany({ where: { id: creatorId } }); + } + if (userId) { + await prisma.user.deleteMany({ where: { id: userId } }); + } + await prisma.$disconnect(); + }); - it('should reflect the correct total_value after multiple price snapshot updates', async () => { - // First snapshot update - await prisma.creatorPriceSnapshot.upsert({ - where: { creatorId: CREATOR_ID }, - update: { currentPrice: 100n }, - create: { creatorId: CREATOR_ID, currentPrice: 100n }, - }); + it('should reflect the correct total_value after multiple price snapshot updates', async () => { + // First snapshot update + await upsertCreatorPriceSnapshot(prisma, creatorId, 100n); - let res = await supertest(app).get(`/api/v1/wallets/${WALLET_ADDRESS}/holdings`); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - let items = res.body.data.items; - expect(items).toHaveLength(1); - expect(items[0].current_price).toBe('100'); - expect(items[0].total_value).toBe('500'); + let res = await supertest(app).get( + `/api/v1/wallets/${WALLET_ADDRESS}/holdings` + ); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + let items = res.body.data.items; + expect(items).toHaveLength(1); + expect(items[0].current_price).toBe('100'); + expect(items[0].total_value).toBe('500'); - // Second snapshot update - await prisma.creatorPriceSnapshot.upsert({ - where: { creatorId: CREATOR_ID }, - update: { currentPrice: 250n }, - create: { creatorId: CREATOR_ID, currentPrice: 250n }, - }); + // Second snapshot update + await upsertCreatorPriceSnapshot(prisma, creatorId, 250n); - res = await supertest(app).get(`/api/v1/wallets/${WALLET_ADDRESS}/holdings`); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - items = res.body.data.items; - expect(items).toHaveLength(1); - expect(items[0].current_price).toBe('250'); - expect(items[0].total_value).toBe('1250'); + res = await supertest(app).get(`/api/v1/wallets/${WALLET_ADDRESS}/holdings`); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + items = res.body.data.items; + expect(items).toHaveLength(1); + expect(items[0].current_price).toBe('250'); + expect(items[0].total_value).toBe('1250'); - // Third snapshot update - await prisma.creatorPriceSnapshot.upsert({ - where: { creatorId: CREATOR_ID }, - update: { currentPrice: 300n }, - create: { creatorId: CREATOR_ID, currentPrice: 300n }, - }); + // Third snapshot update + await upsertCreatorPriceSnapshot(prisma, creatorId, 300n); - res = await supertest(app).get(`/api/v1/wallets/${WALLET_ADDRESS}/holdings`); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - items = res.body.data.items; - expect(items).toHaveLength(1); - expect(items[0].current_price).toBe('300'); - expect(items[0].total_value).toBe('1500'); - }); + res = await supertest(app).get(`/api/v1/wallets/${WALLET_ADDRESS}/holdings`); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + items = res.body.data.items; + expect(items).toHaveLength(1); + expect(items[0].current_price).toBe('300'); + expect(items[0].total_value).toBe('1500'); + }); }); diff --git a/src/utils/test/seeded-creator-fixtures.utils.ts b/src/utils/test/seeded-creator-fixtures.utils.ts index b7f8c67..37e4f5e 100644 --- a/src/utils/test/seeded-creator-fixtures.utils.ts +++ b/src/utils/test/seeded-creator-fixtures.utils.ts @@ -1,7 +1,30 @@ import { CreatorProfile } from '../../types/profile.types'; +import type { prisma as appPrisma } from '../prisma.utils'; const CREATOR_FIXTURE_BASE_DATE = new Date(Date.UTC(2020, 0, 1)); +type CreatorMarketSeedPrisma = typeof appPrisma; + +export interface CreatorMarketSeedOptions { + prefix?: string; + displayName?: string; + isVerified?: boolean; + createdAt?: Date; + updatedAt?: Date; + price?: bigint; + price24hAgo?: bigint; + lastTradeAt?: Date | null; + walletAddress?: string; + balance?: number | string; +} + +export interface SeededCreatorMarketFixture { + userId: string; + creatorId: string; + handle: string; + displayName: string; +} + /** * Generates a deterministic creator record from a numeric seed. * @@ -46,3 +69,140 @@ export function createSeededCreatorFixture( ...overrides, }; } + +function buildMarketSeedIdentity(seed: number, prefix: string): CreatorProfile { + return createSeededCreatorFixture(seed, { + id: `${prefix}-creator-${seed}`, + userId: `${prefix}-user-${seed}`, + handle: `${prefix}-handle-${seed}`, + displayName: `${prefix} creator ${seed}`, + }); +} + +export async function upsertCreatorPriceSnapshot( + prisma: CreatorMarketSeedPrisma, + creatorId: string, + currentPrice: bigint, + options: Pick = {} +): Promise { + const price24hAgo = options.price24hAgo ?? currentPrice; + const lastTradeAt = options.lastTradeAt ?? new Date(); + + await prisma.creatorPriceSnapshot.upsert({ + where: { creatorId }, + create: { + creatorId, + currentPrice, + price24hAgo, + lastTradeAt, + }, + update: { + currentPrice, + price24hAgo, + lastTradeAt, + }, + }); +} + +export async function upsertCreatorHolding( + prisma: CreatorMarketSeedPrisma, + ownerAddress: string, + creatorId: string, + balance: number | string +): Promise { + await prisma.keyOwnership.upsert({ + where: { + ownerAddress_creatorId: { + ownerAddress, + creatorId, + }, + }, + create: { + ownerAddress, + creatorId, + balance, + }, + update: { + balance, + }, + }); +} + +export async function seedCreatorMarketFixture( + prisma: CreatorMarketSeedPrisma, + seed: number, + options: CreatorMarketSeedOptions = {} +): Promise { + const prefix = options.prefix ?? 'creator-market'; + const identity = buildMarketSeedIdentity(seed, prefix); + const displayName = options.displayName ?? identity.displayName; + const isVerified = options.isVerified ?? identity.isVerified; + + await prisma.user.upsert({ + where: { id: identity.userId }, + create: { + id: identity.userId, + email: `${identity.userId}@example.test`, + passwordHash: 'dummy-hash', + firstName: 'Creator', + lastName: `${prefix} ${seed}`, + }, + update: { + email: `${identity.userId}@example.test`, + passwordHash: 'dummy-hash', + firstName: 'Creator', + lastName: `${prefix} ${seed}`, + }, + }); + + const creator = await prisma.creatorProfile.upsert({ + where: { userId: identity.userId }, + create: { + id: identity.id, + userId: identity.userId, + handle: identity.handle, + displayName, + bio: identity.bio, + avatarUrl: identity.avatarUrl, + perkSummary: identity.perkSummary, + perks: identity.perks ?? [], + isVerified, + createdAt: options.createdAt ?? identity.createdAt, + updatedAt: options.updatedAt ?? identity.updatedAt, + }, + update: { + handle: identity.handle, + displayName, + bio: identity.bio, + avatarUrl: identity.avatarUrl, + perkSummary: identity.perkSummary, + perks: identity.perks ?? [], + isVerified, + createdAt: options.createdAt ?? identity.createdAt, + updatedAt: options.updatedAt ?? identity.updatedAt, + }, + }); + + if (options.price !== undefined) { + await upsertCreatorPriceSnapshot(prisma, creator.id, options.price, { + price24hAgo: options.price24hAgo, + lastTradeAt: options.lastTradeAt, + }); + } + + if (options.walletAddress !== undefined && options.balance !== undefined) { + await upsertCreatorHolding( + prisma, + options.walletAddress, + creator.id, + options.balance + ); + } + + return { + userId: identity.userId, + creatorId: creator.id, + handle: creator.handle, + displayName, + }; +}