diff --git a/src/__tests__/integration/creator-holders-quantity-sort.test.ts b/src/__tests__/integration/creator-holders-quantity-sort.test.ts new file mode 100644 index 0000000..97c4bc1 --- /dev/null +++ b/src/__tests__/integration/creator-holders-quantity-sort.test.ts @@ -0,0 +1,111 @@ +import supertest from 'supertest'; +import app from '../../app'; +import { prisma } from '../../utils/prisma.utils'; + +describe('GET /api/v1/creators/:id/holders — quantity descending order (#604)', () => { + let creatorId: string; + let singleHolderCreatorId: string; + + beforeAll(async () => { + const user = await prisma.user.create({ + data: { + id: 'holder-qty-sort-test-user', + email: 'holder-qty-sort-test@example.com', + passwordHash: 'dummy-hash', + firstName: 'HolderQty', + lastName: 'SortTest', + }, + }); + + const creator = await prisma.creatorProfile.create({ + data: { + userId: user.id, + handle: 'holder-qty-sort-creator', + displayName: 'Holder Quantity Sort Creator', + }, + }); + creatorId = creator.id; + + // Seed four holders with quantities 5, 1, 10, 3 for the same creator + await prisma.keyOwnership.createMany({ + data: [ + { ownerAddress: '0xqty-5', creatorId: creator.id, balance: 5 }, + { ownerAddress: '0xqty-1', creatorId: creator.id, balance: 1 }, + { ownerAddress: '0xqty-10', creatorId: creator.id, balance: 10 }, + { ownerAddress: '0xqty-3', creatorId: creator.id, balance: 3 }, + ], + }); + + const singleUser = await prisma.user.create({ + data: { + id: 'holder-qty-sort-single-user', + email: 'holder-qty-sort-single@example.com', + passwordHash: 'dummy-hash', + firstName: 'HolderQtySingle', + lastName: 'SortTest', + }, + }); + + const singleCreator = await prisma.creatorProfile.create({ + data: { + userId: singleUser.id, + handle: 'holder-qty-sort-single-creator', + displayName: 'Holder Quantity Sort Single Creator', + }, + }); + singleHolderCreatorId = singleCreator.id; + + await prisma.keyOwnership.create({ + data: { + ownerAddress: '0xqty-single', + creatorId: singleCreator.id, + balance: 7, + }, + }); + }); + + afterAll(async () => { + await prisma.keyOwnership.deleteMany({ + where: { creatorId: { in: [creatorId, singleHolderCreatorId] } }, + }); + await prisma.creatorProfile.deleteMany({ + where: { id: { in: [creatorId, singleHolderCreatorId] } }, + }); + await prisma.user.deleteMany({ + where: { + id: { + in: ['holder-qty-sort-test-user', 'holder-qty-sort-single-user'], + }, + }, + }); + await prisma.$disconnect(); + }); + + it('returns all four holders sorted by quantity descending: 10, 5, 3, 1', 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).toHaveLength(4); + + const quantities = items.map((item: any) => item.key_balance); + expect(quantities).toEqual([10, 5, 3, 1]); + + const addresses = items.map((item: any) => item.wallet_address); + expect(addresses).toEqual(['0xqty-10', '0xqty-5', '0xqty-3', '0xqty-1']); + }); + + it('handles a creator with a single holder correctly', async () => { + const res = await supertest(app).get( + `/api/v1/creators/${singleHolderCreatorId}/holders` + ); + expect(res.status).toBe(200); + + const items = res.body.data.items; + expect(items).toHaveLength(1); + expect(items[0].wallet_address).toBe('0xqty-single'); + expect(items[0].key_balance).toBe(7); + }); +}); diff --git a/src/config.schema.ts b/src/config.schema.ts index 24ff999..a7307db 100644 --- a/src/config.schema.ts +++ b/src/config.schema.ts @@ -97,7 +97,7 @@ export const envSchema = z .int() .positive() .default(300000), - SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().positive().default(500), + SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().positive().default(200), DB_POOL_WAIT_WARN_MS: z.coerce.number().int().positive().default(500), DB_POOL_WAIT_ERROR_MS: z.coerce.number().int().positive().default(2000), CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS: z.coerce diff --git a/src/modules/creators/creator-detail-price-change-sign.integration.test.ts b/src/modules/creators/creator-detail-price-change-sign.integration.test.ts new file mode 100644 index 0000000..0c78e16 --- /dev/null +++ b/src/modules/creators/creator-detail-price-change-sign.integration.test.ts @@ -0,0 +1,114 @@ +import supertest from 'supertest'; +import app from '../../app'; +import { prisma } from '../../utils/prisma.utils'; + +const USER_ID_UP = 'creator-price-change-sign-up-user'; +const HANDLE_UP = 'creator-price-change-sign-up'; + +const USER_ID_DOWN = 'creator-price-change-sign-down-user'; +const HANDLE_DOWN = 'creator-price-change-sign-down'; + +describe('creator detail endpoint — priceChange24h sign (#576)', () => { + let creatorIdUp: string; + let creatorIdDown: string; + + beforeAll(async () => { + await prisma.user.upsert({ + where: { id: USER_ID_UP }, + create: { + id: USER_ID_UP, + email: 'creator-price-change-sign-up@example.test', + passwordHash: 'dummy-hash', + firstName: 'Price', + lastName: 'Up', + }, + update: {}, + }); + await prisma.user.upsert({ + where: { id: USER_ID_DOWN }, + create: { + id: USER_ID_DOWN, + email: 'creator-price-change-sign-down@example.test', + passwordHash: 'dummy-hash', + firstName: 'Price', + lastName: 'Down', + }, + update: {}, + }); + + const creatorUp = await prisma.creatorProfile.upsert({ + where: { userId: USER_ID_UP }, + create: { + userId: USER_ID_UP, + handle: HANDLE_UP, + displayName: 'Price Change Sign Up Creator', + }, + update: {}, + }); + creatorIdUp = creatorUp.id; + + const creatorDown = await prisma.creatorProfile.upsert({ + where: { userId: USER_ID_DOWN }, + create: { + userId: USER_ID_DOWN, + handle: HANDLE_DOWN, + displayName: 'Price Change Sign Down Creator', + }, + update: {}, + }); + creatorIdDown = creatorDown.id; + + // Current price higher than the 24h-ago snapshot -> positive change + await prisma.creatorPriceSnapshot.create({ + data: { + creatorId: creatorIdUp, + currentPrice: BigInt(1_500_000), + price24hAgo: BigInt(1_000_000), + lastTradeAt: new Date(), + }, + }); + + // Current price lower than the 24h-ago snapshot -> negative change + await prisma.creatorPriceSnapshot.create({ + data: { + creatorId: creatorIdDown, + currentPrice: BigInt(800_000), + price24hAgo: BigInt(1_000_000), + lastTradeAt: new Date(), + }, + }); + }); + + afterAll(async () => { + await prisma.creatorPriceSnapshot.deleteMany({ + where: { creatorId: { in: [creatorIdUp, creatorIdDown] } }, + }); + await prisma.creatorProfile.deleteMany({ + where: { handle: { in: [HANDLE_UP, HANDLE_DOWN] } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: [USER_ID_UP, USER_ID_DOWN] } }, + }); + await prisma.$disconnect(); + }); + + it('returns a positive priceChange24h when the price increased over 24h', async () => { + const res = await supertest(app).get( + `/api/v1/creators/${creatorIdUp}/profile` + ); + expect(res.status).toBe(200); + expect(res.body.data).toHaveProperty('priceChange24h'); + expect(res.body.data.priceChange24h).not.toBeNull(); + expect(res.body.data.priceChange24h).toBeGreaterThan(0); + }); + + it('returns a negative priceChange24h when the price decreased over 24h', async () => { + const res = await supertest(app).get( + `/api/v1/creators/${creatorIdDown}/profile` + ); + expect(res.status).toBe(200); + expect(res.body.data).toHaveProperty('priceChange24h'); + expect(res.body.data.priceChange24h).not.toBeNull(); + expect(res.body.data.priceChange24h).toBeLessThan(0); + }); +}); diff --git a/src/utils/prisma.utils.ts b/src/utils/prisma.utils.ts index 1e7eec9..7603eef 100644 --- a/src/utils/prisma.utils.ts +++ b/src/utils/prisma.utils.ts @@ -33,6 +33,17 @@ function normalizeArgsForFingerprint(value: unknown, depth = 0): unknown { return '?'; } +/** Maps a Prisma client operation to the SQL verb it maps to, for logging. */ +function mapOperationToSqlVerb( + operation: string +): 'select' | 'insert' | 'update' | 'delete' | string { + if (/^(find|count|aggregate|groupBy)/.test(operation)) return 'select'; + if (/^create/.test(operation)) return 'insert'; + if (/^(update|upsert)/.test(operation)) return 'update'; + if (/^delete/.test(operation)) return 'delete'; + return operation; +} + /** * Build a short deterministic hash that identifies the query pattern (model, * operation, and arg structure) without including any parameter values. @@ -147,8 +158,11 @@ export const prisma = basePrisma.$extends({ logger.warn( { type: 'slow_query', + query_name: `${model ?? 'unknown'}.${operation}`, + table: model, + operation: mapOperationToSqlVerb(operation), + duration_ms: elapsedMs, model, - operation, fingerprint: buildQueryFingerprint( model, operation,