Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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))
);
});
});
8 changes: 7 additions & 1 deletion src/modules/creators/creators.utils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading