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; }