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
74 changes: 74 additions & 0 deletions test/airdrops-service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -545,4 +545,78 @@ describe('airdrops service', () => {
expect(airdropsService.TERMINAL_STATUSES.has('draft')).toBe(false);
});
});

describe('cancel preserves recipients', () => {
test('recipients remain accessible after cancelling', async () => {
const airdrop = await airdropsService.create({
name: 'Drop', asset: 'USDC', asset_issuer: 'GI', total_amount: '100', expiry_ledger: 1000,
recipients: [{ address: 'GAAA', amount: '50' }, { address: 'GBBB', amount: '50' }],
});

await airdropsService.cancel(airdrop.id);

const { recipients, total } = await airdropsService.listRecipients(airdrop.id);
expect(total).toBe(2);
expect(recipients).toHaveLength(2);
});
});

describe('addRecipients with empty array', () => {
test('returns empty duplicates for empty input', async () => {
const airdrop = await airdropsService.create({
name: 'Drop', asset: 'USDC', asset_issuer: 'GI', total_amount: '100', expiry_ledger: 1000,
});

const dupes = await airdropsService.addRecipients(airdrop.id, []);
expect(dupes).toEqual([]);
});
});

describe('listRecipients pagination', () => {
test('paginates recipients', async () => {
const airdrop = await airdropsService.create({
name: 'Drop', asset: 'USDC', asset_issuer: 'GI', total_amount: '100', expiry_ledger: 1000,
recipients: [
{ address: 'GAAA', amount: '10' },
{ address: 'GBBB', amount: '20' },
{ address: 'GCCC', amount: '30' },
],
});

const page1 = await airdropsService.listRecipients(airdrop.id, 1, 2);
expect(page1.recipients).toHaveLength(2);
expect(page1.total).toBe(3);

const page2 = await airdropsService.listRecipients(airdrop.id, 2, 2);
expect(page2.recipients).toHaveLength(1);
});
});

describe('update contract_airdrop_id', () => {
test('sets contract_airdrop_id', async () => {
const airdrop = await airdropsService.create({
name: 'Drop', asset: 'USDC', asset_issuer: 'GI', total_amount: '100', expiry_ledger: 1000,
});

const updated = await airdropsService.update(airdrop.id, { contract_airdrop_id: '0xabc123' });
expect(updated.contract_airdrop_id).toBe('0xabc123');
});
});

describe('create with contract_airdrop_id', () => {
test('stores contract_airdrop_id when provided', async () => {
const airdrop = await airdropsService.create({
name: 'Drop', asset: 'USDC', asset_issuer: 'GI', total_amount: '100', expiry_ledger: 1000,
contract_airdrop_id: '0xcontract',
});
expect(airdrop.contract_airdrop_id).toBe('0xcontract');
});

test('defaults contract_airdrop_id to null', async () => {
const airdrop = await airdropsService.create({
name: 'Drop', asset: 'USDC', asset_issuer: 'GI', total_amount: '100', expiry_ledger: 1000,
});
expect(airdrop.contract_airdrop_id).toBeNull();
});
});
});
86 changes: 86 additions & 0 deletions test/alerts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -327,3 +327,89 @@ describe('repeat: true with change_pct', () => {
expect(alert.baseline_price).toBe(0.12);
});
});

describe('isTriggered edge cases', () => {
test('above: fires at exactly threshold + epsilon', async () => {
await makeAlert({ type: 'above', threshold_usd: 1.0 });
await alertsService.evaluateForAsset('XLM', 1.00001);
expect(mockWebhookDeliver).toHaveBeenCalledTimes(1);
});

test('below: fires at exactly threshold - epsilon', async () => {
await makeAlert({ type: 'below', threshold_usd: 1.0 });
await alertsService.evaluateForAsset('XLM', 0.99999);
expect(mockWebhookDeliver).toHaveBeenCalledTimes(1);
});

test('above: does not fire at exactly threshold', async () => {
await makeAlert({ type: 'above', threshold_usd: 1.0 });
await alertsService.evaluateForAsset('XLM', 1.0);
expect(mockWebhookDeliver).not.toHaveBeenCalled();
});

test('below: does not fire at exactly threshold', async () => {
await makeAlert({ type: 'below', threshold_usd: 1.0 });
await alertsService.evaluateForAsset('XLM', 1.0);
expect(mockWebhookDeliver).not.toHaveBeenCalled();
});

test('unknown type returns false', async () => {
await makeAlert({ type: 'unknown_type', threshold_usd: 1.0 });
await alertsService.evaluateForAsset('XLM', 100);
expect(mockWebhookDeliver).not.toHaveBeenCalled();
});
});

describe('create normalization', () => {
test('asset is uppercased', async () => {
const alert = await makeAlert({ asset: 'xlm' });
expect(alert.asset).toBe('XLM');
});

test('repeat defaults to false when not provided', async () => {
const alert = await makeAlert({ repeat: undefined });
expect(alert.repeat).toBe(false);
});

test('generates unique IDs for each alert', async () => {
const a1 = await makeAlert();
const a2 = await makeAlert();
expect(a1.id).not.toBe(a2.id);
});
});

describe('fire payload', () => {
test('delivers correct payload shape to webhook', async () => {
await makeAlert({ threshold_usd: 0.09 });
await alertsService.evaluateForAsset('XLM', 0.05);

const [url, secret, payload] = mockWebhookDeliver.mock.calls[0];
expect(url).toBe('https://example.com/hook');
expect(secret).toBe('whsec_testsecret');
expect(payload).toMatchObject({
event: 'price.alert',
asset: 'XLM',
type: 'below',
threshold_usd: 0.09,
actual_price_usd: 0.05,
});
expect(payload.triggered_at).toBeDefined();
});
});

describe('evaluateForAsset with multiple alerts', () => {
test('fires all triggered alerts for the same asset', async () => {
await makeAlert({ threshold_usd: 0.09, webhook_url: 'https://a.com/hook', webhook_secret: 'whsec_a' });
await makeAlert({ threshold_usd: 0.10, webhook_url: 'https://b.com/hook', webhook_secret: 'whsec_b' });
await alertsService.evaluateForAsset('XLM', 0.05);
expect(mockWebhookDeliver).toHaveBeenCalledTimes(2);
});

test('only fires alerts matching the queried asset', async () => {
await makeAlert({ asset: 'XLM', threshold_usd: 0.09 });
await makeAlert({ asset: 'BTC', threshold_usd: 0.09 });
await alertsService.evaluateForAsset('XLM', 0.05);
expect(mockWebhookDeliver).toHaveBeenCalledTimes(1);
expect(mockWebhookDeliver.mock.calls[0][2].asset).toBe('XLM');
});
});
89 changes: 89 additions & 0 deletions test/webhooks.routes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,10 @@ describe('GET /api/v1/webhooks/:id/deliveries', () => {
describe('Full webhook lifecycle (integration)', () => {
const app = buildApp();

beforeEach(() => {
mockAxiosPost.mockReset();
});

test('create → test → update → list deliveries → delete', async () => {
// 1. Create
const created = await request(app)
Expand Down Expand Up @@ -293,3 +297,88 @@ describe('Full webhook lifecycle (integration)', () => {
expect(res.body.delivery_id).toMatch(/^dlv_/);
});
});

describe('Webhook API contract', () => {
const app = buildApp();

test('secret is not returned in GET list response', async () => {
await request(app)
.post('/api/v1/webhooks')
.send({ url: 'https://example.com', events: ['*'], secret: 'whsec_supersecretvalue1234' });

const res = await request(app).get('/api/v1/webhooks');
const webhook = res.body.data[0];
expect(webhook).not.toHaveProperty('secret');
expect(webhook).toHaveProperty('secret_preview');
});

test('secret is not returned in GET by id response', async () => {
const created = await request(app)
.post('/api/v1/webhooks')
.send({ url: 'https://example.com', events: ['*'], secret: 'whsec_supersecretvalue1234' });

const res = await request(app).get(`/api/v1/webhooks/${created.body.id}`);
expect(res.body).not.toHaveProperty('secret');
});

test('webhook has default active=true on creation', async () => {
const res = await request(app)
.post('/api/v1/webhooks')
.send({ url: 'https://example.com', events: ['*'] });
expect(res.body.active).toBe(true);
});

test('wildcard event subscription is accepted', async () => {
const res = await request(app)
.post('/api/v1/webhooks')
.send({ url: 'https://example.com', events: ['*'], secret: 'whsec_aaaaaaaaaaaaaaaa' });
expect(res.status).toBe(201);
expect(res.body.events).toEqual(['*']);
});

test('multiple valid event types are accepted', async () => {
const res = await request(app)
.post('/api/v1/webhooks')
.send({
url: 'https://example.com',
events: ['pool.created', 'pool.assets_locked', 'price.alert'],
secret: 'whsec_aaaaaaaaaaaaaaaa',
});
expect(res.status).toBe(201);
expect(res.body.events).toHaveLength(3);
});

test('PATCH can update events', async () => {
const created = await request(app)
.post('/api/v1/webhooks')
.send({ url: 'https://example.com', events: ['pool.created'], secret: 'whsec_aaaaaaaaaaaaaaaa' });

const res = await request(app)
.patch(`/api/v1/webhooks/${created.body.id}`)
.send({ events: ['pool.created', 'price.alert'] });
expect(res.status).toBe(200);
expect(res.body.events).toEqual(['pool.created', 'price.alert']);
});

test('PATCH can update URL', async () => {
const created = await request(app)
.post('/api/v1/webhooks')
.send({ url: 'https://old.com', events: ['*'], secret: 'whsec_aaaaaaaaaaaaaaaa' });

const res = await request(app)
.patch(`/api/v1/webhooks/${created.body.id}`)
.send({ url: 'https://new.com' });
expect(res.status).toBe(200);
expect(res.body.url).toBe('https://new.com');
});

test('deliveries endpoint returns empty array for webhook with no deliveries', async () => {
const created = await request(app)
.post('/api/v1/webhooks')
.send({ url: 'https://example.com', events: ['*'], secret: 'whsec_aaaaaaaaaaaaaaaa' });

const res = await request(app).get(`/api/v1/webhooks/${created.body.id}/deliveries`);
expect(res.status).toBe(200);
expect(res.body.deliveries).toHaveLength(0);
});
});
Loading