Skip to content

Commit ebb11bd

Browse files
authored
Merge pull request #523 from dubemoyibe-star/feat/idempotency-hash-and-price-snapshot-tests
feat: add request body hash helper and creator price snapshot tests
2 parents b9a7655 + 7244132 commit ebb11bd

3 files changed

Lines changed: 256 additions & 0 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import supertest from 'supertest';
2+
import app from '../../app';
3+
import { prisma } from '../../utils/prisma.utils';
4+
import { upsertPriceSnapshot } from '../indexer/price-snapshot.service';
5+
6+
const USER_ID = 'creator-price-snap-test-user';
7+
const HANDLE = 'creator-price-snap-test';
8+
9+
describe('#504 creator detail endpoint — current_price from price snapshot', () => {
10+
let creatorId: string;
11+
12+
beforeAll(async () => {
13+
await prisma.user.upsert({
14+
where: { id: USER_ID },
15+
create: {
16+
id: USER_ID,
17+
email: 'creator-price-snap-test@example.test',
18+
passwordHash: 'dummy-hash',
19+
firstName: 'Price',
20+
lastName: 'Snap',
21+
},
22+
update: {},
23+
});
24+
25+
const creator = await prisma.creatorProfile.upsert({
26+
where: { userId: USER_ID },
27+
create: {
28+
userId: USER_ID,
29+
handle: HANDLE,
30+
displayName: 'Price Snap Creator',
31+
},
32+
update: {},
33+
});
34+
35+
creatorId = creator.id;
36+
});
37+
38+
afterAll(async () => {
39+
await prisma.creatorPriceSnapshot.deleteMany({ where: { creatorId } });
40+
await prisma.creatorProfile.deleteMany({ where: { handle: HANDLE } });
41+
await prisma.user.deleteMany({ where: { id: USER_ID } });
42+
await prisma.$disconnect();
43+
});
44+
45+
it('creator detail returns null current_price before any snapshot exists', async () => {
46+
const res = await supertest(app).get(`/api/v1/creators/${creatorId}/profile`);
47+
expect(res.status).toBe(200);
48+
expect(res.body.data.currentPrice).toBeNull();
49+
expect(res.body.data.priceChange24h).toBeNull();
50+
});
51+
52+
it('creator detail returns current_price matching seeded snapshot value', async () => {
53+
const seededPrice = BigInt(1_500_000);
54+
await upsertPriceSnapshot({
55+
creatorId,
56+
price: seededPrice,
57+
tradeAt: new Date(),
58+
});
59+
60+
const res = await supertest(app).get(`/api/v1/creators/${creatorId}/profile`);
61+
expect(res.status).toBe(200);
62+
expect(res.body.data.currentPrice).toBe('1500000');
63+
});
64+
65+
it('current_price updates after snapshot is refreshed', async () => {
66+
const initialPrice = BigInt(2_000_000);
67+
await upsertPriceSnapshot({
68+
creatorId,
69+
price: initialPrice,
70+
tradeAt: new Date(),
71+
});
72+
73+
const beforeRes = await supertest(app).get(`/api/v1/creators/${creatorId}/profile`);
74+
expect(beforeRes.status).toBe(200);
75+
expect(beforeRes.body.data.currentPrice).toBe('2000000');
76+
77+
const updatedPrice = BigInt(3_750_000);
78+
await upsertPriceSnapshot({
79+
creatorId,
80+
price: updatedPrice,
81+
tradeAt: new Date(),
82+
});
83+
84+
const afterRes = await supertest(app).get(`/api/v1/creators/${creatorId}/profile`);
85+
expect(afterRes.status).toBe(200);
86+
expect(afterRes.body.data.currentPrice).toBe('3750000');
87+
expect(afterRes.body.data.currentPrice).not.toBe(beforeRes.body.data.currentPrice);
88+
});
89+
90+
it('creator list includes current_price matching snapshot value', async () => {
91+
await upsertPriceSnapshot({
92+
creatorId,
93+
price: BigInt(500_000),
94+
tradeAt: new Date(),
95+
});
96+
97+
const res = await supertest(app).get('/api/v1/creators');
98+
expect(res.status).toBe(200);
99+
100+
const item = (res.body.data.items as any[]).find(
101+
(c: any) => c.id === creatorId
102+
);
103+
expect(item).toBeDefined();
104+
expect(item.currentPrice).toBe('500000');
105+
});
106+
107+
it('creator list current_price updates after snapshot refresh', async () => {
108+
const beforeListRes = await supertest(app).get('/api/v1/creators');
109+
const beforeItem = (beforeListRes.body.data.items as any[]).find(
110+
(c: any) => c.id === creatorId
111+
);
112+
expect(beforeItem.currentPrice).toBe('500000');
113+
114+
await upsertPriceSnapshot({
115+
creatorId,
116+
price: BigInt(750_000),
117+
tradeAt: new Date(),
118+
});
119+
120+
const afterListRes = await supertest(app).get('/api/v1/creators');
121+
const afterItem = (afterListRes.body.data.items as any[]).find(
122+
(c: any) => c.id === creatorId
123+
);
124+
expect(afterItem.currentPrice).toBe('750000');
125+
expect(afterItem.currentPrice).not.toBe(beforeItem.currentPrice);
126+
});
127+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import crypto from 'crypto';
2+
3+
function stableStringify(value: unknown): string {
4+
if (value === null) return 'null';
5+
if (value === undefined) return 'undefined';
6+
if (typeof value === 'string') return JSON.stringify(value);
7+
if (typeof value === 'number') return JSON.stringify(value);
8+
if (typeof value === 'boolean') return JSON.stringify(value);
9+
if (typeof value === 'bigint') return JSON.stringify(value.toString());
10+
if (Array.isArray(value)) {
11+
const items = value.map(item => stableStringify(item));
12+
return `[${items.join(',')}]`;
13+
}
14+
if (typeof value === 'object') {
15+
const keys = Object.keys(value).sort();
16+
const entries = keys.map(
17+
key => `${JSON.stringify(key)}:${stableStringify((value as Record<string, unknown>)[key])}`,
18+
);
19+
return `{${entries.join(',')}}`;
20+
}
21+
return JSON.stringify(value);
22+
}
23+
24+
export function hashRequestBody(body: unknown): string {
25+
const normalized = stableStringify(body);
26+
return crypto.createHash('sha256').update(normalized, 'utf8').digest('hex');
27+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { hashRequestBody } from '../hash-request-body.utils';
2+
3+
describe('hashRequestBody()', () => {
4+
// ── Output format ──────────────────────────────────────────────────────────
5+
6+
it('returns a 64-character hex string', () => {
7+
const hash = hashRequestBody({});
8+
expect(hash).toMatch(/^[0-9a-f]{64}$/);
9+
});
10+
11+
// ── Determinism ────────────────────────────────────────────────────────────
12+
13+
it('returns the same hash for identical objects', () => {
14+
const body = { name: 'alert', channel: 'email' };
15+
expect(hashRequestBody(body)).toBe(hashRequestBody(body));
16+
});
17+
18+
it('produces the same hash for objects with keys in different insertion order', () => {
19+
const a: Record<string, unknown> = { name: 'alert', channel: 'email' };
20+
const b: Record<string, unknown> = { channel: 'email', name: 'alert' };
21+
expect(hashRequestBody(a)).toBe(hashRequestBody(b));
22+
});
23+
24+
// ── Sensitivity to values ──────────────────────────────────────────────────
25+
26+
it('produces different hashes for different objects', () => {
27+
const a = { name: 'alert', channel: 'email' };
28+
const b = { name: 'webhook', channel: 'slack' };
29+
expect(hashRequestBody(a)).not.toBe(hashRequestBody(b));
30+
});
31+
32+
it('produces different hashes when a single field value changes', () => {
33+
const a = { name: 'alert', channel: 'email' };
34+
const b = { name: 'alert', channel: 'sms' };
35+
expect(hashRequestBody(a)).not.toBe(hashRequestBody(b));
36+
});
37+
38+
// ── Empty object ───────────────────────────────────────────────────────────
39+
40+
it('produces a stable hash for an empty object', () => {
41+
const first = hashRequestBody({});
42+
const second = hashRequestBody({});
43+
expect(first).toBe(second);
44+
expect(first).toMatch(/^[0-9a-f]{64}$/);
45+
});
46+
47+
// ── Primitive and edge-case inputs ─────────────────────────────────────────
48+
49+
it('hashes null consistently', () => {
50+
expect(hashRequestBody(null)).toBe(hashRequestBody(null));
51+
});
52+
53+
it('hashes undefined consistently', () => {
54+
expect(hashRequestBody(undefined)).toBe(hashRequestBody(undefined));
55+
});
56+
57+
it('hashes strings consistently', () => {
58+
expect(hashRequestBody('hello')).toBe(hashRequestBody('hello'));
59+
});
60+
61+
it('produces different hashes for different strings', () => {
62+
expect(hashRequestBody('hello')).not.toBe(hashRequestBody('world'));
63+
});
64+
65+
it('hashes numbers consistently', () => {
66+
expect(hashRequestBody(42)).toBe(hashRequestBody(42));
67+
});
68+
69+
it('produces different hashes for different numbers', () => {
70+
expect(hashRequestBody(1)).not.toBe(hashRequestBody(2));
71+
});
72+
73+
it('hashes booleans consistently', () => {
74+
expect(hashRequestBody(true)).toBe(hashRequestBody(true));
75+
expect(hashRequestBody(false)).toBe(hashRequestBody(false));
76+
});
77+
78+
it('hashes arrays consistently', () => {
79+
expect(hashRequestBody([1, 2, 3])).toBe(hashRequestBody([1, 2, 3]));
80+
});
81+
82+
it('produces different hashes for different arrays', () => {
83+
expect(hashRequestBody([1, 2, 3])).not.toBe(hashRequestBody([1, 2, 4]));
84+
});
85+
86+
it('produces the same hash for objects with undefined values', () => {
87+
const a: Record<string, unknown> = { a: 1, b: undefined };
88+
const b: Record<string, unknown> = { b: undefined, a: 1 };
89+
expect(hashRequestBody(a)).toBe(hashRequestBody(b));
90+
});
91+
92+
it('hashes nested objects consistently', () => {
93+
const body = { alert: { name: 'test', settings: { retries: 3 } } };
94+
expect(hashRequestBody(body)).toBe(hashRequestBody(body));
95+
});
96+
97+
it('produces different hashes for different nested objects', () => {
98+
const a = { alert: { name: 'test', retries: 3 } };
99+
const b = { alert: { name: 'test', retries: 5 } };
100+
expect(hashRequestBody(a)).not.toBe(hashRequestBody(b));
101+
});
102+
});

0 commit comments

Comments
 (0)