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 e69de29..a0853ab 100644 --- a/src/modules/creators/creator-list-price-filter.integration.test.ts +++ b/src/modules/creators/creator-list-price-filter.integration.test.ts @@ -0,0 +1,168 @@ +// src/modules/creators/creator-list-price-filter.integration.test.ts +// Integration tests for #419 — min_price and max_price filtering. + +import supertest from 'supertest'; +import app from '../../app'; +import { prisma } from '../../utils/prisma.utils'; + +const USER_IDS = ['price-filter-user-1', 'price-filter-user-2', 'price-filter-user-3']; +const HANDLES = ['price-filter-creator-1', 'price-filter-creator-2', 'price-filter-creator-3']; + +describe('#419 min_price and max_price filtering', () => { + let creatorIds: string[]; + + beforeAll(async () => { + creatorIds = []; + + for (let i = 0; i < 3; i++) { + await prisma.user.upsert({ + where: { id: USER_IDS[i] }, + create: { + id: USER_IDS[i], + email: `price-filter-${i}@example.test`, + passwordHash: 'dummy-hash', + firstName: 'Price', + lastName: `Filter ${i}`, + }, + update: {}, + }); + + const creator = await prisma.creatorProfile.upsert({ + where: { userId: USER_IDS[i] }, + create: { + userId: USER_IDS[i], + handle: HANDLES[i], + displayName: `Creator ${i}`, + }, + update: {}, + }); + + creatorIds.push(creator.id); + } + + // Seed price snapshots: 1M, 3M, 5M stroops + const prices = [1_000_000n, 3_000_000n, 5_000_000n]; + for (let i = 0; i < 3; i++) { + await prisma.creatorPriceSnapshot.upsert({ + where: { creatorId: creatorIds[i] }, + create: { + creatorId: creatorIds[i], + currentPrice: prices[i], + price24hAgo: prices[i], + lastTradeAt: new Date(), + }, + update: { + currentPrice: prices[i], + price24hAgo: prices[i], + lastTradeAt: new Date(), + }, + }); + } + }); + + afterAll(async () => { + await prisma.creatorPriceSnapshot.deleteMany({ + where: { creatorId: { in: creatorIds } }, + }); + await prisma.creatorProfile.deleteMany({ + where: { handle: { in: HANDLES } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: USER_IDS } }, + }); + await prisma.$disconnect(); + }); + + it('minPrice alone filters out creators below the value', async () => { + const res = await supertest(app).get('/api/v1/creators?minPrice=2000000'); + expect(res.status).toBe(200); + + const ids = (res.body.data.items as any[]) + .filter((c: any) => creatorIds.includes(c.id)) + .map((c: any) => c.id); + + // Only creators with price >= 2M (creators 1 and 2) + expect(ids).toContain(creatorIds[1]); // 3M + expect(ids).toContain(creatorIds[2]); // 5M + expect(ids).not.toContain(creatorIds[0]); // 1M + }); + + it('maxPrice alone filters out creators above the value', async () => { + const res = await supertest(app).get('/api/v1/creators?maxPrice=4000000'); + expect(res.status).toBe(200); + + const ids = (res.body.data.items as any[]) + .filter((c: any) => creatorIds.includes(c.id)) + .map((c: any) => c.id); + + // Only creators with price <= 4M (creators 0 and 1) + expect(ids).toContain(creatorIds[0]); // 1M + expect(ids).toContain(creatorIds[1]); // 3M + expect(ids).not.toContain(creatorIds[2]); // 5M + }); + + it('both params together return only creators within range (inclusive)', async () => { + const res = await supertest(app).get( + '/api/v1/creators?minPrice=2000000&maxPrice=4000000' + ); + expect(res.status).toBe(200); + + const ids = (res.body.data.items as any[]) + .filter((c: any) => creatorIds.includes(c.id)) + .map((c: any) => c.id); + + // Only creator 1 (3M) is in range [2M, 4M] + expect(ids).toContain(creatorIds[1]); + expect(ids).not.toContain(creatorIds[0]); + expect(ids).not.toContain(creatorIds[2]); + }); + + it('returns 400 when minPrice > maxPrice', async () => { + const res = await supertest(app).get( + '/api/v1/creators?minPrice=5000000&maxPrice=1000000' + ); + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + 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 () => { + const res = await supertest(app).get( + '/api/v1/creators?minPrice=1000000&maxPrice=5000000&limit=10&offset=0&sort=createdAt&order=desc' + ); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.meta.limit).toBe(10); + expect(res.body.data.meta.offset).toBe(0); + }); + + it('combines correctly with verified filter', async () => { + // Mark creator 1 as verified + await prisma.creatorProfile.update({ + where: { id: creatorIds[1] }, + data: { isVerified: true }, + }); + + const res = await supertest(app).get( + '/api/v1/creators?minPrice=1000000&maxPrice=5000000&verified=true' + ); + expect(res.status).toBe(200); + + const ids = (res.body.data.items as any[]) + .filter((c: any) => creatorIds.includes(c.id)) + .map((c: any) => c.id); + + // Only verified creator 1 within price range + expect(ids).toContain(creatorIds[1]); + expect(ids).not.toContain(creatorIds[0]); + expect(ids).not.toContain(creatorIds[2]); + + // Cleanup + await prisma.creatorProfile.update({ + where: { id: creatorIds[1] }, + data: { isVerified: false }, + }); + }); +}); 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 4f7b8ec..9f14e12 100644 --- a/src/modules/creators/creators.sort.ts +++ b/src/modules/creators/creators.sort.ts @@ -50,6 +50,6 @@ export function mapCreatorListSort( } return { - [field]: { sort: order, nulls: 'last' }, + [field]: order, } as Prisma.CreatorProfileOrderByWithRelationInput; } 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]; }