diff --git a/src/__tests__/integration/creator-holders-key-count-sort.integration.test.ts b/src/__tests__/integration/creator-holders-key-count-sort.integration.test.ts new file mode 100644 index 0000000..50ad8dd --- /dev/null +++ b/src/__tests__/integration/creator-holders-key-count-sort.integration.test.ts @@ -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); + }); +}); diff --git a/src/__tests__/integration/wallet-activity-order.integration.test.ts b/src/__tests__/integration/wallet-activity-order.integration.test.ts new file mode 100644 index 0000000..e6dbfcb --- /dev/null +++ b/src/__tests__/integration/wallet-activity-order.integration.test.ts @@ -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); + }); +}); diff --git a/src/modules/creators/creator-holders-held-since.integration.test.ts b/src/modules/creators/creator-holders-held-since.integration.test.ts index e41bd4c..fe9f066 100644 --- a/src/modules/creators/creator-holders-held-since.integration.test.ts +++ b/src/modules/creators/creator-holders-held-since.integration.test.ts @@ -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)', () => { diff --git a/src/modules/creators/creator-holders.integration.test.ts b/src/modules/creators/creator-holders.integration.test.ts index 9c0648d..fcfa921 100644 --- a/src/modules/creators/creator-holders.integration.test.ts +++ b/src/modules/creators/creator-holders.integration.test.ts @@ -43,10 +43,14 @@ function makeHolder( index: number, overrides: Partial = {} ): 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, }; } diff --git a/src/modules/creators/creator-holders.service.ts b/src/modules/creators/creator-holders.service.ts index 8f52ecd..5feda42 100644 --- a/src/modules/creators/creator-holders.service.ts +++ b/src/modules/creators/creator-holders.service.ts @@ -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; } /** @@ -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, @@ -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; diff --git a/src/modules/creators/creator-list-search-partial-name.integration.test.ts b/src/modules/creators/creator-list-search-partial-name.integration.test.ts new file mode 100644 index 0000000..07f37e0 --- /dev/null +++ b/src/modules/creators/creator-list-search-partial-name.integration.test.ts @@ -0,0 +1,112 @@ +// src/modules/creators/creator-list-search-partial-name.integration.test.ts +// Integration test for #685 — creator search endpoint returns creators +// whose display name contains the query string, case-insensitively. + +import supertest from 'supertest'; +import app from '../../app'; +import { prisma } from '../../utils/prisma.utils'; + +const USER_IDS = [ + 'search-partial-user-alice', + 'search-partial-user-alicia', + 'search-partial-user-bob', + 'search-partial-user-bobby', +]; +const HANDLES = [ + 'search-partial-alice', + 'search-partial-alicia', + 'search-partial-bob', + 'search-partial-bobby', +]; +const DISPLAY_NAMES = ['Alice', 'Alicia', 'Bob', 'Bobby']; + +describe('#685 creator search — partial display name match', () => { + let creatorIds: string[]; + + beforeAll(async () => { + creatorIds = []; + + for (let i = 0; i < DISPLAY_NAMES.length; i++) { + await prisma.user.upsert({ + where: { id: USER_IDS[i] }, + create: { + id: USER_IDS[i], + email: `search-partial-${i}@example.test`, + passwordHash: 'dummy-hash', + firstName: 'Search', + lastName: `Partial ${i}`, + }, + update: {}, + }); + + const creator = await prisma.creatorProfile.upsert({ + where: { userId: USER_IDS[i] }, + create: { + userId: USER_IDS[i], + handle: HANDLES[i], + displayName: DISPLAY_NAMES[i], + }, + update: { displayName: DISPLAY_NAMES[i] }, + }); + + creatorIds.push(creator.id); + } + }); + + afterAll(async () => { + await prisma.creatorProfile.deleteMany({ + where: { handle: { in: HANDLES } }, + }); + await prisma.user.deleteMany({ where: { id: { in: USER_IDS } } }); + await prisma.$disconnect(); + }); + + function seededMatches(res: supertest.Response): any[] { + return (res.body.data.items as any[]).filter((c: any) => + creatorIds.includes(c.id) + ); + } + + it("'ali' returns Alice and Alicia only", async () => { + const res = await supertest(app).get('/api/v1/creators?search=ali'); + expect(res.status).toBe(200); + + const names = seededMatches(res).map((c: any) => c.name); + expect(names).toEqual(expect.arrayContaining(['Alice', 'Alicia'])); + expect(names).not.toEqual(expect.arrayContaining(['Bob', 'Bobby'])); + }); + + it("'bob' returns Bob and Bobby only", async () => { + const res = await supertest(app).get('/api/v1/creators?search=bob'); + expect(res.status).toBe(200); + + const names = seededMatches(res).map((c: any) => c.name); + expect(names).toEqual(expect.arrayContaining(['Bob', 'Bobby'])); + expect(names).not.toEqual(expect.arrayContaining(['Alice', 'Alicia'])); + }); + + it("'xyz' returns an empty array", async () => { + const res = await supertest(app).get('/api/v1/creators?search=xyz'); + expect(res.status).toBe(200); + + expect(seededMatches(res)).toHaveLength(0); + }); + + it("'ALI' matches case-insensitively", async () => { + const res = await supertest(app).get('/api/v1/creators?search=ALI'); + expect(res.status).toBe(200); + + const names = seededMatches(res).map((c: any) => c.name); + expect(names).toEqual(expect.arrayContaining(['Alice', 'Alicia'])); + }); + + it('each match includes an id and display name', async () => { + const res = await supertest(app).get('/api/v1/creators?search=ali'); + expect(res.status).toBe(200); + + for (const item of seededMatches(res)) { + expect(item).toHaveProperty('id'); + expect(item).toHaveProperty('name'); + } + }); +}); diff --git a/src/utils/pricing.utils.test.ts b/src/utils/pricing.utils.test.ts index 7d5ff4f..7144e2f 100644 --- a/src/utils/pricing.utils.test.ts +++ b/src/utils/pricing.utils.test.ts @@ -1,4 +1,4 @@ -import { computeBuyCost } from './pricing.utils'; +import { computeBuyCost, computeSellPayout } from './pricing.utils'; describe('computeBuyCost', () => { it('amount 1 at supply 0 returns base cost only', () => { @@ -31,3 +31,46 @@ describe('computeBuyCost', () => { expect(cost).toBeGreaterThanOrEqual(0n); }); }); + +describe('computeSellPayout', () => { + it('sell of 1 key at supply 10 with 5% fee returns the correct net payout', () => { + // Gross: price of the key at supply 9 = 10_000_000 + 9_000_000 = 19_000_000 + // Fee: 5% of 19_000_000 = 950_000 + // Net: 19_000_000 - 950_000 = 18_050_000 + const payout = computeSellPayout(10, 1, 500); + expect(payout).toBe(18_050_000n); + }); + + it('0% fee returns the full gross payout', () => { + const payout = computeSellPayout(10, 1, 0); + expect(payout).toBe(19_000_000n); + }); + + it('100% fee returns 0 net payout', () => { + const payout = computeSellPayout(10, 1, 10000); + expect(payout).toBe(0n); + }); + + it('selling the last key (supply becomes 0) returns the base price minus fee', () => { + // Base price at supply 0 = 10_000_000. 5% fee = 500_000. Net = 9_500_000. + const payout = computeSellPayout(1, 1, 500); + expect(payout).toBe(9_500_000n); + }); + + it('payout is always a non-negative integer in stroops', () => { + const normal = computeSellPayout(10, 5, 500); + expect(normal).toBeGreaterThanOrEqual(0n); + expect(typeof normal).toBe('bigint'); + + // A fee rate above 100% would make gross - fee negative; must clamp to 0. + const overFee = computeSellPayout(10, 1, 20000); + expect(overFee).toBe(0n); + + const zeroAmount = computeSellPayout(10, 0, 500); + expect(zeroAmount).toBe(0n); + }); + + it('throws when selling more keys than the current supply', () => { + expect(() => computeSellPayout(1, 2, 500)).toThrow(); + }); +}); diff --git a/src/utils/pricing.utils.ts b/src/utils/pricing.utils.ts index 2760557..887b231 100644 --- a/src/utils/pricing.utils.ts +++ b/src/utils/pricing.utils.ts @@ -36,3 +36,48 @@ export function computeBuyCost(currentSupply: number, amount: number, feeBps: nu return totalCost; } + +/** + * Helper for computing the XLM payout for selling N keys at the current supply using the + * bonding curve formula, net of the protocol fee. + * Note: This is a placeholder implementation since the real bonding curve math is trapped in an unmerged PR. + * + * @param currentSupply - The current supply of keys (before the sell) + * @param amount - The number of keys to sell + * @param feeBps - The protocol fee in basis points (e.g. 500 = 5%) + * @returns Net XLM payout in stroops as a bigint (never negative) + */ +export function computeSellPayout(currentSupply: number, amount: number, feeBps: number): bigint { + if (amount < 0 || currentSupply < 0) { + throw new Error('Supply and amount must be non-negative'); + } + + if (amount > currentSupply) { + throw new Error('Cannot sell more keys than the current supply'); + } + + if (amount === 0) { + return 0n; + } + + // Same placeholder bonding curve as computeBuyCost. Selling walks the + // curve back down: the key at the top of the supply (currentSupply - 1) + // is sold first, then currentSupply - 2, and so on. + const BASE_COST = 10_000_000n; + const STEP = 1_000_000n; + + let grossPayout = 0n; + for (let i = 0; i < amount; i++) { + const supplyAtSale = BigInt(currentSupply - 1 - i); + grossPayout += BASE_COST + (supplyAtSale * STEP); + } + + const fee = (grossPayout * BigInt(feeBps)) / 10000n; + const netPayout = grossPayout - fee; + + if (netPayout < 0n) { + return 0n; // fee exceeding gross (e.g. feeBps > 10000) must never yield a negative payout + } + + return netPayout; +}