Skip to content
Open
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
30 changes: 30 additions & 0 deletions prisma/schema/alert.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// prisma/schema/alert.prisma

enum AlertDirection {
ABOVE
BELOW
}

enum AlertStatus {
PENDING
TRIGGERED
FAILED
}

model Alert {
id String @id @default(cuid())
creatorId String
walletAddress String
targetPrice Decimal @db.Decimal(38, 18)
direction AlertDirection
callbackUrl String
status AlertStatus @default(PENDING)
retryCount Int @default(0)
lastError String?
triggeredAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([creatorId])
@@index([status])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
-- CreateEnum
CREATE TYPE "AlertDirection" AS ENUM ('ABOVE', 'BELOW');

-- CreateEnum
CREATE TYPE "AlertStatus" AS ENUM ('PENDING', 'TRIGGERED', 'FAILED');

-- CreateTable
CREATE TABLE "Alert" (
"id" TEXT NOT NULL,
"creatorId" TEXT NOT NULL,
"walletAddress" TEXT NOT NULL,
"targetPrice" DECIMAL(38,18) NOT NULL,
"direction" "AlertDirection" NOT NULL,
"callbackUrl" TEXT NOT NULL,
"status" "AlertStatus" NOT NULL DEFAULT 'PENDING',
"retryCount" INTEGER NOT NULL DEFAULT 0,
"lastError" TEXT,
"triggeredAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "Alert_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "Alert_creatorId_idx" ON "Alert"("creatorId");

-- CreateIndex
CREATE INDEX "Alert_status_idx" ON "Alert"("status");
65 changes: 65 additions & 0 deletions src/modules/alerts/alert.controllers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { Request, Response } from 'express';
import {
sendSuccess,
sendError,
sendValidationError,
sendNotFound,
} from '../../utils/api-response.utils';
import { ErrorCode } from '../../constants/error.constants';
import { CreateAlertSchema } from './alert.schemas';
import * as alertService from './alert.service';

export async function registerAlertHandler(
req: Request,
res: Response
): Promise<void> {
const parseResult = CreateAlertSchema.safeParse(req.body);
if (!parseResult.success) {
sendValidationError(
res,
'Invalid alert registration data',
parseResult.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
}))
);
return;
}

try {
const result = await alertService.createAlert({
creatorId: parseResult.data.creator_id,
walletAddress: parseResult.data.wallet_address,
targetPrice: parseResult.data.target_price,
direction: parseResult.data.direction,
callbackUrl: parseResult.data.callback_url,
});
sendSuccess(res, result, 201, 'Alert registered successfully');
} catch {
sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Failed to register alert');
}
}

export async function deleteAlertHandler(
req: Request,
res: Response
): Promise<void> {
const rawAlertId = req.params.id;
const alertId = Array.isArray(rawAlertId) ? rawAlertId[0] : rawAlertId;

if (!alertId) {
sendError(res, 400, ErrorCode.BAD_REQUEST, 'Missing alert ID in path');
return;
}

try {
const result = await alertService.deleteAlert(alertId);
if (!result) {
sendNotFound(res, 'Alert');
return;
}
sendSuccess(res, result, 200, 'Alert cancelled successfully');
} catch {
sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Failed to cancel alert');
}
}
244 changes: 244 additions & 0 deletions src/modules/alerts/alert.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import supertest from 'supertest';
import { Prisma } from '@prisma/client';
import { Keypair } from '@stellar/stellar-base';

// Mock Prisma so the integration test exercises the full HTTP + dispatch path
// without requiring a live database, matching the suite's mocking conventions.
jest.mock('../../utils/prisma.utils', () => ({
prisma: {
alert: {
create: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn(),
delete: jest.fn(),
update: jest.fn(),
},
},
}));

jest.mock('../../utils/logger.utils', () => ({
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
}));

import app from '../../app';
import { prisma } from '../../utils/prisma.utils';
import { evaluateTradeForAlerts } from './alert.service';
import { envConfig } from '../../config';

const mockPrisma = prisma as unknown as {
alert: {
create: jest.Mock;
findFirst: jest.Mock;
findMany: jest.Mock;
delete: jest.Mock;
update: jest.Mock;
};
};

const walletAddress = Keypair.random().publicKey();

function decimal(v: string): Prisma.Decimal {
return new Prisma.Decimal(v);
}

beforeEach(() => {
jest.clearAllMocks();
});

describe('POST /api/v1/alerts', () => {
it('registers an alert and returns a unique alert ID', async () => {
mockPrisma.alert.create.mockResolvedValue({
id: 'alert-generated-id',
creatorId: 'creator-1',
walletAddress,
targetPrice: decimal('15'),
direction: 'ABOVE',
callbackUrl: 'https://example.com/hook',
status: 'PENDING',
createdAt: new Date(),
});

const res = await supertest(app)
.post('/api/v1/alerts')
.send({
creator_id: 'creator-1',
wallet_address: walletAddress,
target_price: '15',
direction: 'above',
callback_url: 'https://example.com/hook',
});

expect(res.status).toBe(201);
expect(res.body.success).toBe(true);
expect(res.body.data.id).toBe('alert-generated-id');
expect(res.body.data.direction).toBe('above');
});

it('returns 400 on invalid body (bad direction / url / price)', async () => {
const res = await supertest(app)
.post('/api/v1/alerts')
.send({
creator_id: 'creator-1',
wallet_address: walletAddress,
target_price: '-5',
direction: 'sideways',
callback_url: 'not-a-url',
});

expect(res.status).toBe(400);
expect(mockPrisma.alert.create).not.toHaveBeenCalled();
});
});

