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,150 @@
// Integration test: leaderboard endpoint returns creators sorted by
// holder count descending, with alphabetical tie-breaking by creator
// address (#680).
//
// Uses Jest mocks — no database required. Follows the same conventions
// as trending-creators.integration.test.ts (the sibling ranked-list
// endpoint in this module).

import supertest from 'supertest';
import app from '../../app';
import { prisma } from '../../utils/prisma.utils';

jest.mock('../../utils/prisma.utils', () => ({
prisma: {
creatorProfile: {
findMany: jest.fn(),
},
keyOwnership: {
count: jest.fn(),
},
$disconnect: jest.fn(),
},
}));

const mockPrisma = prisma as unknown as {
creatorProfile: { findMany: jest.Mock };
keyOwnership: { count: jest.Mock };
};

// Two creators tie on holder count (50); their addresses are chosen so
// the alphabetically-earlier one ("GAAA...") must outrank the later one
// ("GBBB...") once the tie-break is applied.
const CREATOR_HIGH_A = {
id: 'creator-high-a',
handle: 'high-a',
address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB',
holderCount: 50,
currentPrice: 2_000_000n,
};
const CREATOR_HIGH_B = {
id: 'creator-high-b',
handle: 'high-b',
address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB',
holderCount: 50,
currentPrice: 3_000_000n,
};
const CREATOR_LOW = {
id: 'creator-low',
handle: 'low',
address: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
holderCount: 20,
currentPrice: 500_000n,
};

function mockCreatorProfile(fixture: typeof CREATOR_HIGH_A) {
return {
id: fixture.id,
handle: fixture.handle,
priceSnapshot: { currentPrice: fixture.currentPrice },
user: { stellarWallet: { address: fixture.address } },
};
}

describe('GET /api/v1/creators/leaderboard', () => {
beforeEach(() => {
jest.clearAllMocks();

// Seed three creators: two tied at 50 holders, one at 20.
mockPrisma.creatorProfile.findMany.mockResolvedValue([
mockCreatorProfile(CREATOR_HIGH_A),
mockCreatorProfile(CREATOR_HIGH_B),
mockCreatorProfile(CREATOR_LOW),
]);

mockPrisma.keyOwnership.count.mockImplementation(
async ({ where }: any) => {
const byId: Record<string, number> = {
[CREATOR_HIGH_A.id]: CREATOR_HIGH_A.holderCount,
[CREATOR_HIGH_B.id]: CREATOR_HIGH_B.holderCount,
[CREATOR_LOW.id]: CREATOR_LOW.holderCount,
};
return byId[where.creatorId] ?? 0;
}
);
});

it('ranks the two 50-holder creators above the 20-holder creator', async () => {
const res = await supertest(app).get('/api/v1/creators/leaderboard');

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);

const items = res.body.data.items;
expect(items).toHaveLength(3);

expect(items[0].holder_count).toBe(50);
expect(items[1].holder_count).toBe(50);
expect(items[2].holder_count).toBe(20);

// The 20-holder creator is ranked last.
expect(items[2].creator).toBe(CREATOR_LOW.address);
});

it('breaks the tie between equal holder counts alphabetically by creator address', async () => {
const res = await supertest(app).get('/api/v1/creators/leaderboard');

const items = res.body.data.items;
const tied = items.slice(0, 2);

expect(tied.map((entry: any) => entry.creator)).toEqual(
[CREATOR_HIGH_A.address, CREATOR_HIGH_B.address].sort()
);
// GAAA...B sorts before GBBB...B alphabetically.
expect(tied[0].creator).toBe(CREATOR_HIGH_B.address);
expect(tied[1].creator).toBe(CREATOR_HIGH_A.address);
});

it('assigns sequential rank fields starting at 1', async () => {
const res = await supertest(app).get('/api/v1/creators/leaderboard');

const items = res.body.data.items;
expect(items.map((entry: any) => entry.rank)).toEqual([1, 2, 3]);
});

it('includes rank, creator, holder_count, and current_price on every entry', async () => {
const res = await supertest(app).get('/api/v1/creators/leaderboard');

const items = res.body.data.items;
for (const entry of items) {
expect(entry).toEqual(
expect.objectContaining({
rank: expect.any(Number),
creator: expect.any(String),
holder_count: expect.any(Number),
current_price: expect.any(String),
})
);
}
});

it('reflects each creator current_price from its price snapshot', async () => {
const res = await supertest(app).get('/api/v1/creators/leaderboard');

const items = res.body.data.items;
const lowEntry = items.find(
(entry: any) => entry.creator === CREATOR_LOW.address
);
expect(lowEntry.current_price).toBe(CREATOR_LOW.currentPrice.toString());
});
});
158 changes: 158 additions & 0 deletions src/modules/creators/creator-stats-buy-transaction.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// Integration test: creator profile key stats (supply, holder count,
// current price) after a buy transaction (#678).
//
// Follows the same conventions as
// creator-detail-holder-count-sequential.integration.test.ts: the
// controller and the real ownership.service.ts are exercised directly,
// with prisma calls backed by in-memory fixtures instead of a live
// database.

import { httpGetCreatorStats } from './creators.controllers';
import { updateOwnership } from '../ownership/ownership.service';
import { prisma } from '../../utils/prisma.utils';

