Skip to content

Commit cbce564

Browse files
committed
test: add coverage for pagination cursor round-trip, leaderboard sort order, wallet validator, and creator profile stats
Closes #678 Closes #679 Closes #680 Closes #682
1 parent 94cbd4b commit cbce564

13 files changed

Lines changed: 551 additions & 31 deletions
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Integration test: leaderboard endpoint returns creators sorted by
2+
// holder count descending, with alphabetical tie-breaking by creator
3+
// address (#680).
4+
//
5+
// Uses Jest mocks — no database required. Follows the same conventions
6+
// as trending-creators.integration.test.ts (the sibling ranked-list
7+
// endpoint in this module).
8+
9+
import supertest from 'supertest';
10+
import app from '../../app';
11+
import { prisma } from '../../utils/prisma.utils';
12+
13+
jest.mock('../../utils/prisma.utils', () => ({
14+
prisma: {
15+
creatorProfile: {
16+
findMany: jest.fn(),
17+
},
18+
keyOwnership: {
19+
count: jest.fn(),
20+
},
21+
$disconnect: jest.fn(),
22+
},
23+
}));
24+
25+
const mockPrisma = prisma as unknown as {
26+
creatorProfile: { findMany: jest.Mock };
27+
keyOwnership: { count: jest.Mock };
28+
};
29+
30+
// Two creators tie on holder count (50); their addresses are chosen so
31+
// the alphabetically-earlier one ("GAAA...") must outrank the later one
32+
// ("GBBB...") once the tie-break is applied.
33+
const CREATOR_HIGH_A = {
34+
id: 'creator-high-a',
35+
handle: 'high-a',
36+
address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB',
37+
holderCount: 50,
38+
currentPrice: 2_000_000n,
39+
};
40+
const CREATOR_HIGH_B = {
41+
id: 'creator-high-b',
42+
handle: 'high-b',
43+
address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB',
44+
holderCount: 50,
45+
currentPrice: 3_000_000n,
46+
};
47+
const CREATOR_LOW = {
48+
id: 'creator-low',
49+
handle: 'low',
50+
address: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
51+
holderCount: 20,
52+
currentPrice: 500_000n,
53+
};
54+
55+
function mockCreatorProfile(fixture: typeof CREATOR_HIGH_A) {
56+
return {
57+
id: fixture.id,
58+
handle: fixture.handle,
59+
priceSnapshot: { currentPrice: fixture.currentPrice },
60+
user: { stellarWallet: { address: fixture.address } },
61+
};
62+
}
63+
64+
describe('GET /api/v1/creators/leaderboard', () => {
65+
beforeEach(() => {
66+
jest.clearAllMocks();
67+
68+
// Seed three creators: two tied at 50 holders, one at 20.
69+
mockPrisma.creatorProfile.findMany.mockResolvedValue([
70+
mockCreatorProfile(CREATOR_HIGH_A),
71+
mockCreatorProfile(CREATOR_HIGH_B),
72+
mockCreatorProfile(CREATOR_LOW),
73+
]);
74+
75+
mockPrisma.keyOwnership.count.mockImplementation(
76+
async ({ where }: any) => {
77+
const byId: Record<string, number> = {
78+
[CREATOR_HIGH_A.id]: CREATOR_HIGH_A.holderCount,
79+
[CREATOR_HIGH_B.id]: CREATOR_HIGH_B.holderCount,
80+
[CREATOR_LOW.id]: CREATOR_LOW.holderCount,
81+
};
82+
return byId[where.creatorId] ?? 0;
83+
}
84+
);
85+
});
86+
87+
it('ranks the two 50-holder creators above the 20-holder creator', async () => {
88+
const res = await supertest(app).get('/api/v1/creators/leaderboard');
89+
90+
expect(res.status).toBe(200);
91+
expect(res.body.success).toBe(true);
92+
93+
const items = res.body.data.items;
94+
expect(items).toHaveLength(3);
95+
96+
expect(items[0].holder_count).toBe(50);
97+
expect(items[1].holder_count).toBe(50);
98+
expect(items[2].holder_count).toBe(20);
99+
100+
// The 20-holder creator is ranked last.
101+
expect(items[2].creator).toBe(CREATOR_LOW.address);
102+
});
103+
104+
it('breaks the tie between equal holder counts alphabetically by creator address', async () => {
105+
const res = await supertest(app).get('/api/v1/creators/leaderboard');
106+
107+
const items = res.body.data.items;
108+
const tied = items.slice(0, 2);
109+
110+
expect(tied.map((entry: any) => entry.creator)).toEqual(
111+
[CREATOR_HIGH_A.address, CREATOR_HIGH_B.address].sort()
112+
);
113+
// GAAA...B sorts before GBBB...B alphabetically.
114+
expect(tied[0].creator).toBe(CREATOR_HIGH_B.address);
115+
expect(tied[1].creator).toBe(CREATOR_HIGH_A.address);
116+
});
117+
118+
it('assigns sequential rank fields starting at 1', async () => {
119+
const res = await supertest(app).get('/api/v1/creators/leaderboard');
120+
121+
const items = res.body.data.items;
122+
expect(items.map((entry: any) => entry.rank)).toEqual([1, 2, 3]);
123+
});
124+
125+
it('includes rank, creator, holder_count, and current_price on every entry', async () => {
126+
const res = await supertest(app).get('/api/v1/creators/leaderboard');
127+
128+
const items = res.body.data.items;
129+
for (const entry of items) {
130+
expect(entry).toEqual(
131+
expect.objectContaining({
132+
rank: expect.any(Number),
133+
creator: expect.any(String),
134+
holder_count: expect.any(Number),
135+
current_price: expect.any(String),
136+
})
137+
);
138+
}
139+
});
140+
141+
it('reflects each creator current_price from its price snapshot', async () => {
142+
const res = await supertest(app).get('/api/v1/creators/leaderboard');
143+
144+
const items = res.body.data.items;
145+
const lowEntry = items.find(
146+
(entry: any) => entry.creator === CREATOR_LOW.address
147+
);
148+
expect(lowEntry.current_price).toBe(CREATOR_LOW.currentPrice.toString());
149+
});
150+
});
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Integration test: creator profile key stats (supply, holder count,
2+
// current price) after a buy transaction (#678).
3+
//
4+
// Follows the same conventions as
5+
// creator-detail-holder-count-sequential.integration.test.ts: the
6+
// controller and the real ownership.service.ts are exercised directly,
7+
// with prisma calls backed by in-memory fixtures instead of a live
8+
// database.
9+
10+
import { httpGetCreatorStats } from './creators.controllers';
11+
import { updateOwnership } from '../ownership/ownership.service';
12+
import { prisma } from '../../utils/prisma.utils';
13+
14+
function makeReq(creatorId: string): any {
15+
return {
16+
params: { id: creatorId },
17+
};
18+
}
19+
20+
function makeRes(): any {
21+
const res: any = {};
22+
res.status = jest.fn().mockReturnValue(res);
23+
res.json = jest.fn().mockReturnValue(res);
24+
res.setHeader = jest.fn().mockReturnValue(res);
25+
res.set = jest.fn().mockReturnValue(res);
26+
return res;
27+
}
28+
29+
function makeNext(): jest.Mock {
30+
return jest.fn();
31+
}
32+
33+
describe('#678 Integration test: creator profile key stats after a buy transaction', () => {
34+
const creatorId = '456';
35+
const holderA = 'GHOLDERA111111111111111111111111111111111111111111111111';
36+
const holderB = 'GHOLDERB222222222222222222222222222222222222222222222222';
37+
const holderC = 'GHOLDERC333333333333333333333333333333333333333333333333';
38+
const newInvestor =
39+
'GINVESTORD44444444444444444444444444444444444444444444444';
40+
41+
const INITIAL_PRICE = 1_000_000n;
42+
const POST_BUY_PRICE = 1_250_000n;
43+
44+
// In-memory stand-ins for the ownership read model and the price
45+
// snapshot read model, keyed the same way the real Prisma calls are.
46+
const ownershipStore = new Map<string, number>();
47+
let currentSnapshotPrice: bigint | null = null;
48+
49+
beforeEach(() => {
50+
jest.restoreAllMocks();
51+
ownershipStore.clear();
52+
currentSnapshotPrice = null;
53+
54+
(prisma.creatorProfile.findFirst as any) = jest.fn(async () => ({
55+
id: creatorId,
56+
}));
57+
58+
(prisma.keyOwnership.count as any) = jest.fn(async (args: any) => {
59+
let count = 0;
60+
for (const [key, bal] of ownershipStore.entries()) {
61+
if (key.endsWith(`:${args.where.creatorId}`) && bal > 0) {
62+
count++;
63+
}
64+
}
65+
return count;
66+
});
67+
68+
(prisma.keyOwnership.findFirst as any) = jest.fn(async (args: any) => {
69+
const { ownerAddress, creatorId: cid } = args.where;
70+
const key = `${ownerAddress}:${cid}`;
71+
const bal = ownershipStore.get(key) || 0;
72+
return { balance: bal } as any;
73+
});
74+
75+
(prisma.keyOwnership.upsert as any) = jest.fn(async (args: any) => {
76+
const { ownerAddress, creatorId: cid } = args.create;
77+
const key = `${ownerAddress}:${cid}`;
78+
const current = ownershipStore.get(key) || 0;
79+
const change = args.update.balance.increment;
80+
const newBal = current + change;
81+
ownershipStore.set(key, newBal);
82+
return { ownerAddress, creatorId: cid, balance: newBal } as any;
83+
});
84+
85+
(prisma.keyOwnership.aggregate as any) = jest.fn(async (args: any) => {
86+
let sum = 0;
87+
for (const [key, bal] of ownershipStore.entries()) {
88+
if (key.endsWith(`:${args.where.creatorId}`)) {
89+
sum += bal;
90+
}
91+
}
92+
return { _sum: { balance: sum } } as any;
93+
});
94+
95+
(prisma.creatorPriceSnapshot.findUnique as any) = jest.fn(async () => {
96+
if (currentSnapshotPrice === null) return null;
97+
return { currentPrice: currentSnapshotPrice } as any;
98+
});
99+
});
100+
101+
it('reflects supply, holder count, and price after a buy transaction', async () => {
102+
// ── Seed: initial supply of 10 keys held by 3 holders ──────────────
103+
await updateOwnership(holderA, creatorId, 4);
104+
await updateOwnership(holderB, creatorId, 3);
105+
await updateOwnership(holderC, creatorId, 3);
106+
currentSnapshotPrice = INITIAL_PRICE;
107+
108+
const reqBefore = makeReq(creatorId);
109+
const resBefore = makeRes();
110+
await httpGetCreatorStats(reqBefore, resBefore, makeNext());
111+
112+
const before = resBefore.json.mock.calls[0][0].data;
113+
expect(resBefore.status).toHaveBeenCalledWith(200);
114+
expect(before.totalSupply).toBe(10);
115+
expect(before.holderCount).toBe(3);
116+
expect(before.currentPrice).toBe(INITIAL_PRICE.toString());
117+
118+
// ── Simulate a buy of 5 keys by a new investor ─────────────────────
119+
await updateOwnership(newInvestor, creatorId, 5);
120+
// The indexer records the trade's price on the bonding curve as the
121+
// new current price once the buy lands.
122+
currentSnapshotPrice = POST_BUY_PRICE;
123+
124+
const reqAfter = makeReq(creatorId);
125+
const resAfter = makeRes();
126+
await httpGetCreatorStats(reqAfter, resAfter, makeNext());
127+
128+
expect(resAfter.status).toHaveBeenCalledWith(200);
129+
const after = resAfter.json.mock.calls[0][0].data;
130+
131+
// Supply incremented correctly after the buy (10 + 5 = 15).
132+
expect(after.totalSupply).toBe(15);
133+
// Holder count incremented since the new investor is a first-time buyer.
134+
expect(after.holderCount).toBe(4);
135+
expect(after.holder_count).toBe(4);
136+
// Price reflects the updated bonding-curve supply.
137+
expect(after.currentPrice).toBe(POST_BUY_PRICE.toString());
138+
expect(after.currentPrice).not.toBe(before.currentPrice);
139+
});
140+
141+
it('does not change holder count when an existing holder buys more keys', async () => {
142+
await updateOwnership(holderA, creatorId, 4);
143+
await updateOwnership(holderB, creatorId, 3);
144+
await updateOwnership(holderC, creatorId, 3);
145+
currentSnapshotPrice = INITIAL_PRICE;
146+
147+
// An existing holder (not a new investor) buys more keys.
148+
await updateOwnership(holderA, creatorId, 5);
149+
150+
const req = makeReq(creatorId);
151+
const res = makeRes();
152+
await httpGetCreatorStats(req, res, makeNext());
153+
154+
const data = res.json.mock.calls[0][0].data;
155+
expect(data.totalSupply).toBe(15);
156+
expect(data.holderCount).toBe(3);
157+
});
158+
});

0 commit comments

Comments
 (0)