Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions src/modules/creators/creator-detail-price-snapshot.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
27 changes: 27 additions & 0 deletions src/utils/hash-request-body.utils.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)[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');
}
102 changes: 102 additions & 0 deletions src/utils/test/hash-request-body.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = { name: 'alert', channel: 'email' };
const b: Record<string, unknown> = { 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<string, unknown> = { a: 1, b: undefined };
const b: Record<string, unknown> = { 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));
});
});
Loading