diff --git a/prisma/schema/alert.prisma b/prisma/schema/alert.prisma index e6ba7c2..81befcd 100644 --- a/prisma/schema/alert.prisma +++ b/prisma/schema/alert.prisma @@ -1,16 +1,30 @@ // prisma/schema/alert.prisma -model PriceAlert { - id String @id @default(cuid()) +enum AlertDirection { + ABOVE + BELOW +} + +enum AlertStatus { + PENDING + TRIGGERED + FAILED +} + +model Alert { + id String @id @default(cuid()) creatorId String walletAddress String - targetPrice Decimal - direction String // "above" | "below" + targetPrice Decimal @db.Decimal(38, 18) + direction AlertDirection callbackUrl String - isActive Boolean @default(true) + status AlertStatus @default(PENDING) + retryCount Int @default(0) + lastError String? triggeredAt DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([creatorId]) - @@index([walletAddress]) + @@index([status]) } diff --git a/prisma/schema/migrations/20260619000000_add_price_alerts/migration.sql b/prisma/schema/migrations/20260619000000_add_price_alerts/migration.sql new file mode 100644 index 0000000..8f3943a --- /dev/null +++ b/prisma/schema/migrations/20260619000000_add_price_alerts/migration.sql @@ -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"); diff --git a/src/modules/alerts/__tests__/alert-duplicate.integration.test.ts b/src/modules/alerts/__tests__/alert-duplicate.integration.test.ts deleted file mode 100644 index a35581a..0000000 --- a/src/modules/alerts/__tests__/alert-duplicate.integration.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import request from 'supertest'; -import app from '../../../app'; -import { prisma } from '../../../utils/prisma.utils'; - -describe('POST /api/v1/alerts - Duplicate Alert', () => { - const creatorId = '1'; - const walletAddress = - 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; - const targetPrice = 100; - const direction = 'above'; - const callbackUrl = 'https://example.com/webhook'; - - afterEach(async () => { - await prisma.priceAlert.deleteMany({ - where: { creatorId }, - }); - }); - - it('should return 409 when registering duplicate alert with identical fields', async () => { - const alertPayload = { - creator_id: creatorId, - wallet_address: walletAddress, - target_price: targetPrice, - direction, - callback_url: callbackUrl, - }; - - // First registration should succeed - await request(app).post('/api/v1/alerts').send(alertPayload).expect(201); - - // Second identical registration should return 409 - const response = await request(app) - .post('/api/v1/alerts') - .send(alertPayload) - .expect(409); - - expect(response.body.error).toBeDefined(); - expect(response.body.message).toMatch(/already exists|duplicate/i); - - // Verify only one alert exists in database - const count = await prisma.priceAlert.count({ - where: { - creatorId, - walletAddress, - targetPrice, - direction, - isActive: true, - }, - }); - expect(count).toBe(1); - }); - - it('should allow different direction with same target price', async () => { - await request(app) - .post('/api/v1/alerts') - .send({ - creator_id: creatorId, - wallet_address: walletAddress, - target_price: targetPrice, - direction: 'above', - callback_url: callbackUrl, - }) - .expect(201); - - // Different direction should succeed - await request(app) - .post('/api/v1/alerts') - .send({ - creator_id: creatorId, - wallet_address: walletAddress, - target_price: targetPrice, - direction: 'below', - callback_url: callbackUrl, - }) - .expect(201); - - const count = await prisma.priceAlert.count({ - where: { creatorId, walletAddress, isActive: true }, - }); - expect(count).toBe(2); - }); - - it('should allow same alert parameters for different wallets', async () => { - const wallet2 = - 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; - - await request(app) - .post('/api/v1/alerts') - .send({ - creator_id: creatorId, - wallet_address: walletAddress, - target_price: targetPrice, - direction, - callback_url: callbackUrl, - }) - .expect(201); - - await request(app) - .post('/api/v1/alerts') - .send({ - creator_id: creatorId, - wallet_address: wallet2, - target_price: targetPrice, - direction, - callback_url: callbackUrl, - }) - .expect(201); - - const count = await prisma.priceAlert.count({ - where: { creatorId, isActive: true }, - }); - expect(count).toBe(2); - }); -}); diff --git a/src/modules/alerts/__tests__/alert-invalid-address.integration.test.ts b/src/modules/alerts/__tests__/alert-invalid-address.integration.test.ts deleted file mode 100644 index 0174489..0000000 --- a/src/modules/alerts/__tests__/alert-invalid-address.integration.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -// Integration test: alert registration returns 400 for invalid Stellar wallet address (#491) -// -// Covers: POST /alerts with a malformed wallet_address is rejected with 400 -// before any database write occurs. -// Uses Jest mocks — no database required. - -import { httpCreateAlert } from '../alert.controllers'; -import * as alertService from '../alert.service'; - -jest.mock('../../../utils/prisma.utils', () => ({ - prisma: { - priceAlert: { - create: jest.fn(), - }, - }, -})); - -function makeRes(): any { - const res: any = {}; - res.status = jest.fn().mockReturnValue(res); - res.setHeader = jest.fn().mockReturnValue(res); - res.json = jest.fn().mockReturnValue(res); - return res; -} - -function makeNext(): jest.Mock { - return jest.fn(); -} - -function makeReq(body: Record): any { - return { body }; -} - -const VALID_PAYLOAD = { - creator_id: 'creator-1', - wallet_address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - target_price: 100, - direction: 'above', - callback_url: 'https://example.com/cb', -}; - -describe('POST /alerts — invalid Stellar wallet address', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('returns 400 for a malformed wallet address', async () => { - const req = makeReq({ ...VALID_PAYLOAD, wallet_address: 'not-a-stellar-address' }); - const res = makeRes(); - - await httpCreateAlert(req, res, makeNext()); - - expect(res.status).toHaveBeenCalledWith(400); - }); - - it('error body identifies the wallet_address field', async () => { - const req = makeReq({ ...VALID_PAYLOAD, wallet_address: 'BADINPUT' }); - const res = makeRes(); - - await httpCreateAlert(req, res, makeNext()); - - const body = res.json.mock.calls[0][0]; - expect(body.success).toBe(false); - const details: Array<{ field: string; message: string }> = body.error.details ?? []; - const fieldNames = details.map((d) => d.field); - expect(fieldNames).toContain('wallet_address'); - }); - - it('does not create an alert record after failed validation', async () => { - const createSpy = jest.spyOn(alertService, 'createAlert'); - const req = makeReq({ ...VALID_PAYLOAD, wallet_address: 'invalid' }); - const res = makeRes(); - - await httpCreateAlert(req, res, makeNext()); - - expect(createSpy).not.toHaveBeenCalled(); - }); -}); diff --git a/src/modules/alerts/__tests__/alert-price-crossing.integration.test.ts b/src/modules/alerts/__tests__/alert-price-crossing.integration.test.ts deleted file mode 100644 index 20e4f72..0000000 --- a/src/modules/alerts/__tests__/alert-price-crossing.integration.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -// Integration tests for price alert firing and post-delivery cleanup (#464, #472) -// -// #464: Verifies that the registered callback receives a POST with the correct -// payload when the creator key price crosses the registered threshold. -// #472: Verifies that the alert record is deleted (set inactive) after a -// successful delivery, and that a second crossing does not retrigger. - -import { evaluatePriceAlertsForMovement } from '../alert.service'; -import { prisma } from '../../../utils/prisma.utils'; - -jest.mock('../../../utils/prisma.utils', () => ({ - prisma: { - priceAlert: { - findMany: jest.fn(), - update: jest.fn(), - }, - }, -})); - -const mockPrisma = prisma as unknown as { - priceAlert: { - findMany: jest.Mock; - update: jest.Mock; - }; -}; - -const CREATOR_ID = 'creator-alert-test'; -const WALLET_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; -const CALLBACK_URL = 'https://hooks.example.com/price-alert'; - -const BASE_ALERT = { - id: 'alert-fire-test', - creatorId: CREATOR_ID, - walletAddress: WALLET_ADDRESS, - targetPrice: 100, - direction: 'above' as const, - callbackUrl: CALLBACK_URL, - isActive: true, - triggeredAt: null, - createdAt: new Date('2026-06-01T00:00:00Z'), -}; - -beforeEach(() => { - jest.clearAllMocks(); - global.fetch = jest.fn().mockResolvedValue({ ok: true }); -}); - -describe('price alert fires when price crosses threshold (#464)', () => { - it('calls the callback URL when price crosses above the target', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); - mockPrisma.priceAlert.update.mockResolvedValue({}); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 110, - }); - - expect(global.fetch).toHaveBeenCalledTimes(1); - expect(global.fetch).toHaveBeenCalledWith( - CALLBACK_URL, - expect.objectContaining({ method: 'POST' }) - ); - }); - - it('sends the correct payload to the callback', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); - mockPrisma.priceAlert.update.mockResolvedValue({}); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 110, - }); - - const callArgs = (global.fetch as jest.Mock).mock.calls[0]; - const body = JSON.parse(callArgs[1].body); - - expect(body.creator_id).toBe(CREATOR_ID); - expect(body.target_price).toBe(100); - expect(body.current_price).toBe(110); - expect(body.direction).toBe('above'); - expect(body.event_type).toBe('price_alert'); - }); - - it('calls the callback when price crosses below the target', async () => { - const belowAlert = { ...BASE_ALERT, id: 'alert-below', direction: 'below' as const, targetPrice: 80 }; - mockPrisma.priceAlert.findMany.mockResolvedValue([belowAlert]); - mockPrisma.priceAlert.update.mockResolvedValue({}); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 70, - }); - - expect(global.fetch).toHaveBeenCalledTimes(1); - const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body); - expect(body.direction).toBe('below'); - expect(body.current_price).toBe(70); - }); - - it('does not call the callback when price does not cross the threshold', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 95, - }); - - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it('does not call the callback when price crosses in the wrong direction', async () => { - // alert is 'above' but price dropped - mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 110, - currentPrice: 90, - }); - - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it('includes alert_id in the callback payload', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); - mockPrisma.priceAlert.update.mockResolvedValue({}); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 110, - }); - - const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body); - expect(body.alert_id).toBe(BASE_ALERT.id); - }); -}); - -describe('price alert deleted after successful webhook delivery (#472)', () => { - it('marks the alert inactive after successful delivery', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); - mockPrisma.priceAlert.update.mockResolvedValue({}); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 110, - }); - - expect(mockPrisma.priceAlert.update).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: BASE_ALERT.id }, - data: expect.objectContaining({ isActive: false }), - }) - ); - }); - - it('sets triggeredAt after successful delivery', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); - mockPrisma.priceAlert.update.mockResolvedValue({}); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 110, - }); - - const updateCall = mockPrisma.priceAlert.update.mock.calls[0][0]; - expect(updateCall.data.triggeredAt).toBeInstanceOf(Date); - }); - - it('does not re-trigger when the alert is already inactive (second crossing)', async () => { - // Second call: alert is not returned because isActive: false filters it out - mockPrisma.priceAlert.findMany.mockResolvedValue([]); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 120, - }); - - expect(global.fetch).not.toHaveBeenCalled(); - expect(mockPrisma.priceAlert.update).not.toHaveBeenCalled(); - }); - - it('does not mark alert inactive when the callback fails', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); - (global.fetch as jest.Mock).mockResolvedValue({ ok: false, status: 500 }); - - await expect( - evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 110, - }) - ).rejects.toThrow(); - - expect(mockPrisma.priceAlert.update).not.toHaveBeenCalled(); - }); - - it('update is called exactly once per alert per movement', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([BASE_ALERT]); - mockPrisma.priceAlert.update.mockResolvedValue({}); - - await evaluatePriceAlertsForMovement({ - creatorId: CREATOR_ID, - previousPrice: 90, - currentPrice: 110, - }); - - expect(mockPrisma.priceAlert.update).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/modules/alerts/__tests__/alert-price-movement.integration.test.ts b/src/modules/alerts/__tests__/alert-price-movement.integration.test.ts deleted file mode 100644 index 8967ed5..0000000 --- a/src/modules/alerts/__tests__/alert-price-movement.integration.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { evaluatePriceAlertsForMovement } from '../alert.service'; -import { prisma } from '../../../utils/prisma.utils'; -import { envConfig } from '../../../config'; -import { logger } from '../../../utils/logger.utils'; - -jest.mock('../../../utils/prisma.utils', () => ({ - prisma: { - priceAlert: { - findMany: jest.fn(), - update: jest.fn(), - }, - }, -})); - -jest.mock('../../../utils/logger.utils', () => ({ - logger: { - warn: jest.fn(), - error: jest.fn(), - }, -})); - -const mockPrisma = prisma as unknown as { - priceAlert: { - findMany: jest.Mock; - update: jest.Mock; - }; -}; - -const mockLogger = logger as unknown as { - warn: jest.Mock; - error: jest.Mock; -}; - -const BASE_ALERT = { - id: 'alert-1', - creatorId: 'creator-1', - walletAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - targetPrice: 100, - callbackUrl: 'https://example.com/price-alert', - isActive: true, - triggeredAt: null, - createdAt: new Date('2026-06-01T00:00:00Z'), -}; - -describe('price alert movement integration', () => { - beforeEach(() => { - jest.clearAllMocks(); - global.fetch = jest.fn().mockResolvedValue({ ok: true }); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('does not fire an above alert when the price drops', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([ - { - ...BASE_ALERT, - id: 'above-alert', - direction: 'above', - targetPrice: 120, - }, - ]); - - await evaluatePriceAlertsForMovement({ - creatorId: 'creator-1', - previousPrice: 100, - currentPrice: 90, - }); - - expect(global.fetch).not.toHaveBeenCalled(); - expect(mockPrisma.priceAlert.update).not.toHaveBeenCalled(); - expect(mockPrisma.priceAlert.findMany).toHaveBeenCalledWith({ - where: { - creatorId: 'creator-1', - isActive: true, - triggeredAt: null, - }, - }); - }); - - it('does not fire a below alert when the price rises', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([ - { - ...BASE_ALERT, - id: 'below-alert', - direction: 'below', - targetPrice: 80, - }, - ]); - - await evaluatePriceAlertsForMovement({ - creatorId: 'creator-1', - previousPrice: 100, - currentPrice: 110, - }); - - expect(global.fetch).not.toHaveBeenCalled(); - expect(mockPrisma.priceAlert.update).not.toHaveBeenCalled(); - }); - - it('logs a structured warning with masked URL after a failed delivery attempt', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([ - { - ...BASE_ALERT, - id: 'above-alert', - direction: 'above', - targetPrice: 100, - callbackUrl: 'https://hooks.example.com/secret/path?token=sensitive', - }, - ]); - mockPrisma.priceAlert.update.mockResolvedValue({}); - (global.fetch as jest.Mock) - .mockRejectedValueOnce(new Error('Network failure')) - .mockResolvedValueOnce({ ok: true }); - - await evaluatePriceAlertsForMovement({ - creatorId: 'creator-1', - previousPrice: 90, - currentPrice: 110, - }); - - expect(mockLogger.warn).toHaveBeenCalledWith( - { - alert_id: 'above-alert', - retry_count: 1, - error_code: 'Error', - failure_reason: 'Network failure', - masked_url: 'https://hooks.example.com', - }, - 'Price alert webhook delivery failed' - ); - expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain('/secret/path'); - expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain('sensitive'); - expect(mockLogger.error).not.toHaveBeenCalled(); - expect(mockPrisma.priceAlert.update).toHaveBeenCalledWith({ - where: { id: 'above-alert' }, - data: { - isActive: false, - triggeredAt: expect.any(Date), - }, - }); - }); - - it('logs a structured final error with masked URL when retries are exhausted', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([ - { - ...BASE_ALERT, - id: 'below-alert', - direction: 'below', - targetPrice: 80, - callbackUrl: 'https://hooks.example.com/private/price-alert', - }, - ]); - (global.fetch as jest.Mock).mockResolvedValue({ - ok: false, - status: 503, - }); - - await expect( - evaluatePriceAlertsForMovement({ - creatorId: 'creator-1', - previousPrice: 100, - currentPrice: 70, - }) - ).rejects.toThrow('HTTP_503'); - - expect(global.fetch).toHaveBeenCalledTimes(envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS); - expect(mockLogger.error).toHaveBeenCalledWith( - { - alert_id: 'below-alert', - retry_count: envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS, - error_code: 'HTTP_503', - failure_reason: 'HTTP_503', - masked_url: 'https://hooks.example.com', - final: true, - }, - 'Price alert webhook delivery exhausted retries' - ); - expect(JSON.stringify(mockLogger.error.mock.calls)).not.toContain('/private/price-alert'); - expect(mockPrisma.priceAlert.update).not.toHaveBeenCalled(); - }); - - it('logs a structured error with all fields when the database query fails', async () => { - const dbError = new Error('connection refused'); - mockPrisma.priceAlert.findMany.mockRejectedValue(dbError); - - await expect( - evaluatePriceAlertsForMovement({ - creatorId: 'creator-1', - previousPrice: 90, - currentPrice: 110, - ledger_sequence: 42, - }) - ).rejects.toThrow('connection refused'); - - expect(mockLogger.error).toHaveBeenCalledWith( - { - creator_id: 'creator-1', - ledger_sequence: 42, - new_price: 110, - error_message: 'connection refused', - failed_at: expect.any(String), - }, - 'Price alert threshold check failed' - ); - }); - - it('does not log alert threshold failure when the check succeeds', async () => { - mockPrisma.priceAlert.findMany.mockResolvedValue([]); - - await evaluatePriceAlertsForMovement({ - creatorId: 'creator-1', - previousPrice: 90, - currentPrice: 110, - }); - - expect(mockLogger.error).not.toHaveBeenCalled(); - }); -}); diff --git a/src/modules/alerts/__tests__/alert.service.test.ts b/src/modules/alerts/__tests__/alert.service.test.ts deleted file mode 100644 index f9b8592..0000000 --- a/src/modules/alerts/__tests__/alert.service.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -// Unit tests for alert.service.ts (#423) -// -// Covers: createAlert, listAlerts, deleteAlert. -// Uses Jest mocks for prisma — no database required. - -import { createAlert, listAlerts, deleteAlert } from '../alert.service'; -import { prisma } from '../../../utils/prisma.utils'; -import { logger } from '../../../utils/logger.utils'; - -jest.mock('../../../utils/prisma.utils', () => ({ - prisma: { - priceAlert: { - create: jest.fn(), - findMany: jest.fn(), - findFirst: jest.fn(), - delete: jest.fn(), - }, - }, -})); - -jest.mock('../../../utils/logger.utils', () => ({ - logger: { info: jest.fn() }, -})); - -const mockedPrisma = prisma as jest.Mocked; - -const VALID_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; - -const BASE_INPUT = { - creator_id: 'creator-1', - wallet_address: VALID_ADDRESS, - target_price: 100, - direction: 'above' as const, - callback_url: 'https://example.com/callback', -}; - -const DB_ALERT = { - id: 'alert-1', - creatorId: 'creator-1', - walletAddress: VALID_ADDRESS, - targetPrice: 100, - direction: 'above', - callbackUrl: 'https://example.com/callback', - isActive: true, - triggeredAt: null, - createdAt: new Date('2026-01-01T00:00:00Z'), -}; - -describe('createAlert', () => { - afterEach(() => jest.clearAllMocks()); - - it('calls prisma.priceAlert.create with correct data', async () => { - (mockedPrisma.priceAlert.create as jest.Mock).mockResolvedValue(DB_ALERT); - - const result = await createAlert(BASE_INPUT); - - expect(mockedPrisma.priceAlert.create).toHaveBeenCalledWith({ - data: { - creatorId: 'creator-1', - walletAddress: VALID_ADDRESS, - targetPrice: 100, - direction: 'above', - callbackUrl: 'https://example.com/callback', - }, - }); - expect(result).toEqual(DB_ALERT); - - expect(logger.info).toHaveBeenCalledWith( - expect.objectContaining({ - alert_id: DB_ALERT.id, - creator_id: DB_ALERT.creatorId, - direction: DB_ALERT.direction, - target_price: DB_ALERT.targetPrice, - registered_at: DB_ALERT.createdAt, - wallet_address: 'GAAA***AAAA', - }), - 'Price alert registered' - ); - expect((logger.info as jest.Mock).mock.calls[0][0]).not.toHaveProperty('callback_url'); - }); - - it('creates a below-direction alert', async () => { - const input = { ...BASE_INPUT, direction: 'below' as const, target_price: 50 }; - (mockedPrisma.priceAlert.create as jest.Mock).mockResolvedValue({ - ...DB_ALERT, - direction: 'below', - targetPrice: 50, - }); - - const result = await createAlert(input); - expect(result.direction).toBe('below'); - }); -}); - -describe('listAlerts', () => { - afterEach(() => jest.clearAllMocks()); - - it('returns active alerts for a wallet address', async () => { - (mockedPrisma.priceAlert.findMany as jest.Mock).mockResolvedValue([DB_ALERT]); - - const result = await listAlerts(VALID_ADDRESS); - - expect(mockedPrisma.priceAlert.findMany).toHaveBeenCalledWith({ - where: { walletAddress: VALID_ADDRESS, isActive: true }, - orderBy: { createdAt: 'desc' }, - }); - expect(result).toHaveLength(1); - expect(result[0].id).toBe('alert-1'); - }); - - it('returns empty array when no alerts exist', async () => { - (mockedPrisma.priceAlert.findMany as jest.Mock).mockResolvedValue([]); - - const result = await listAlerts(VALID_ADDRESS); - expect(result).toEqual([]); - }); -}); - -describe('deleteAlert', () => { - afterEach(() => jest.clearAllMocks()); - - it('deletes the alert and returns its id when found', async () => { - (mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(DB_ALERT); - (mockedPrisma.priceAlert.delete as jest.Mock).mockResolvedValue(DB_ALERT); - - const result = await deleteAlert('alert-1', VALID_ADDRESS); - - expect(mockedPrisma.priceAlert.findFirst).toHaveBeenCalledWith({ - where: { id: 'alert-1', walletAddress: VALID_ADDRESS }, - }); - expect(mockedPrisma.priceAlert.delete).toHaveBeenCalledWith({ - where: { id: 'alert-1' }, - }); - expect(result).toEqual({ id: 'alert-1' }); - - expect(logger.info).toHaveBeenCalledWith( - expect.objectContaining({ - alert_id: DB_ALERT.id, - creator_id: DB_ALERT.creatorId, - cancelled_at: expect.any(Date), - wallet_address: 'GAAA***AAAA', - }), - 'Price alert cancelled' - ); - expect((logger.info as jest.Mock).mock.calls[0][0]).not.toHaveProperty('callback_url'); - }); - - it('returns null when the alert is not found', async () => { - (mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(null); - - const result = await deleteAlert('nonexistent', VALID_ADDRESS); - - expect(result).toBeNull(); - expect(mockedPrisma.priceAlert.delete).not.toHaveBeenCalled(); - }); - - it('does not delete an alert belonging to a different wallet address', async () => { - (mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(null); - - const result = await deleteAlert('alert-1', 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'); - expect(result).toBeNull(); - }); -}); diff --git a/src/modules/alerts/alert.controllers.ts b/src/modules/alerts/alert.controllers.ts index 91c559d..9b42102 100644 --- a/src/modules/alerts/alert.controllers.ts +++ b/src/modules/alerts/alert.controllers.ts @@ -1,122 +1,65 @@ -import { Request, Response, NextFunction } from 'express'; +import type { Request, Response } from 'express'; import { - CreateAlertSchema, - ListAlertsQuerySchema, - AlertParamsSchema, - DeleteAlertBodySchema, -} from './alert.schemas'; -import { createAlert, listAlerts, deleteAlert } from './alert.service'; -import { - sendSuccess, - sendValidationError, - sendNotFound, + 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'; -/** - * POST /api/v1/alerts - * Register a new price alert. - */ -export async function httpCreateAlert( - req: Request, - res: Response, - next: NextFunction +export async function registerAlertHandler( + req: Request, + res: Response ): Promise { - try { - const parsed = CreateAlertSchema.safeParse(req.body); - if (!parsed.success) { - sendValidationError( - res, - 'Invalid alert input', - parsed.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({ - field: issue.path.join('.'), - message: issue.message, - })) - ); - return; - } + 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; + } - const alert = await createAlert(parsed.data); - sendSuccess(res, alert, 201); - } catch (error) { - next(error); - } + 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'); + } } -/** - * GET /api/v1/alerts?wallet_address=... - * List all active price alerts for a wallet address. - */ -export async function httpListAlerts( - req: Request, - res: Response, - next: NextFunction +export async function deleteAlertHandler( + req: Request, + res: Response ): Promise { - try { - const parsed = ListAlertsQuerySchema.safeParse(req.query); - if (!parsed.success) { - sendValidationError( - res, - 'Invalid query parameters', - parsed.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({ - field: issue.path.join('.'), - message: issue.message, - })) - ); - return; - } - - const alerts = await listAlerts(parsed.data.wallet_address); - sendSuccess(res, { items: alerts, total: alerts.length }); - } catch (error) { - next(error); - } -} - -/** - * DELETE /api/v1/alerts/:id - * Delete a price alert by id, scoped to the wallet address in the request body. - */ -export async function httpDeleteAlert( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const parsedParams = AlertParamsSchema.safeParse(req.params); - if (!parsedParams.success) { - sendValidationError( - res, - 'Invalid alert id', - parsedParams.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({ - field: issue.path.join('.'), - message: issue.message, - })) - ); - return; - } - - const parsedBody = DeleteAlertBodySchema.safeParse(req.body); - if (!parsedBody.success) { - sendValidationError( - res, - 'Invalid request body', - parsedBody.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({ - field: issue.path.join('.'), - message: issue.message, - })) - ); - return; - } - - const result = await deleteAlert(parsedParams.data.id, parsedBody.data.wallet_address); + const rawAlertId = req.params.id; + const alertId = Array.isArray(rawAlertId) ? rawAlertId[0] : rawAlertId; - if (!result) { - sendNotFound(res, 'Alert'); - return; - } + if (!alertId) { + sendError(res, 400, ErrorCode.BAD_REQUEST, 'Missing alert ID in path'); + return; + } - sendSuccess(res, result); - } catch (error) { - next(error); + 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'); + } } diff --git a/src/modules/alerts/alert.integration.test.ts b/src/modules/alerts/alert.integration.test.ts new file mode 100644 index 0000000..f9022d1 --- /dev/null +++ b/src/modules/alerts/alert.integration.test.ts @@ -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' }), + }) + ); + }); + }); +}); diff --git a/src/modules/alerts/alert.router.ts b/src/modules/alerts/alert.router.ts index 90aaf75..5b3ac56 100644 --- a/src/modules/alerts/alert.router.ts +++ b/src/modules/alerts/alert.router.ts @@ -1,24 +1,9 @@ import { Router } from 'express'; -import { httpCreateAlert, httpListAlerts, httpDeleteAlert } from './alert.controllers'; +import { registerAlertHandler, deleteAlertHandler } from './alert.controllers'; -const alertsRouter = Router(); +const router = Router(); -/** - * POST /api/v1/alerts - * Register a new price alert for a creator key price threshold. - */ -alertsRouter.post('/', httpCreateAlert); +router.post('/', registerAlertHandler); +router.delete('/:id', deleteAlertHandler); -/** - * GET /api/v1/alerts?wallet_address=... - * List all active price alerts for the given Stellar wallet address. - */ -alertsRouter.get('/', httpListAlerts); - -/** - * DELETE /api/v1/alerts/:id - * Delete a price alert by id (wallet_address required in body for authorization). - */ -alertsRouter.delete('/:id', httpDeleteAlert); - -export default alertsRouter; +export default router; diff --git a/src/modules/alerts/alert.schemas.ts b/src/modules/alerts/alert.schemas.ts index 0f7f2f1..89e6443 100644 --- a/src/modules/alerts/alert.schemas.ts +++ b/src/modules/alerts/alert.schemas.ts @@ -1,38 +1,20 @@ import { z } from 'zod'; -import { isValidStellarAddress } from '../wallet/wallet.utils'; +import { StellarAddressSchema } from '../wallet/wallet.schemas'; -export const CreateAlertSchema = z.object({ - creator_id: z.string().min(1, 'creator_id is required'), - wallet_address: z - .string() - .refine(isValidStellarAddress, { message: 'Invalid Stellar wallet address' }), - target_price: z - .number({ invalid_type_error: 'target_price must be a number' }) - .positive('target_price must be positive'), - direction: z.enum(['above', 'below'], { - errorMap: () => ({ message: "direction must be 'above' or 'below'" }), - }), - callback_url: z.string().url('callback_url must be a valid URL'), -}); - -export type CreateAlertInput = z.infer; - -export const ListAlertsQuerySchema = z.object({ - wallet_address: z - .string() - .refine(isValidStellarAddress, { message: 'Invalid Stellar wallet address' }), -}); +export const AlertDirectionEnum = z.enum(['above', 'below']); -export type ListAlertsQueryType = z.infer; - -export const AlertParamsSchema = z.object({ - id: z.string().min(1, 'Alert id is required'), -}); - -export const DeleteAlertBodySchema = z.object({ - wallet_address: z - .string() - .refine(isValidStellarAddress, { message: 'Invalid Stellar wallet address' }), +export const CreateAlertSchema = z.object({ + creator_id: z.string().min(1, 'creator_id is required'), + wallet_address: StellarAddressSchema, + target_price: z + .union([z.string(), z.number()]) + .transform((v) => String(v)) + .refine((v) => { + const n = Number(v); + return Number.isFinite(n) && n > 0; + }, 'target_price must be a positive number'), + direction: AlertDirectionEnum, + callback_url: z.string().url('callback_url must be a valid URL'), }); -export type DeleteAlertBodyType = z.infer; +export type CreateAlertType = z.infer; diff --git a/src/modules/alerts/alert.service.test.ts b/src/modules/alerts/alert.service.test.ts new file mode 100644 index 0000000..0aa58e2 --- /dev/null +++ b/src/modules/alerts/alert.service.test.ts @@ -0,0 +1,278 @@ +import { Prisma } from '@prisma/client'; +import { prisma } from '../../utils/prisma.utils'; +import { envConfig } from '../../config'; +import * as alertService from './alert.service'; + +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() }, +})); + +const mockPrisma = prisma as unknown as { + alert: { + create: jest.Mock; + findFirst: jest.Mock; + findMany: jest.Mock; + delete: jest.Mock; + update: jest.Mock; + }; +}; + +function decimal(v: string): Prisma.Decimal { + return new Prisma.Decimal(v); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('createAlert', () => { + it('creates an alert and returns a unique ID with normalized fields', async () => { + mockPrisma.alert.create.mockResolvedValue({ + id: 'alert-1', + creatorId: 'creator-1', + walletAddress: 'GABC', + targetPrice: decimal('10.5'), + direction: 'ABOVE', + callbackUrl: 'https://example.com/hook', + status: 'PENDING', + createdAt: new Date(), + }); + + const result = await alertService.createAlert({ + creatorId: 'creator-1', + walletAddress: 'GABC', + targetPrice: '10.5', + direction: 'above', + callbackUrl: 'https://example.com/hook', + }); + + expect(result.id).toBe('alert-1'); + expect(result.direction).toBe('above'); + expect(result.status).toBe('pending'); + expect(result.targetPrice).toBe('10.5'); + expect(mockPrisma.alert.create).toHaveBeenCalledTimes(1); + }); +}); + +describe('deleteAlert', () => { + it('cancels a pending alert', async () => { + mockPrisma.alert.findFirst.mockResolvedValue({ id: 'alert-1', status: 'PENDING' }); + mockPrisma.alert.delete.mockResolvedValue({ id: 'alert-1' }); + + const result = await alertService.deleteAlert('alert-1'); + + expect(result).toEqual({ id: 'alert-1' }); + expect(mockPrisma.alert.findFirst).toHaveBeenCalledWith({ + where: { id: 'alert-1', status: 'PENDING' }, + }); + expect(mockPrisma.alert.delete).toHaveBeenCalledWith({ where: { id: 'alert-1' } }); + }); + + it('returns null for a non-existent or already-fired alert', async () => { + mockPrisma.alert.findFirst.mockResolvedValue(null); + + const result = await alertService.deleteAlert('alert-1'); + + expect(result).toBeNull(); + expect(mockPrisma.alert.delete).not.toHaveBeenCalled(); + }); +}); + +describe('evaluateTradeForAlerts — threshold crossing', () => { + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('fires an ABOVE alert when price rises past the target', async () => { + mockPrisma.alert.findMany.mockResolvedValue([ + { + id: 'alert-above', + creatorId: 'creator-1', + walletAddress: 'GABC', + targetPrice: decimal('10'), + direction: 'ABOVE', + callbackUrl: 'https://example.com/hook', + status: 'PENDING', + }, + ]); + const mockFetch = jest.fn().mockResolvedValue({ ok: true, status: 200, statusText: 'OK' }); + (global.fetch as jest.Mock) = mockFetch; + mockPrisma.alert.delete.mockResolvedValue({ id: 'alert-above' }); + + await alertService.evaluateTradeForAlerts({ + creatorId: 'creator-1', + price: '12', + timestamp: new Date().toISOString(), + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string); + expect(body).toMatchObject({ + creator_id: 'creator-1', + triggered_price: '12', + target_price: '10', + direction: 'above', + }); + expect(mockPrisma.alert.delete).toHaveBeenCalledWith({ where: { id: 'alert-above' } }); + }); + + it('fires a BELOW alert when price drops past the target', async () => { + mockPrisma.alert.findMany.mockResolvedValue([ + { + id: 'alert-below', + creatorId: 'creator-1', + walletAddress: 'GABC', + targetPrice: decimal('10'), + direction: 'BELOW', + callbackUrl: 'https://example.com/hook', + status: 'PENDING', + }, + ]); + const mockFetch = jest.fn().mockResolvedValue({ ok: true, status: 200, statusText: 'OK' }); + (global.fetch as jest.Mock) = mockFetch; + mockPrisma.alert.delete.mockResolvedValue({ id: 'alert-below' }); + + await alertService.evaluateTradeForAlerts({ + creatorId: 'creator-1', + price: '8', + timestamp: new Date().toISOString(), + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string); + expect(body.direction).toBe('below'); + expect(mockPrisma.alert.delete).toHaveBeenCalledWith({ where: { id: 'alert-below' } }); + }); + + it('does NOT fire an ABOVE alert when price moves the opposite direction', async () => { + mockPrisma.alert.findMany.mockResolvedValue([ + { + id: 'alert-above', + creatorId: 'creator-1', + walletAddress: 'GABC', + targetPrice: decimal('10'), + direction: 'ABOVE', + callbackUrl: 'https://example.com/hook', + status: 'PENDING', + }, + ]); + const mockFetch = jest.fn(); + (global.fetch as jest.Mock) = mockFetch; + + await alertService.evaluateTradeForAlerts({ + creatorId: 'creator-1', + price: '8', + timestamp: new Date().toISOString(), + }); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockPrisma.alert.delete).not.toHaveBeenCalled(); + }); + + it('does NOT fire a BELOW alert when price moves the opposite direction', async () => { + mockPrisma.alert.findMany.mockResolvedValue([ + { + id: 'alert-below', + creatorId: 'creator-1', + walletAddress: 'GABC', + targetPrice: decimal('10'), + direction: 'BELOW', + callbackUrl: 'https://example.com/hook', + status: 'PENDING', + }, + ]); + const mockFetch = jest.fn(); + (global.fetch as jest.Mock) = mockFetch; + + await alertService.evaluateTradeForAlerts({ + creatorId: 'creator-1', + price: '12', + timestamp: new Date().toISOString(), + }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('does nothing when no pending alerts exist for the creator', async () => { + mockPrisma.alert.findMany.mockResolvedValue([]); + const mockFetch = jest.fn(); + (global.fetch as jest.Mock) = mockFetch; + + await alertService.evaluateTradeForAlerts({ + creatorId: 'creator-1', + price: '99', + timestamp: new Date().toISOString(), + }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + +describe('evaluateTradeForAlerts — delivery retry', () => { + beforeEach(() => { + jest.useFakeTimers(); + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('retries delivery up to max attempts then marks the alert FAILED', async () => { + mockPrisma.alert.findMany.mockResolvedValue([ + { + id: 'alert-fail', + creatorId: 'creator-1', + walletAddress: 'GABC', + 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 = alertService.evaluateTradeForAlerts({ + creatorId: 'creator-1', + price: '12', + 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({ + where: { id: 'alert-fail' }, + data: expect.objectContaining({ status: 'FAILED' }), + }) + ); + }); +}); diff --git a/src/modules/alerts/alert.service.ts b/src/modules/alerts/alert.service.ts index 38ff0e6..d5be9cf 100644 --- a/src/modules/alerts/alert.service.ts +++ b/src/modules/alerts/alert.service.ts @@ -1,245 +1,229 @@ +import { Prisma } from '@prisma/client'; import { prisma } from '../../utils/prisma.utils'; -import { envConfig } from '../../config'; import { logger } from '../../utils/logger.utils'; -import { CreateAlertInput } from './alert.schemas'; +import { envConfig } from '../../config'; +import type { + AlertDirectionName, + AlertResponse, + AlertTradeEvent, + AlertTriggerPayload, + CreateAlertInput, +} from './alert.types'; + +type DbDirection = 'ABOVE' | 'BELOW'; +type DbStatus = 'PENDING' | 'TRIGGERED' | 'FAILED'; + +function toDbDirection(direction: AlertDirectionName): DbDirection { + return direction === 'above' ? 'ABOVE' : 'BELOW'; +} -export type PriceMovement = { - creatorId: string; - previousPrice: number | string; - currentPrice: number | string; - ledger_sequence?: number; -}; +function fromDbDirection(direction: DbDirection): AlertDirectionName { + return direction === 'ABOVE' ? 'above' : 'below'; +} -/** - * Creates a new price alert for a wallet address watching a creator's key price. - * Throws a 409 error if an identical active alert already exists. - */ -export async function createAlert(input: CreateAlertInput) { - // Check for duplicate active alert - const existingAlert = await prisma.priceAlert.findFirst({ - where: { - creatorId: input.creator_id, - walletAddress: input.wallet_address, - targetPrice: input.target_price, - direction: input.direction, - isActive: true, - }, - }); - - if (existingAlert) { - const error = new Error( - 'An identical active alert already exists for this creator, wallet, price, and direction' - ) as any; - error.statusCode = 409; - error.code = 'DUPLICATE_ALERT'; - throw error; - } - - const alert = await prisma.priceAlert.create({ - data: { - creatorId: input.creator_id, - walletAddress: input.wallet_address, - targetPrice: input.target_price, - direction: input.direction, - callbackUrl: input.callback_url, - }, - }); - - logger.info( - { - alert_id: alert.id, - creator_id: alert.creatorId, - direction: alert.direction, - target_price: toNumber(alert.targetPrice), - registered_at: alert.createdAt, - wallet_address: maskWalletAddress(alert.walletAddress), - }, - 'Price alert registered' - ); - - return alert; +function fromDbStatus(status: DbStatus): AlertResponse['status'] { + switch (status) { + case 'TRIGGERED': + return 'triggered'; + case 'FAILED': + return 'failed'; + default: + return 'pending'; + } } -/** - * Lists all active price alerts for a given wallet address. - */ -export async function listAlerts(walletAddress: string) { - return await prisma.priceAlert.findMany({ - where: { walletAddress, isActive: true }, - orderBy: { createdAt: 'desc' }, - }); +interface AlertRecord { + id: string; + creatorId: string; + walletAddress: string; + targetPrice: Prisma.Decimal; + direction: DbDirection; + callbackUrl: string; + status: DbStatus; + createdAt: Date; +} + +function toAlertResponse(alert: AlertRecord): AlertResponse { + return { + id: alert.id, + creatorId: alert.creatorId, + walletAddress: alert.walletAddress, + targetPrice: alert.targetPrice.toString(), + direction: fromDbDirection(alert.direction), + callbackUrl: alert.callbackUrl, + status: fromDbStatus(alert.status), + createdAt: alert.createdAt, + }; } /** - * Deletes a price alert by id, scoped to the wallet address for authorization. - * Returns the deleted record id or null if not found. + * Registers a one-shot price alert and returns its unique ID. */ -export async function deleteAlert( - id: string, - walletAddress: string -): Promise<{ id: string } | null> { - const existing = await prisma.priceAlert.findFirst({ - where: { id, walletAddress }, - }); - - if (!existing) { - return null; - } - - await prisma.priceAlert.delete({ where: { id } }); - - logger.info( - { - alert_id: existing.id, - creator_id: existing.creatorId, - cancelled_at: new Date(), - wallet_address: maskWalletAddress(existing.walletAddress), - }, - 'Price alert cancelled' - ); - - return { id }; +export async function createAlert( + input: CreateAlertInput +): Promise { + const alert = await prisma.alert.create({ + data: { + creatorId: input.creatorId, + walletAddress: input.walletAddress, + targetPrice: new Prisma.Decimal(input.targetPrice), + direction: toDbDirection(input.direction), + callbackUrl: input.callbackUrl, + }, + }); + + return toAlertResponse(alert as AlertRecord); } -function toNumber(value: number | string | { toString(): string }): number { - return typeof value === 'number' ? value : Number(value.toString()); -} +/** + * Cancels a pending alert. Only alerts that have not already fired (PENDING) + * can be cancelled. Returns the deleted alert ID, or null when not found. + */ +export async function deleteAlert(alertId: string): Promise<{ id: string } | null> { + const alert = await prisma.alert.findFirst({ + where: { id: alertId, status: 'PENDING' }, + }); -function maskWalletAddress(address: string): string { - if (address.length <= 8) return address; - return `${address.slice(0, 4)}***${address.slice(-4)}`; -} + if (!alert) { + return null; + } -function maskCallbackUrl(callbackUrl: string): string { - try { - const url = new URL(callbackUrl); - return `${url.protocol}//${url.host}`; - } catch { - return 'invalid-url'; - } + await prisma.alert.delete({ where: { id: alertId } }); + return { id: alertId }; } -function getDeliveryErrorCode(error: unknown): string { - if (error instanceof Error && error.message.startsWith('HTTP_')) { - return error.message; - } - - return error instanceof Error ? error.name : 'UNKNOWN_ERROR'; +/** + * Returns true when the new price has crossed the registered threshold in the + * direction the alert was registered for. + * + * - `above`: fires when the new price is at or above the target. + * - `below`: fires when the new price is at or below the target. + */ +function crossesThreshold( + direction: DbDirection, + newPrice: number, + targetPrice: number +): boolean { + if (direction === 'ABOVE') { + return newPrice >= targetPrice; + } + return newPrice <= targetPrice; } -async function deliverPriceAlertWebhook( - alert: { - id: string; - creatorId: string; - walletAddress: string; - targetPrice: unknown; - direction: string; - callbackUrl: string; - }, - payload: Record +/** + * Evaluates all pending alerts for the creator against the new trade price and + * fires any whose threshold was crossed. + * + * Wire this into the indexer trade-event processing path alongside webhook + * dispatch — it processes the same trade event. + */ +export async function evaluateTradeForAlerts( + tradeEvent: AlertTradeEvent ): Promise { - const maxAttempts = envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS; - const maskedUrl = maskCallbackUrl(alert.callbackUrl); - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await fetch(alert.callbackUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - throw new Error(`HTTP_${response.status}`); - } - - return; - } catch (error) { - const logFields = { - alert_id: alert.id, - retry_count: attempt, - error_code: getDeliveryErrorCode(error), - failure_reason: - error instanceof Error ? error.message : 'Unknown error', - masked_url: maskedUrl, - }; - - if (attempt === maxAttempts) { - logger.error( - { ...logFields, final: true }, - 'Price alert webhook delivery exhausted retries' - ); - throw error; - } - - logger.warn(logFields, 'Price alert webhook delivery failed'); - } - } + const newPrice = Number(tradeEvent.price); + if (!Number.isFinite(newPrice)) { + logger.warn( + { creatorId: tradeEvent.creatorId, price: tradeEvent.price }, + 'Skipping alert evaluation: trade price is not a finite number' + ); + return; + } + + const alerts = await prisma.alert.findMany({ + where: { + creatorId: tradeEvent.creatorId, + status: 'PENDING', + }, + }); + + if (alerts.length === 0) return; + + for (const alert of alerts) { + const targetPrice = Number((alert.targetPrice as Prisma.Decimal).toString()); + if (!crossesThreshold(alert.direction as DbDirection, newPrice, targetPrice)) { + continue; + } + + const payload: AlertTriggerPayload = { + creator_id: alert.creatorId, + triggered_price: tradeEvent.price, + target_price: (alert.targetPrice as Prisma.Decimal).toString(), + direction: fromDbDirection(alert.direction as DbDirection), + timestamp: tradeEvent.timestamp, + }; + + await deliverAlert(alert.id, alert.callbackUrl, payload).catch((err) => { + logger.error( + { alertId: alert.id, error: err instanceof Error ? err.message : String(err) }, + 'Alert delivery failed unexpectedly' + ); + }); + } } /** - * Evaluates active alerts for a creator price movement and delivers only alerts - * whose threshold was crossed in the registered direction. + * Delivers a triggered alert to its callback URL via HTTP POST. + * + * On success the alert is deleted (one-shot). On failure the delivery is retried + * up to `WEBHOOK_RETRY_MAX_ATTEMPTS` times with exponential backoff (with + * jitter); after the final failure the alert is marked FAILED. */ -export async function evaluatePriceAlertsForMovement( - movement: PriceMovement +async function deliverAlert( + alertId: string, + callbackUrl: string, + payload: AlertTriggerPayload, + attempt = 1 ): Promise { - try { - const previousPrice = toNumber(movement.previousPrice); - const currentPrice = toNumber(movement.currentPrice); - - const alerts = await prisma.priceAlert.findMany({ - where: { - creatorId: movement.creatorId, - isActive: true, - triggeredAt: null, - }, - }); - - for (const alert of alerts) { - const targetPrice = toNumber(alert.targetPrice); - const crossedAbove = - alert.direction === 'above' && - previousPrice < targetPrice && - currentPrice >= targetPrice; - const crossedBelow = - alert.direction === 'below' && - previousPrice > targetPrice && - currentPrice <= targetPrice; - - if (!crossedAbove && !crossedBelow) { - continue; - } - - await deliverPriceAlertWebhook(alert, { - event_type: 'price_alert', - alert_id: alert.id, - creator_id: alert.creatorId, - wallet_address: alert.walletAddress, - target_price: targetPrice, - current_price: currentPrice, - direction: alert.direction, - }); - - await prisma.priceAlert.update({ - where: { id: alert.id }, - data: { - isActive: false, - triggeredAt: new Date(), - }, - }); - } - } catch (err) { - logger.error( - { - creator_id: movement.creatorId, - ledger_sequence: movement.ledger_sequence, - new_price: movement.currentPrice, - error_message: err instanceof Error ? err.message : 'Unknown error', - failed_at: new Date().toISOString(), - }, - 'Price alert threshold check failed' + const maxAttempts = envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS; + const baseDelayMs = envConfig.WEBHOOK_RETRY_BASE_DELAY_MS; + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + const response = await fetch(callbackUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + clearTimeout(timeout); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + // One-shot: a successfully delivered alert is removed. + await prisma.alert.delete({ where: { id: alertId } }); + logger.info({ alertId, attempt }, 'Alert delivered and deleted'); + return; + } catch (error) { + const errMsg = error instanceof Error ? error.message : 'Unknown error'; + + await prisma.alert.update({ + where: { id: alertId }, + data: { retryCount: attempt, lastError: errMsg }, + }); + + if (attempt < maxAttempts) { + const delay = Math.pow(2, attempt - 1) * baseDelayMs; + logger.warn( + { alertId, attempt, maxAttempts, nextRetryMs: delay, error: errMsg }, + 'Alert delivery failed, retrying' ); - throw err; - } + await new Promise((resolve) => setTimeout(resolve, delay)); + return deliverAlert(alertId, callbackUrl, payload, attempt + 1); + } + + await prisma.alert.update({ + where: { id: alertId }, + data: { status: 'FAILED', retryCount: attempt, triggeredAt: new Date() }, + }); + + logger.error( + { alertId, attempt, maxAttempts, error: errMsg }, + 'Alert delivery exhausted all retries, marked failed' + ); + } } diff --git a/src/modules/alerts/alert.types.ts b/src/modules/alerts/alert.types.ts new file mode 100644 index 0000000..ba5a7c7 --- /dev/null +++ b/src/modules/alerts/alert.types.ts @@ -0,0 +1,42 @@ +export type AlertDirectionName = 'above' | 'below'; + +export interface CreateAlertInput { + creatorId: string; + walletAddress: string; + targetPrice: string; + direction: AlertDirectionName; + callbackUrl: string; +} + +export interface AlertResponse { + id: string; + creatorId: string; + walletAddress: string; + targetPrice: string; + direction: AlertDirectionName; + callbackUrl: string; + status: 'pending' | 'triggered' | 'failed'; + createdAt: Date; +} + +export interface AlertTriggerPayload { + creator_id: string; + triggered_price: string; + target_price: string; + direction: AlertDirectionName; + timestamp: string; +} + +/** + * Minimal trade-event shape required to evaluate price-alert thresholds. + * + * Mirrors the relevant fields of the trade-webhook `TradeEvent` so alert + * evaluation can be wired into the same indexer trade-event processing path. + */ +export interface AlertTradeEvent { + creatorId: string; + /** The new key price after the trade, as a decimal string. */ + price: string; + /** ISO-8601 timestamp of the trade. */ + timestamp: string; +} diff --git a/src/modules/alerts/index.ts b/src/modules/alerts/index.ts new file mode 100644 index 0000000..ed54cfa --- /dev/null +++ b/src/modules/alerts/index.ts @@ -0,0 +1,3 @@ +export { default as alertRouter } from './alert.router'; +export * from './alert.types'; +export { evaluateTradeForAlerts } from './alert.service'; diff --git a/src/modules/index.ts b/src/modules/index.ts index fa5cbc9..3c82d87 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -11,7 +11,7 @@ import activityRouter from './activity/activity.routes'; import ownershipRouter from './ownership/ownership.routes'; import webhookRouter from './webhooks/webhook.router'; import walletsRouter from './wallets/wallets.routes'; -import alertsRouter from './alerts/alert.router'; +import alertRouter from './alerts/alert.router'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; const router = Router(); @@ -26,8 +26,8 @@ router.use('/ledger', ledgerRouter); router.use('/admin', adminRouter); router.use('/activity', activityRouter); router.use('/ownership', ownershipRouter); +router.use('/alerts', alertRouter); router.use(CREATORS_BASE, webhookRouter); router.use('/wallets', walletsRouter); -router.use('/alerts', alertsRouter); export default router; diff --git a/src/utils/trade-event.utils.ts b/src/utils/trade-event.utils.ts new file mode 100644 index 0000000..48d803d --- /dev/null +++ b/src/utils/trade-event.utils.ts @@ -0,0 +1,41 @@ +import { logger } from './logger.utils'; +import { dispatchWebhookEvent } from '../modules/webhooks'; +import type { TradeEvent } from '../modules/webhooks'; +import { evaluateTradeForAlerts } from '../modules/alerts'; + +/** + * Single entry point for processing a creator-key trade event off the indexer. + * + * Fans out the trade event to every interested consumer: + * - trade webhooks registered by the creator (buy/sell notifications) + * - price-threshold alerts registered by fans (one-shot price-cross alerts) + * + * Each consumer is isolated: a failure in one does not prevent the other from + * running, so a webhook delivery problem never suppresses a price alert (and + * vice-versa). + */ +export async function processTradeEvent(tradeEvent: TradeEvent): Promise { + const results = await Promise.allSettled([ + dispatchWebhookEvent(tradeEvent), + evaluateTradeForAlerts({ + creatorId: tradeEvent.creatorId, + price: tradeEvent.price, + timestamp: tradeEvent.timestamp, + }), + ]); + + for (const result of results) { + if (result.status === 'rejected') { + logger.error( + { + creatorId: tradeEvent.creatorId, + error: + result.reason instanceof Error + ? result.reason.message + : String(result.reason), + }, + 'Trade-event consumer failed' + ); + } + } +}