describe('DELETE /api/v1/alerts/:id', () => {
it('cancels a pending alert before it fires', async () => {
mockPrisma.alert.findFirst.mockResolvedValue({ id: 'alert-1', status: 'PENDING' });
mockPrisma.alert.delete.mockResolvedValue({ id: 'alert-1' });

const res = await supertest(app).delete('/api/v1/alerts/alert-1');

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(mockPrisma.alert.delete).toHaveBeenCalledWith({ where: { id: 'alert-1' } });
});

it('returns 404 for a non-existent alert', async () => {
mockPrisma.alert.findFirst.mockResolvedValue(null);

const res = await supertest(app).delete('/api/v1/alerts/missing-id');

expect(res.status).toBe(404);
});
});

describe('alert trigger evaluation', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it('fires on an above trigger and deletes the alert (one-shot)', async () => {
mockPrisma.alert.findMany.mockResolvedValue([
{
id: 'alert-above',
creatorId: 'creator-1',
walletAddress,
targetPrice: decimal('10'),
direction: 'ABOVE',
callbackUrl: 'https://example.com/hook',
status: 'PENDING',
},
]);
mockPrisma.alert.delete.mockResolvedValue({ id: 'alert-above' });
const mockFetch = jest.fn().mockResolvedValue({ ok: true, status: 200, statusText: 'OK' });
(global.fetch as jest.Mock) = mockFetch;

await evaluateTradeForAlerts({
creatorId: 'creator-1',
price: '11',
timestamp: new Date().toISOString(),
});

expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockPrisma.alert.delete).toHaveBeenCalledWith({ where: { id: 'alert-above' } });
});

it('fires on a below trigger and deletes the alert (one-shot)', async () => {
mockPrisma.alert.findMany.mockResolvedValue([
{
id: 'alert-below',
creatorId: 'creator-1',
walletAddress,
targetPrice: decimal('10'),
direction: 'BELOW',
callbackUrl: 'https://example.com/hook',
status: 'PENDING',
},
]);
mockPrisma.alert.delete.mockResolvedValue({ id: 'alert-below' });
const mockFetch = jest.fn().mockResolvedValue({ ok: true, status: 200, statusText: 'OK' });
(global.fetch as jest.Mock) = mockFetch;

await evaluateTradeForAlerts({
creatorId: 'creator-1',
price: '9',
timestamp: new Date().toISOString(),
});

expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockPrisma.alert.delete).toHaveBeenCalledWith({ where: { id: 'alert-below' } });
});

it('does not fire when price moves in the opposite direction', async () => {
mockPrisma.alert.findMany.mockResolvedValue([
{
id: 'alert-above',
creatorId: 'creator-1',
walletAddress,
targetPrice: decimal('10'),
direction: 'ABOVE',
callbackUrl: 'https://example.com/hook',
status: 'PENDING',
},
]);
const mockFetch = jest.fn();
(global.fetch as jest.Mock) = mockFetch;

await evaluateTradeForAlerts({
creatorId: 'creator-1',
price: '5',
timestamp: new Date().toISOString(),
});

expect(mockFetch).not.toHaveBeenCalled();
expect(mockPrisma.alert.delete).not.toHaveBeenCalled();
});

describe('failed delivery retry', () => {
beforeEach(() => {
jest.useFakeTimers();
});

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

it('retries failed delivery up to 3 times then marks the alert failed', async () => {
mockPrisma.alert.findMany.mockResolvedValue([
{
id: 'alert-fail',
creatorId: 'creator-1',
walletAddress,
targetPrice: decimal('10'),
direction: 'ABOVE',
callbackUrl: 'https://nonexistent.example.com/fail',
status: 'PENDING',
},
]);
mockPrisma.alert.update.mockResolvedValue({});
const mockFetch = jest.fn().mockRejectedValue(new Error('Network error'));
(global.fetch as jest.Mock) = mockFetch;

const promise = evaluateTradeForAlerts({
creatorId: 'creator-1',
price: '20',
timestamp: new Date().toISOString(),
});

for (let i = 0; i < envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS; i++) {
await jest.advanceTimersByTimeAsync(
Math.pow(2, i) * envConfig.WEBHOOK_RETRY_BASE_DELAY_MS
);
}

await promise;

expect(mockFetch).toHaveBeenCalledTimes(envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS);
expect(mockPrisma.alert.delete).not.toHaveBeenCalled();
expect(mockPrisma.alert.update).toHaveBeenLastCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: 'FAILED' }),
})
);
});
});
});
9 changes: 9 additions & 0 deletions src/modules/alerts/alert.router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Router } from 'express';
import { registerAlertHandler, deleteAlertHandler } from './alert.controllers';

const router = Router();

router.post('/', registerAlertHandler);
router.delete('/:id', deleteAlertHandler);

export default router;
Loading
Loading