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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ swyft/

```bash
# Clone the repo
git clone https://github.com/Valreb001/Swyft.git
git clone https://github.com/vatix-protocol/Swyft.git
cd swyft

# Install all dependencies
Expand Down Expand Up @@ -163,7 +163,7 @@ Swyft is built almost entirely by external contributors. The maintainer handles

### Good first issues

Look for issues labelled [`good first issue`](https://github.com/Valreb001/Swyft/issues?q=label%3A%22good+first+issue%22). These are small, well-scoped tasks that don't require deep protocol knowledge.
Look for issues labelled [`good first issue`](https://github.com/vatix-protocol/Swyft/issues?q=label%3A%22good+first+issue%22). These are small, well-scoped tasks that don't require deep protocol knowledge.

### Issue labels

Expand Down
12 changes: 6 additions & 6 deletions apps/api/src/price/price.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ const mockSpotResponse: SpotPriceResponse = {
};

const mockCandle: PriceCandle = {
timestamp: 1700000000,
open: '0.09',
high: '0.11',
low: '0.08',
close: '0.10',
volume: '50000',
time: 1700000000,
open: 0.09,
high: 0.11,
low: 0.08,
close: 0.1,
volume: 50000,
};

describe('PriceController', () => {
Expand Down
259 changes: 259 additions & 0 deletions apps/api/src/webhooks/webhook.processor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import { WebhookWorker, WebhookJob } from './webhook.processor';
import { PrismaService } from '../prisma/prisma.service';
import { WebhookPayload } from './webhook.types';
import { Job } from 'bullmq';

// ── Mock factories ────────────────────────────────────────────────────────────

function buildMockPrisma() {
return {
webhook: {
findUnique: jest.fn(),
update: jest.fn().mockResolvedValue({}),
},
webhookDelivery: {
create: jest.fn().mockResolvedValue({}),
},
};
}

function buildMockQueue() {
return {
add: jest.fn().mockResolvedValue({ id: 'job-1' }),
close: jest.fn().mockResolvedValue(undefined),
};
}

// ── Fixtures ──────────────────────────────────────────────────────────────────

const baseWebhook = {
id: 'wh-1',
url: 'https://example.com/hook',
secret: null as string | null,
disabled: false,
consecutiveFails: 0,
};

const basePayload: WebhookPayload = {
event: 'pool.created',
timestamp: '2025-01-15T10:30:00.000Z',
data: { poolId: 'pool-1', token0: 'XLM', token1: 'USDC' },
};

function makeJob(data: WebhookJob): Job<WebhookJob> {
return { data } as Job<WebhookJob>;
}

// ── Tests ─────────────────────────────────────────────────────────────────────

describe('WebhookWorker (dispatch integration)', () => {
let worker: WebhookWorker;
let prisma: ReturnType<typeof buildMockPrisma>;
let fetchSpy: jest.SpyInstance;

beforeEach(() => {
prisma = buildMockPrisma();

// Instantiate directly to skip onModuleInit (avoids real Redis/BullMQ Worker)
worker = new WebhookWorker(prisma as unknown as PrismaService);
(worker as any).queue = buildMockQueue();

fetchSpy = jest
.spyOn(global, 'fetch')
.mockResolvedValue({ status: 200 } as Response);
});

afterEach(() => {
jest.restoreAllMocks();
});

// ── dispatch (queue integration) ──────────────────────────────────────────

describe('dispatch()', () => {
it('enqueues a deliver job with the correct webhook id and payload', async () => {
await worker.dispatch('wh-1', basePayload);

expect((worker as any).queue.add).toHaveBeenCalledWith(
'deliver',
{ webhookId: 'wh-1', payload: basePayload },
expect.objectContaining({ attempts: 3 }),
);
});

it('uses exponential back-off for retries', async () => {
await worker.dispatch('wh-1', basePayload);

const [, , opts] = (worker as any).queue.add.mock.calls[0];
expect(opts.backoff).toEqual({ type: 'exponential', delay: 2000 });
});

it('resolves without throwing on successful enqueue', async () => {
await expect(
worker.dispatch('wh-1', basePayload),
).resolves.toBeUndefined();
});

it('propagates errors when the queue rejects', async () => {
(worker as any).queue.add.mockRejectedValue(new Error('queue full'));

await expect(worker.dispatch('wh-1', basePayload)).rejects.toThrow(
'queue full',
);
});
});

// ── deliver (processor integration) ───────────────────────────────────────

describe('deliver()', () => {
const deliver = (data: WebhookJob) =>
(worker as any).deliver(makeJob(data));

it('skips delivery when the webhook record is not found', async () => {
prisma.webhook.findUnique.mockResolvedValue(null);

await deliver({ webhookId: 'wh-missing', payload: basePayload });

expect(fetchSpy).not.toHaveBeenCalled();
});

it('skips delivery when the webhook is disabled', async () => {
prisma.webhook.findUnique.mockResolvedValue({
...baseWebhook,
disabled: true,
});

await deliver({ webhookId: 'wh-1', payload: basePayload });

expect(fetchSpy).not.toHaveBeenCalled();
});

it('POSTs the JSON payload to the webhook URL', async () => {
prisma.webhook.findUnique.mockResolvedValue(baseWebhook);

await deliver({ webhookId: 'wh-1', payload: basePayload });

expect(fetchSpy).toHaveBeenCalledWith(
baseWebhook.url,
expect.objectContaining({
method: 'POST',
body: JSON.stringify(basePayload),
}),
);
});

it('includes Content-Type: application/json header', async () => {
prisma.webhook.findUnique.mockResolvedValue(baseWebhook);

await deliver({ webhookId: 'wh-1', payload: basePayload });

const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect((init.headers as Record<string, string>)['Content-Type']).toBe(
'application/json',
);
});

it('attaches X-Swyft-Signature when webhook has a secret', async () => {
prisma.webhook.findUnique.mockResolvedValue({
...baseWebhook,
secret: 'hmac-secret',
});

await deliver({ webhookId: 'wh-1', payload: basePayload });

const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(
(init.headers as Record<string, string>)['X-Swyft-Signature'],
).toMatch(/^[0-9a-f]{64}$/);
});

it('omits X-Swyft-Signature when webhook has no secret', async () => {
prisma.webhook.findUnique.mockResolvedValue(baseWebhook);

await deliver({ webhookId: 'wh-1', payload: basePayload });

const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(
(init.headers as Record<string, string>)['X-Swyft-Signature'],
).toBeUndefined();
});

it('records a delivery log entry after each attempt', async () => {
prisma.webhook.findUnique.mockResolvedValue(baseWebhook);

await deliver({ webhookId: 'wh-1', payload: basePayload });

expect(prisma.webhookDelivery.create).toHaveBeenCalledWith({
data: expect.objectContaining({
webhookId: 'wh-1',
eventType: basePayload.event,
responseStatus: 200,
}),
});
});

it('resets consecutiveFails to 0 on success', async () => {
prisma.webhook.findUnique.mockResolvedValue({
...baseWebhook,
consecutiveFails: 3,
});

await deliver({ webhookId: 'wh-1', payload: basePayload });

expect(prisma.webhook.update).toHaveBeenCalledWith({
where: { id: 'wh-1' },
data: { consecutiveFails: 0 },
});
});

it('increments consecutiveFails on HTTP 5xx response', async () => {
fetchSpy.mockResolvedValue({ status: 500 } as Response);
prisma.webhook.findUnique.mockResolvedValue({
...baseWebhook,
consecutiveFails: 2,
});

await expect(
deliver({ webhookId: 'wh-1', payload: basePayload }),
).rejects.toThrow(/Delivery failed/);

expect(prisma.webhook.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ consecutiveFails: 3 }),
}),
);
});

it('disables the webhook after 10 consecutive failures', async () => {
fetchSpy.mockResolvedValue({ status: 503 } as Response);
prisma.webhook.findUnique.mockResolvedValue({
...baseWebhook,
consecutiveFails: 9,
});

await expect(
deliver({ webhookId: 'wh-1', payload: basePayload }),
).rejects.toThrow();

expect(prisma.webhook.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ disabled: true }),
}),
);
});

it('handles network errors gracefully and records undefined status', async () => {
fetchSpy.mockRejectedValue(new Error('ECONNREFUSED'));
prisma.webhook.findUnique.mockResolvedValue(baseWebhook);

await expect(
deliver({ webhookId: 'wh-1', payload: basePayload }),
).rejects.toThrow(/Delivery failed/);

expect(prisma.webhookDelivery.create).toHaveBeenCalledWith({
data: expect.objectContaining({
responseStatus: undefined,
}),
});
});
});
});
3 changes: 3 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
# "TESTNET" or "PUBLIC"
NEXT_PUBLIC_STELLAR_NETWORK=TESTNET

# Soroban RPC endpoint (used by @swyft/sdk in the browser)
NEXT_PUBLIC_STELLAR_RPC_URL=https://soroban-testnet.stellar.org