function makeReq(creatorId: string): any {
return {
params: { id: creatorId },
};
}

function makeRes(): any {
const res: any = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
res.setHeader = jest.fn().mockReturnValue(res);
res.set = jest.fn().mockReturnValue(res);
return res;
}

function makeNext(): jest.Mock {
return jest.fn();
}

describe('#678 Integration test: creator profile key stats after a buy transaction', () => {
const creatorId = '456';
const holderA = 'GHOLDERA111111111111111111111111111111111111111111111111';
const holderB = 'GHOLDERB222222222222222222222222222222222222222222222222';
const holderC = 'GHOLDERC333333333333333333333333333333333333333333333333';
const newInvestor =
'GINVESTORD44444444444444444444444444444444444444444444444';

const INITIAL_PRICE = 1_000_000n;
const POST_BUY_PRICE = 1_250_000n;

// In-memory stand-ins for the ownership read model and the price
// snapshot read model, keyed the same way the real Prisma calls are.
const ownershipStore = new Map<string, number>();
let currentSnapshotPrice: bigint | null = null;

beforeEach(() => {
jest.restoreAllMocks();
ownershipStore.clear();
currentSnapshotPrice = null;

(prisma.creatorProfile.findFirst as any) = jest.fn(async () => ({
id: creatorId,
}));

(prisma.keyOwnership.count as any) = jest.fn(async (args: any) => {
let count = 0;
for (const [key, bal] of ownershipStore.entries()) {
if (key.endsWith(`:${args.where.creatorId}`) && bal > 0) {
count++;
}
}
return count;
});

(prisma.keyOwnership.findFirst as any) = jest.fn(async (args: any) => {
const { ownerAddress, creatorId: cid } = args.where;
const key = `${ownerAddress}:${cid}`;
const bal = ownershipStore.get(key) || 0;
return { balance: bal } as any;
});

(prisma.keyOwnership.upsert as any) = jest.fn(async (args: any) => {
const { ownerAddress, creatorId: cid } = args.create;
const key = `${ownerAddress}:${cid}`;
const current = ownershipStore.get(key) || 0;
const change = args.update.balance.increment;
const newBal = current + change;
ownershipStore.set(key, newBal);
return { ownerAddress, creatorId: cid, balance: newBal } as any;
});

(prisma.keyOwnership.aggregate as any) = jest.fn(async (args: any) => {
let sum = 0;
for (const [key, bal] of ownershipStore.entries()) {
if (key.endsWith(`:${args.where.creatorId}`)) {
sum += bal;
}
}
return { _sum: { balance: sum } } as any;
});

(prisma.creatorPriceSnapshot.findUnique as any) = jest.fn(async () => {
if (currentSnapshotPrice === null) return null;
return { currentPrice: currentSnapshotPrice } as any;
});
});

it('reflects supply, holder count, and price after a buy transaction', async () => {
// ── Seed: initial supply of 10 keys held by 3 holders ──────────────
await updateOwnership(holderA, creatorId, 4);
await updateOwnership(holderB, creatorId, 3);
await updateOwnership(holderC, creatorId, 3);
currentSnapshotPrice = INITIAL_PRICE;

const reqBefore = makeReq(creatorId);
const resBefore = makeRes();
await httpGetCreatorStats(reqBefore, resBefore, makeNext());

const before = resBefore.json.mock.calls[0][0].data;
expect(resBefore.status).toHaveBeenCalledWith(200);
expect(before.totalSupply).toBe(10);
expect(before.holderCount).toBe(3);
expect(before.currentPrice).toBe(INITIAL_PRICE.toString());

// ── Simulate a buy of 5 keys by a new investor ─────────────────────
await updateOwnership(newInvestor, creatorId, 5);
// The indexer records the trade's price on the bonding curve as the
// new current price once the buy lands.
currentSnapshotPrice = POST_BUY_PRICE;

const reqAfter = makeReq(creatorId);
const resAfter = makeRes();
await httpGetCreatorStats(reqAfter, resAfter, makeNext());

expect(resAfter.status).toHaveBeenCalledWith(200);
const after = resAfter.json.mock.calls[0][0].data;

// Supply incremented correctly after the buy (10 + 5 = 15).
expect(after.totalSupply).toBe(15);
// Holder count incremented since the new investor is a first-time buyer.
expect(after.holderCount).toBe(4);
expect(after.holder_count).toBe(4);
// Price reflects the updated bonding-curve supply.
expect(after.currentPrice).toBe(POST_BUY_PRICE.toString());
expect(after.currentPrice).not.toBe(before.currentPrice);
});

it('does not change holder count when an existing holder buys more keys', async () => {
await updateOwnership(holderA, creatorId, 4);
await updateOwnership(holderB, creatorId, 3);
await updateOwnership(holderC, creatorId, 3);
currentSnapshotPrice = INITIAL_PRICE;

// An existing holder (not a new investor) buys more keys.
await updateOwnership(holderA, creatorId, 5);

const req = makeReq(creatorId);
const res = makeRes();
await httpGetCreatorStats(req, res, makeNext());

const data = res.json.mock.calls[0][0].data;
expect(data.totalSupply).toBe(15);
expect(data.holderCount).toBe(3);
});
});
Loading
Loading