From 724413209bc902abb619b331062f9e902173962c Mon Sep 17 00:00:00 2001 From: dubemoyibe-star Date: Fri, 26 Jun 2026 22:31:57 +0100 Subject: [PATCH] feat: add request body hash helper and creator price snapshot tests --- ...-detail-price-snapshot.integration.test.ts | 127 ++++++++++++++++++ src/utils/hash-request-body.utils.ts | 27 ++++ .../test/hash-request-body.utils.test.ts | 102 ++++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 src/modules/creators/creator-detail-price-snapshot.integration.test.ts create mode 100644 src/utils/hash-request-body.utils.ts create mode 100644 src/utils/test/hash-request-body.utils.test.ts diff --git a/src/modules/creators/creator-detail-price-snapshot.integration.test.ts b/src/modules/creators/creator-detail-price-snapshot.integration.test.ts new file mode 100644 index 0000000..f65b59c --- /dev/null +++ b/src/modules/creators/creator-detail-price-snapshot.integration.test.ts @@ -0,0 +1,127 @@ +import supertest from 'supertest'; +import app from '../../app'; +import { prisma } from '../../utils/prisma.utils'; +import { upsertPriceSnapshot } from '../indexer/price-snapshot.service'; + +const USER_ID = 'creator-price-snap-test-user'; +const HANDLE = 'creator-price-snap-test'; + +describe('#504 creator detail endpoint — current_price from price snapshot', () => { + let creatorId: string; + + beforeAll(async () => { + await prisma.user.upsert({ + where: { id: USER_ID }, + create: { + id: USER_ID, + email: 'creator-price-snap-test@example.test', + passwordHash: 'dummy-hash', + firstName: 'Price', + lastName: 'Snap', + }, + update: {}, + }); + + const creator = await prisma.creatorProfile.upsert({ + where: { userId: USER_ID }, + create: { + userId: USER_ID, + handle: HANDLE, + displayName: 'Price Snap Creator', + }, + update: {}, + }); + + creatorId = creator.id; + }); + + afterAll(async () => { + await prisma.creatorPriceSnapshot.deleteMany({ where: { creatorId } }); + await prisma.creatorProfile.deleteMany({ where: { handle: HANDLE } }); + await prisma.user.deleteMany({ where: { id: USER_ID } }); + await prisma.$disconnect(); + }); + + it('creator detail returns null current_price before any snapshot exists', async () => { + const res = await supertest(app).get(`/api/v1/creators/${creatorId}/profile`); + expect(res.status).toBe(200); + expect(res.body.data.currentPrice).toBeNull(); + expect(res.body.data.priceChange24h).toBeNull(); + }); + + it('creator detail returns current_price matching seeded snapshot value', async () => { + const seededPrice = BigInt(1_500_000); + await upsertPriceSnapshot({ + creatorId, + price: seededPrice, + tradeAt: new Date(), + }); + + const res = await supertest(app).get(`/api/v1/creators/${creatorId}/profile`); + expect(res.status).toBe(200); + expect(res.body.data.currentPrice).toBe('1500000'); + }); + + it('current_price updates after snapshot is refreshed', async () => { + const initialPrice = BigInt(2_000_000); + await upsertPriceSnapshot({ + creatorId, + price: initialPrice, + tradeAt: new Date(), + }); + + const beforeRes = await supertest(app).get(`/api/v1/creators/${creatorId}/profile`); + expect(beforeRes.status).toBe(200); + expect(beforeRes.body.data.currentPrice).toBe('2000000'); + + const updatedPrice = BigInt(3_750_000); + await upsertPriceSnapshot({ + creatorId, + price: updatedPrice, + tradeAt: new Date(), + }); + + const afterRes = await supertest(app).get(`/api/v1/creators/${creatorId}/profile`); + expect(afterRes.status).toBe(200); + expect(afterRes.body.data.currentPrice).toBe('3750000'); + expect(afterRes.body.data.currentPrice).not.toBe(beforeRes.body.data.currentPrice); + }); + + it('creator list includes current_price matching snapshot value', async () => { + await upsertPriceSnapshot({ + creatorId, + price: BigInt(500_000), + tradeAt: new Date(), + }); + + const res = await supertest(app).get('/api/v1/creators'); + expect(res.status).toBe(200); + + const item = (res.body.data.items as any[]).find( + (c: any) => c.id === creatorId + ); + expect(item).toBeDefined(); + expect(item.currentPrice).toBe('500000'); + }); + + it('creator list current_price updates after snapshot refresh', async () => { + const beforeListRes = await supertest(app).get('/api/v1/creators'); + const beforeItem = (beforeListRes.body.data.items as any[]).find( + (c: any) => c.id === creatorId + ); + expect(beforeItem.currentPrice).toBe('500000'); + + await upsertPriceSnapshot({ + creatorId, + price: BigInt(750_000), + tradeAt: new Date(), + }); + + const afterListRes = await supertest(app).get('/api/v1/creators'); + const afterItem = (afterListRes.body.data.items as any[]).find( + (c: any) => c.id === creatorId + ); + expect(afterItem.currentPrice).toBe('750000'); + expect(afterItem.currentPrice).not.toBe(beforeItem.currentPrice); + }); +}); \ No newline at end of file diff --git a/src/utils/hash-request-body.utils.ts b/src/utils/hash-request-body.utils.ts new file mode 100644 index 0000000..7df0f93 --- /dev/null +++ b/src/utils/hash-request-body.utils.ts @@ -0,0 +1,27 @@ +import crypto from 'crypto'; + +function stableStringify(value: unknown): string { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number') return JSON.stringify(value); + if (typeof value === 'boolean') return JSON.stringify(value); + if (typeof value === 'bigint') return JSON.stringify(value.toString()); + if (Array.isArray(value)) { + const items = value.map(item => stableStringify(item)); + return `[${items.join(',')}]`; + } + if (typeof value === 'object') { + const keys = Object.keys(value).sort(); + const entries = keys.map( + key => `${JSON.stringify(key)}:${stableStringify((value as Record)[key])}`, + ); + return `{${entries.join(',')}}`; + } + return JSON.stringify(value); +} + +export function hashRequestBody(body: unknown): string { + const normalized = stableStringify(body); + return crypto.createHash('sha256').update(normalized, 'utf8').digest('hex'); +} diff --git a/src/utils/test/hash-request-body.utils.test.ts b/src/utils/test/hash-request-body.utils.test.ts new file mode 100644 index 0000000..730924f --- /dev/null +++ b/src/utils/test/hash-request-body.utils.test.ts @@ -0,0 +1,102 @@ +import { hashRequestBody } from '../hash-request-body.utils'; + +describe('hashRequestBody()', () => { + // ── Output format ────────────────────────────────────────────────────────── + + it('returns a 64-character hex string', () => { + const hash = hashRequestBody({}); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); + + // ── Determinism ──────────────────────────────────────────────────────────── + + it('returns the same hash for identical objects', () => { + const body = { name: 'alert', channel: 'email' }; + expect(hashRequestBody(body)).toBe(hashRequestBody(body)); + }); + + it('produces the same hash for objects with keys in different insertion order', () => { + const a: Record = { name: 'alert', channel: 'email' }; + const b: Record = { channel: 'email', name: 'alert' }; + expect(hashRequestBody(a)).toBe(hashRequestBody(b)); + }); + + // ── Sensitivity to values ────────────────────────────────────────────────── + + it('produces different hashes for different objects', () => { + const a = { name: 'alert', channel: 'email' }; + const b = { name: 'webhook', channel: 'slack' }; + expect(hashRequestBody(a)).not.toBe(hashRequestBody(b)); + }); + + it('produces different hashes when a single field value changes', () => { + const a = { name: 'alert', channel: 'email' }; + const b = { name: 'alert', channel: 'sms' }; + expect(hashRequestBody(a)).not.toBe(hashRequestBody(b)); + }); + + // ── Empty object ─────────────────────────────────────────────────────────── + + it('produces a stable hash for an empty object', () => { + const first = hashRequestBody({}); + const second = hashRequestBody({}); + expect(first).toBe(second); + expect(first).toMatch(/^[0-9a-f]{64}$/); + }); + + // ── Primitive and edge-case inputs ───────────────────────────────────────── + + it('hashes null consistently', () => { + expect(hashRequestBody(null)).toBe(hashRequestBody(null)); + }); + + it('hashes undefined consistently', () => { + expect(hashRequestBody(undefined)).toBe(hashRequestBody(undefined)); + }); + + it('hashes strings consistently', () => { + expect(hashRequestBody('hello')).toBe(hashRequestBody('hello')); + }); + + it('produces different hashes for different strings', () => { + expect(hashRequestBody('hello')).not.toBe(hashRequestBody('world')); + }); + + it('hashes numbers consistently', () => { + expect(hashRequestBody(42)).toBe(hashRequestBody(42)); + }); + + it('produces different hashes for different numbers', () => { + expect(hashRequestBody(1)).not.toBe(hashRequestBody(2)); + }); + + it('hashes booleans consistently', () => { + expect(hashRequestBody(true)).toBe(hashRequestBody(true)); + expect(hashRequestBody(false)).toBe(hashRequestBody(false)); + }); + + it('hashes arrays consistently', () => { + expect(hashRequestBody([1, 2, 3])).toBe(hashRequestBody([1, 2, 3])); + }); + + it('produces different hashes for different arrays', () => { + expect(hashRequestBody([1, 2, 3])).not.toBe(hashRequestBody([1, 2, 4])); + }); + + it('produces the same hash for objects with undefined values', () => { + const a: Record = { a: 1, b: undefined }; + const b: Record = { b: undefined, a: 1 }; + expect(hashRequestBody(a)).toBe(hashRequestBody(b)); + }); + + it('hashes nested objects consistently', () => { + const body = { alert: { name: 'test', settings: { retries: 3 } } }; + expect(hashRequestBody(body)).toBe(hashRequestBody(body)); + }); + + it('produces different hashes for different nested objects', () => { + const a = { alert: { name: 'test', retries: 3 } }; + const b = { alert: { name: 'test', retries: 5 } }; + expect(hashRequestBody(a)).not.toBe(hashRequestBody(b)); + }); +});