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,109 @@
// Integration test: GET /api/v1/creators/:id/holders — key count sort (#692)
//
// Verifies holders are returned sorted descending by key count, that ties
// are broken alphabetically by wallet address, and that each entry includes
// wallet_address, key_count, share_percent, and rank.

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

const WALLET_A = '0xAAAA000000000000000000000000000000000A'; // 10 keys
const WALLET_B = '0xBBBB000000000000000000000000000000000B'; // 5 keys
const WALLET_C = '0xCCCC000000000000000000000000000000000C'; // 10 keys, ties with A

describe('GET /api/v1/creators/:id/holders — key count descending (#692)', () => {
let creatorId: string;

beforeAll(async () => {
const user = await prisma.user.create({
data: {
id: 'holder-key-count-sort-user',
email: 'holder-key-count-sort@example.com',
passwordHash: 'dummy-hash',
firstName: 'HolderKeyCount',
lastName: 'SortTest',
},
});

const creator = await prisma.creatorProfile.create({
data: {
userId: user.id,
handle: 'holder-key-count-sort-creator',
displayName: 'Holder Key Count Sort Creator',
},
});
creatorId = creator.id;

await prisma.keyOwnership.createMany({
data: [
{ ownerAddress: WALLET_B, creatorId, balance: 5 },
{ ownerAddress: WALLET_C, creatorId, balance: 10 },
{ ownerAddress: WALLET_A, creatorId, balance: 10 },
],
});
});

afterAll(async () => {
await prisma.keyOwnership.deleteMany({ where: { creatorId } });
await prisma.creatorProfile.delete({ where: { id: creatorId } });
await prisma.user.delete({ where: { id: 'holder-key-count-sort-user' } });
await prisma.$disconnect();
});

it('sorts holders descending by key count with A and C before B', async () => {
const res = await supertest(app).get(
`/api/v1/creators/${creatorId}/holders`
);

expect(res.status).toBe(200);
const items = res.body.data.items;
expect(items.map((i: any) => i.wallet_address)).toEqual([
WALLET_A,
WALLET_C,
WALLET_B,
]);
});

it('breaks the tie between A and C alphabetically by wallet address', async () => {
const res = await supertest(app).get(
`/api/v1/creators/${creatorId}/holders`
);

const [first, second] = res.body.data.items;
expect(first.wallet_address).toBe(WALLET_A);
expect(second.wallet_address).toBe(WALLET_C);
expect(WALLET_A < WALLET_C).toBe(true);
});

it('each entry includes wallet_address, key_count, share_percent, and rank', async () => {
const res = await supertest(app).get(
`/api/v1/creators/${creatorId}/holders`
);

const items = res.body.data.items;
expect(items).toHaveLength(3);
for (const item of items) {
expect(item).toHaveProperty('wallet_address');
expect(item).toHaveProperty('key_count');
expect(item).toHaveProperty('share_percent');
expect(item).toHaveProperty('rank');
}

expect(items.map((i: any) => i.rank)).toEqual([1, 2, 3]);
expect(items.map((i: any) => i.key_count)).toEqual([10, 10, 5]);
});

it('share percentages across all holders sum to 100%', async () => {
const res = await supertest(app).get(
`/api/v1/creators/${creatorId}/holders`
);

const items = res.body.data.items;
const totalShare = items.reduce(
(sum: number, i: any) => sum + i.share_percent,
0
);
expect(totalShare).toBeCloseTo(100, 5);
});
});
112 changes: 112 additions & 0 deletions src/__tests__/integration/wallet-activity-order.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Integration test: transaction history endpoint sort order (#683)
//
// Verifies GET /api/v1/wallets/:address/activity always returns entries
// newest-first by createdAt, regardless of insertion order, and that the
// sort order is preserved across paginated pages.

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

const WALLET_ADDRESS =
'GORDERTESTAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const TEST_USER_ID = 'wallet-order-test-user';

describe('GET /api/v1/wallets/:address/activity — descending timestamp order', () => {
let creatorId: string;

// Timestamps intentionally inserted out of chronological order.
const base = Date.now();
const T_PLUS_30 = new Date(base + 30_000);
const T_PLUS_10 = new Date(base + 10_000);
const T_PLUS_20 = new Date(base + 20_000);

beforeAll(async () => {
const user = await prisma.user.create({
data: {
id: TEST_USER_ID,
email: 'wallet-order-test@example.com',
passwordHash: 'dummy-hash',
firstName: 'Order',
lastName: 'Test',
},
});

const creator = await prisma.creatorProfile.create({
data: {
userId: user.id,
handle: 'wallet-order-test-creator',
displayName: 'Wallet Order Test Creator',
},
});
creatorId = creator.id;

// Insert out of order: +30s, then +10s, then +20s.
for (const createdAt of [T_PLUS_30, T_PLUS_10, T_PLUS_20]) {
await prisma.activity.create({
data: {
type: 'KEY_BOUGHT',
actor: WALLET_ADDRESS,
creatorId,
payload: {
amount: '1',
price_at_trade: '1',
fee_paid: '0.01',
ledger_sequence: 1,
},
createdAt,
},
});
}
});

afterAll(async () => {
await prisma.activity.deleteMany({ where: { actor: WALLET_ADDRESS } });
await prisma.creatorProfile.delete({ where: { id: creatorId } });
await prisma.user.delete({ where: { id: TEST_USER_ID } });
await prisma.$disconnect();
});

it('returns entries newest-first regardless of insertion order', async () => {
const res = await supertest(app).get(
`/api/v1/wallets/${WALLET_ADDRESS}/activity?limit=20&offset=0`
);

expect(res.status).toBe(200);
const items = res.body.data.items;
expect(items).toHaveLength(3);
expect(items.map((i: any) => i.timestamp)).toEqual([
T_PLUS_30.toISOString(),
T_PLUS_20.toISOString(),
T_PLUS_10.toISOString(),
]);
});

it('maintains descending sort order across paginated pages', async () => {
const pageOne = await supertest(app).get(
`/api/v1/wallets/${WALLET_ADDRESS}/activity?limit=2&offset=0`
);
const pageTwo = await supertest(app).get(
`/api/v1/wallets/${WALLET_ADDRESS}/activity?limit=2&offset=2`
);

expect(pageOne.body.data.items.map((i: any) => i.timestamp)).toEqual([
T_PLUS_30.toISOString(),
T_PLUS_20.toISOString(),
]);
expect(pageTwo.body.data.items.map((i: any) => i.timestamp)).toEqual([
T_PLUS_10.toISOString(),
]);
});

it('created_at (timestamp) of each entry matches the inserted database value', async () => {
const res = await supertest(app).get(
`/api/v1/wallets/${WALLET_ADDRESS}/activity?limit=20&offset=0`
);

const [first, second, third] = res.body.data.items;
expect(new Date(first.timestamp)).toEqual(T_PLUS_30);
expect(new Date(second.timestamp)).toEqual(T_PLUS_20);
expect(new Date(third.timestamp)).toEqual(T_PLUS_10);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,26 @@ const HOLDER_A: HolderRecord = {
wallet_address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1',
key_balance: 30,
held_since: TS_WALLET_A,
key_count: 30,
share_percent: 46.15,
rank: 1,
};
const HOLDER_B: HolderRecord = {
wallet_address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB2',
key_balance: 20,
held_since: TS_WALLET_B,
key_count: 20,
share_percent: 30.77,
rank: 2,
};
// Wallet C bought twice — held_since is still its FIRST buy (TS_WALLET_C)
const HOLDER_C: HolderRecord = {
wallet_address: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC3',
key_balance: 15, // bought 10 then 5 more
held_since: TS_WALLET_C, // createdAt from first buy — never updated on re-buy
key_count: 15,
share_percent: 23.08,
rank: 3,
};

describe('GET /creators/:id/holders — held_since per wallet (#493)', () => {
Expand Down
6 changes: 5 additions & 1 deletion src/modules/creators/creator-holders.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,14 @@ function makeHolder(
index: number,
overrides: Partial<HolderRecord> = {}
): HolderRecord {
const key_balance = (4 - index) * 10; // 30, 20, 10 for indices 1, 2, 3
return {
wallet_address: `GWALLETADDRESS${String(index).padStart(46, '0')}`,
key_balance: (4 - index) * 10, // 30, 20, 10 for indices 1, 2, 3
key_balance,
held_since: new Date(`2024-0${index}-01T00:00:00.000Z`),
key_count: key_balance,
share_percent: 0,
rank: index,
...overrides,
};
}
Expand Down
35 changes: 27 additions & 8 deletions src/modules/creators/creator-holders.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ export interface HolderRecord {
wallet_address: string;
key_balance: number;
held_since: Date;
/** Alias of key_balance kept for response-shape compatibility. */
key_count: number;
/** This wallet's share of all outstanding keys for the creator, 0-100. */
share_percent: number;
/** 1-based position in the full (offset-aware) sorted holder list. */
rank: number;
}

/**
Expand Down Expand Up @@ -52,10 +58,14 @@ export async function fetchCreatorHolders(
balance: { gt: 0 },
};

const orderBy: Prisma.KeyOwnershipOrderByWithRelationInput =
sort === 'held_since' ? { createdAt: 'asc' } : { balance: 'desc' };
// Tie-break alphabetically by wallet address so results are deterministic
// when two holders have the same balance.
const orderBy: Prisma.KeyOwnershipOrderByWithRelationInput[] =
sort === 'held_since'
? [{ createdAt: 'asc' }, { ownerAddress: 'asc' }]
: [{ balance: 'desc' }, { ownerAddress: 'asc' }];

const [rows, total] = await Promise.all([
const [rows, total, balanceSum] = await Promise.all([
prisma.keyOwnership.findMany({
where,
orderBy,
Expand All @@ -68,13 +78,22 @@ export async function fetchCreatorHolders(
},
}),
prisma.keyOwnership.count({ where }),
prisma.keyOwnership.aggregate({ where, _sum: { balance: true } }),
]);

const holders: HolderRecord[] = rows.map(row => ({
wallet_address: row.ownerAddress,
key_balance: Number(row.balance),
held_since: row.createdAt,
}));
const totalKeys = Number(balanceSum._sum.balance ?? 0);

const holders: HolderRecord[] = rows.map((row, index) => {
const keyBalance = Number(row.balance);
return {
wallet_address: row.ownerAddress,
key_balance: keyBalance,
held_since: row.createdAt,
key_count: keyBalance,
share_percent: totalKeys > 0 ? (keyBalance / totalKeys) * 100 : 0,
rank: offset + index + 1,
};
});

if (holders.length === 0) {
const durationMs = Date.now() - startMs;
Expand Down
Loading
Loading