From 2fdc2b6099cc473ad340e1c80372fedb74a1715a Mon Sep 17 00:00:00 2001 From: tecAlhaji Date: Sat, 27 Jun 2026 13:00:45 +0100 Subject: [PATCH 1/2] test: verify filtered creator count in pagination metadata --- jest.setup.ts | 2 + ...ator-list-price-filter.integration.test.ts | 3 +- ...t-price-filtered-total.integration.test.ts | 108 ++++++++++++++++++ .../creators/creators-cache-key.utils.ts | 2 + src/modules/creators/creators.sort.ts | 2 +- 5 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 src/modules/creators/creator-list-price-filtered-total.integration.test.ts diff --git a/jest.setup.ts b/jest.setup.ts index 209b855..f737bb3 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -14,3 +14,5 @@ process.env.CLOUDINARY_API_KEY ??= 'test-api-key'; process.env.CLOUDINARY_API_SECRET ??= 'test-api-secret'; process.env.PAYSTACK_SECRET_KEY ??= 'test-paystack-secret'; process.env.APP_SECRET ??= 'accesslayer_test_secret_key_32_bytes_long_xxxx'; +process.env.DB_QUERY_TIMEOUT_MS = '30000'; +jest.setTimeout(30000); diff --git a/src/modules/creators/creator-list-price-filter.integration.test.ts b/src/modules/creators/creator-list-price-filter.integration.test.ts index b19f8c7..a0853ab 100644 --- a/src/modules/creators/creator-list-price-filter.integration.test.ts +++ b/src/modules/creators/creator-list-price-filter.integration.test.ts @@ -124,7 +124,8 @@ describe('#419 min_price and max_price filtering', () => { expect(res.status).toBe(400); expect(res.body.success).toBe(false); expect(res.body.error.code).toBe('VALIDATION_ERROR'); - expect(res.body.error.message).toContain('minPrice'); + expect(res.body.error.message).toBe('Invalid query parameters'); + expect(res.body.error.details[0].field).toBe('minPrice'); }); it('combines correctly with sort and pagination', async () => { diff --git a/src/modules/creators/creator-list-price-filtered-total.integration.test.ts b/src/modules/creators/creator-list-price-filtered-total.integration.test.ts new file mode 100644 index 0000000..7e25b21 --- /dev/null +++ b/src/modules/creators/creator-list-price-filtered-total.integration.test.ts @@ -0,0 +1,108 @@ +// src/modules/creators/creator-list-price-filtered-total.integration.test.ts + +import supertest from 'supertest'; +import app from '../../app'; +import { prisma } from '../../utils/prisma.utils'; + +const USER_IDS = [ + 'filtered-total-user-1', + 'filtered-total-user-2', + 'filtered-total-user-3', + 'filtered-total-user-4', + 'filtered-total-user-5', +]; + +const HANDLES = [ + 'filtered-total-creator-1', + 'filtered-total-creator-2', + 'filtered-total-creator-3', + 'filtered-total-creator-4', + 'filtered-total-creator-5', +]; + +describe('GET /api/v1/creators — filtered total count with price range', () => { + let creatorIds: string[] = []; + + beforeAll(async () => { + // Ensure database is completely clean of any conflicting data + await prisma.keyOwnership.deleteMany({}); + await prisma.creatorPriceSnapshot.deleteMany({}); + await prisma.creatorProfile.deleteMany({}); + await prisma.user.deleteMany({}); + + creatorIds = []; + + // Seed exactly 5 users and creators + for (let i = 0; i < 5; i++) { + await prisma.user.create({ + data: { + id: USER_IDS[i], + email: `filtered-total-${i}@example.test`, + passwordHash: 'dummy-hash', + firstName: 'Filtered', + lastName: `Total ${i}`, + }, + }); + + const creator = await prisma.creatorProfile.create({ + data: { + userId: USER_IDS[i], + handle: HANDLES[i], + displayName: `Creator ${i}`, + }, + }); + + creatorIds.push(creator.id); + } + + // Seed exactly 5 creators with varied prices: 1M, 2M, 3M, 4M, 5M stroops + const prices = [1_000_000n, 2_000_000n, 3_000_000n, 4_000_000n, 5_000_000n]; + for (let i = 0; i < 5; i++) { + await prisma.creatorPriceSnapshot.create({ + data: { + creatorId: creatorIds[i], + currentPrice: prices[i], + price24hAgo: prices[i], + lastTradeAt: new Date(), + }, + }); + } + }); + + afterAll(async () => { + // Teardown + await prisma.creatorPriceSnapshot.deleteMany({ + where: { creatorId: { in: creatorIds } }, + }); + await prisma.creatorProfile.deleteMany({ + where: { id: { in: creatorIds } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: USER_IDS } }, + }); + await prisma.$disconnect(); + }); + + it('should verify that meta.total reflects the filtered creator count and matches the response data length when a price range is applied', async () => { + // Apply a price range filter matching exactly three creators: [2M, 4M] (prices 2M, 3M, 4M) + const res = await supertest(app).get('/api/v1/creators?minPrice=2000000&maxPrice=4000000'); + expect(res.status).toBe(200); + + const response = { + data: res.body.data.items, + meta: res.body.data.meta, + }; + const { meta } = response; + + // Assert requirements: + // - meta.total === 3 + // - response.data.length === 3 + // - response.data.length === response.meta.total + expect(meta.total).toBe(3); + expect(response.data.length).toBe(3); + expect(response.data.length).toBe(response.meta.total); + + // Verify that meta.total is the filtered count, not the total creator count in the database (5) + expect(meta.total).not.toBe(5); + }); +}); diff --git a/src/modules/creators/creators-cache-key.utils.ts b/src/modules/creators/creators-cache-key.utils.ts index 3a7e23d..1b764e7 100644 --- a/src/modules/creators/creators-cache-key.utils.ts +++ b/src/modules/creators/creators-cache-key.utils.ts @@ -47,6 +47,8 @@ export function buildCreatorFeedCacheKey(query: CreatorListQueryType): string { query.include !== undefined && query.include.length > 0 ? query.include.join(',') : undefined, + minPrice: query.minPrice !== undefined ? query.minPrice.toString() : undefined, + maxPrice: query.maxPrice !== undefined ? query.maxPrice.toString() : undefined, }; const canonical = buildCanonicalParamString(params); diff --git a/src/modules/creators/creators.sort.ts b/src/modules/creators/creators.sort.ts index 4703713..89701b7 100644 --- a/src/modules/creators/creators.sort.ts +++ b/src/modules/creators/creators.sort.ts @@ -40,6 +40,6 @@ export function mapCreatorListSort( } return { - [field]: { sort: order, nulls: 'last' }, + [field]: order, } as Prisma.CreatorProfileOrderByWithRelationInput; } From 3303bf098f64c1c9b946c57559305b9a2f60864f Mon Sep 17 00:00:00 2001 From: tecAlhaji Date: Sat, 27 Jun 2026 13:13:37 +0100 Subject: [PATCH 2/2] test: verify wallet holdings are sorted by total value by default --- ...-holdings-default-sort.integration.test.ts | 141 ++++++++++++++++++ ...oldings-price-snapshot.integration.test.ts | 12 +- .../wallets/wallet-holdings.service.ts | 7 + 3 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 src/modules/wallets/__tests__/wallet-holdings-default-sort.integration.test.ts diff --git a/src/modules/wallets/__tests__/wallet-holdings-default-sort.integration.test.ts b/src/modules/wallets/__tests__/wallet-holdings-default-sort.integration.test.ts new file mode 100644 index 0000000..b449351 --- /dev/null +++ b/src/modules/wallets/__tests__/wallet-holdings-default-sort.integration.test.ts @@ -0,0 +1,141 @@ +// src/modules/wallets/__tests__/wallet-holdings-default-sort.integration.test.ts + +import supertest from 'supertest'; +import app from '../../../app'; +import { prisma } from '../../../utils/prisma.utils'; + +describe('GET /api/v1/wallets/:address/holdings - default sort order', () => { + const WALLET_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + + const USER_IDS = [ + 'wallet-sort-user-1', + 'wallet-sort-user-2', + 'wallet-sort-user-3', + ]; + + const CREATOR_IDS = [ + 'wallet-sort-creator-1', + 'wallet-sort-creator-2', + 'wallet-sort-creator-3', + ]; + + 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({}); + + // Create 3 users for the creators + for (let i = 0; i < 3; i++) { + await prisma.user.create({ + data: { + id: USER_IDS[i], + email: `wallet-sort-user-${i}@example.test`, + passwordHash: 'dummy-hash', + firstName: 'Wallet', + lastName: `Sort ${i}`, + }, + }); + } + + // Create 3 creators + for (let i = 0; i < 3; i++) { + await prisma.creatorProfile.create({ + data: { + id: CREATOR_IDS[i], + userId: USER_IDS[i], + handle: `wallet_sort_creator_${i}`, + displayName: `Wallet Sort Creator ${i}`, + }, + }); + } + + // Create price snapshots with different currentPrice (BigInt) + // Creator 1: price = 100 + // Creator 2: price = 50 + // Creator 3: price = 50 + await prisma.creatorPriceSnapshot.create({ + data: { + creatorId: CREATOR_IDS[0], + currentPrice: 100n, + }, + }); + await prisma.creatorPriceSnapshot.create({ + data: { + creatorId: CREATOR_IDS[1], + currentPrice: 50n, + }, + }); + await prisma.creatorPriceSnapshot.create({ + data: { + creatorId: CREATOR_IDS[2], + currentPrice: 50n, + }, + }); + + // Create key ownership records + // Creator 1: balance = 3 -> total value = 300 + // Creator 2: balance = 3 -> total value = 150 + // Creator 3: balance = 1 -> total value = 50 + // To ensure the test does not pass by accident due to creation/insert order, + // we insert Creator 2, then Creator 3, then Creator 1. + await prisma.keyOwnership.create({ + data: { + ownerAddress: WALLET_ADDRESS, + creatorId: CREATOR_IDS[1], + balance: 3.0, + }, + }); + await prisma.keyOwnership.create({ + data: { + ownerAddress: WALLET_ADDRESS, + creatorId: CREATOR_IDS[2], + balance: 1.0, + }, + }); + await prisma.keyOwnership.create({ + data: { + ownerAddress: WALLET_ADDRESS, + creatorId: CREATOR_IDS[0], + balance: 3.0, + }, + }); + }); + + afterAll(async () => { + // Clean up seeded database tables + await prisma.keyOwnership.deleteMany({}); + await prisma.creatorPriceSnapshot.deleteMany({}); + await prisma.creatorProfile.deleteMany({}); + await prisma.user.deleteMany({}); + }); + + it('should return holdings ordered by total value descending by default', async () => { + const res = await supertest(app).get(`/api/v1/wallets/${WALLET_ADDRESS}/holdings`); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const { items, total } = res.body.data; + expect(items).toHaveLength(3); + expect(total).toBe(3); + + // Verify ordering by total holding value (balance * price) descending: + // 1. Creator 1: total_value = 300 + // 2. Creator 2: total_value = 150 + // 3. Creator 3: total_value = 50 + expect(items[0].creator_id).toBe(CREATOR_IDS[0]); + expect(items[0].total_value).toBe('300'); + + expect(items[1].creator_id).toBe(CREATOR_IDS[1]); + expect(items[1].total_value).toBe('150'); + + expect(items[2].creator_id).toBe(CREATOR_IDS[2]); + expect(items[2].total_value).toBe('50'); + + // Confirm that the first has the highest and the last has the lowest + const totalValues = items.map((item: any) => Number(item.total_value)); + expect(totalValues[0]).toBeGreaterThan(totalValues[1]); + expect(totalValues[1]).toBeGreaterThan(totalValues[2]); + }); +}); diff --git a/src/modules/wallets/wallet-holdings-price-snapshot.integration.test.ts b/src/modules/wallets/wallet-holdings-price-snapshot.integration.test.ts index 092f57c..59ac607 100644 --- a/src/modules/wallets/wallet-holdings-price-snapshot.integration.test.ts +++ b/src/modules/wallets/wallet-holdings-price-snapshot.integration.test.ts @@ -118,10 +118,10 @@ describe('Holdings total_value recalculated after price snapshot update', () => const [items, total] = await fetchWalletHoldings(WALLET_ADDRESS); expect(total).toBe(2); - expect(items[0].current_price).toBe('100'); - expect(items[0].total_value).toBe('300'); - expect(items[1].current_price).toBe('200'); - expect(items[1].total_value).toBe('400'); + expect(items[0].current_price).toBe('200'); + expect(items[0].total_value).toBe('400'); + expect(items[1].current_price).toBe('100'); + expect(items[1].total_value).toBe('300'); }); it('total_value for each holding updates independently when snapshot changes', async () => { @@ -156,9 +156,9 @@ describe('Holdings total_value recalculated after price snapshot update', () => ]); const [after] = await fetchWalletHoldings(WALLET_ADDRESS); - expect(before[0].total_value).toBe('300'); + expect(before[1].total_value).toBe('300'); expect(after[0].total_value).toBe('450'); - expect(before[1].total_value).toBe(after[1].total_value); + expect(before[0].total_value).toBe(after[1].total_value); }); it('returns empty items for a wallet with no holdings', async () => { diff --git a/src/modules/wallets/wallet-holdings.service.ts b/src/modules/wallets/wallet-holdings.service.ts index 007b776..16c8cd4 100644 --- a/src/modules/wallets/wallet-holdings.service.ts +++ b/src/modules/wallets/wallet-holdings.service.ts @@ -76,5 +76,12 @@ export async function fetchWalletHoldings( }; }); + // Default sort order is by total holding value descending. + items.sort((a, b) => { + const valA = a.total_value !== null ? Number(a.total_value) : 0; + const valB = b.total_value !== null ? Number(b.total_value) : 0; + return valB - valA; + }); + return [items, total]; }