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
22 changes: 12 additions & 10 deletions src/modules/alerts/__tests__/alert.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe('createAlert', () => {
direction: DB_ALERT.direction,
target_price: DB_ALERT.targetPrice,
registered_at: DB_ALERT.createdAt,
wallet_address: 'GAAA***AAAA',
wallet_address: 'GAAA…AAAA',
}),
'Price alert registered'
);
Expand Down Expand Up @@ -137,7 +137,7 @@ describe('deleteAlert', () => {
const result = await deleteAlert('alert-1', VALID_ADDRESS);

expect(mockedPrisma.priceAlert.findFirst).toHaveBeenCalledWith({
where: { id: 'alert-1', walletAddress: VALID_ADDRESS },
where: { id: 'alert-1' },
});
expect(mockedPrisma.priceAlert.delete).toHaveBeenCalledWith({
where: { id: 'alert-1' },
Expand All @@ -149,7 +149,7 @@ describe('deleteAlert', () => {
alert_id: DB_ALERT.id,
creator_id: DB_ALERT.creatorId,
cancelled_at: expect.any(Date),
wallet_address: 'GAAA***AAAA',
wallet_address: 'GAAA…AAAA',
}),
'Price alert cancelled'
);
Expand All @@ -168,12 +168,14 @@ describe('deleteAlert', () => {
});

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();
(mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(DB_ALERT);

await expect(
deleteAlert(
'alert-1',
'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'
)
).rejects.toThrow('You do not have permission to delete this alert');
expect(mockedPrisma.priceAlert.delete).not.toHaveBeenCalled();
});
});
294 changes: 294 additions & 0 deletions src/modules/alerts/__tests__/alert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
import express from 'express';
import request from 'supertest';
import alertsRouter from '../alert.router';
import { prisma } from '../../../utils/prisma.utils';
import { signWalletAccessToken } from '../../../utils/jwt.utils';

jest.mock('../../../utils/prisma.utils', () => ({
prisma: {
priceAlert: {
create: jest.fn(),
findMany: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
}));

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

const mockedPrisma = prisma as jest.Mocked<typeof prisma>;

const WALLET_A = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const WALLET_B = 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';

const app = express();
app.use(express.json());
app.use('/api/v1/alerts', alertsRouter);

describe('Price Alerts Endpoints (#867)', () => {
let tokenA: string;
let tokenB: string;

beforeAll(() => {
tokenA = signWalletAccessToken(WALLET_A);
tokenB = signWalletAccessToken(WALLET_B);
});

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

describe('POST /api/v1/alerts', () => {
it('returns 401 when no authorization header is sent', async () => {
const res = await request(app)
.post('/api/v1/alerts')
.send({
keyId: 'creator-1',
targetPrice: 100,
direction: 'above',
});

expect(res.status).toBe(401);
expect(res.body.success).toBe(false);
expect(mockedPrisma.priceAlert.create).not.toHaveBeenCalled();
});

it('creates an alert for the authenticated wallet', async () => {
const mockCreated = {
id: 'alert-1',
creatorId: 'creator-1',
walletAddress: WALLET_A,
targetPrice: 100,
direction: 'above',
callbackUrl: 'https://accesslayer.org/webhooks/alerts',
isActive: true,
triggeredAt: null,
createdAt: new Date(),
};
(mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(null);
(mockedPrisma.priceAlert.create as jest.Mock).mockResolvedValue(mockCreated);

const res = await request(app)
.post('/api/v1/alerts')
.set('Authorization', `Bearer ${tokenA}`)
.send({
keyId: 'creator-1',
targetPrice: 100,
direction: 'above',
});

expect(res.status).toBe(201);
expect(res.body.success).toBe(true);
expect(res.body.data.id).toBe('alert-1');
expect(mockedPrisma.priceAlert.create).toHaveBeenCalledWith({
data: expect.objectContaining({
creatorId: 'creator-1',
walletAddress: WALLET_A,
targetPrice: 100,
direction: 'above',
}),
});
});

it('returns 400 when targetPrice is not positive', async () => {
const res = await request(app)
.post('/api/v1/alerts')
.set('Authorization', `Bearer ${tokenA}`)
.send({
keyId: 'creator-1',
targetPrice: -10,
direction: 'above',
});

expect(res.status).toBe(400);
expect(mockedPrisma.priceAlert.create).not.toHaveBeenCalled();
});

it('returns 400 when direction is invalid', async () => {
const res = await request(app)
.post('/api/v1/alerts')
.set('Authorization', `Bearer ${tokenA}`)
.send({
keyId: 'creator-1',
targetPrice: 100,
direction: 'sideways',
});

expect(res.status).toBe(400);
expect(mockedPrisma.priceAlert.create).not.toHaveBeenCalled();
});

it('returns 409 when identical active alert already exists', async () => {
(mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue({
id: 'existing-alert',
});

const res = await request(app)
.post('/api/v1/alerts')
.set('Authorization', `Bearer ${tokenA}`)
.send({
keyId: 'creator-1',
targetPrice: 100,
direction: 'above',
});

expect(res.status).toBe(409);
expect(mockedPrisma.priceAlert.create).not.toHaveBeenCalled();
});
});

describe('GET /api/v1/alerts', () => {
it('returns 401 when unauthenticated', async () => {
const res = await request(app).get('/api/v1/alerts');
expect(res.status).toBe(401);
});

it('returns active alerts for caller wallet', async () => {
const alerts = [
{
id: 'alert-1',
creatorId: 'creator-1',
walletAddress: WALLET_A,
targetPrice: 100,
direction: 'above',
isActive: true,
},
];
(mockedPrisma.priceAlert.findMany as jest.Mock).mockResolvedValue(alerts);

const res = await request(app)
.get('/api/v1/alerts')
.set('Authorization', `Bearer ${tokenA}`);

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.items).toHaveLength(1);
expect(mockedPrisma.priceAlert.findMany).toHaveBeenCalledWith({
where: { walletAddress: WALLET_A, isActive: true },
orderBy: { createdAt: 'desc' },
});
});
});

describe('PATCH /api/v1/alerts/:alertId/triggered', () => {
it('returns 401 when unauthenticated', async () => {
const res = await request(app).patch('/api/v1/alerts/alert-1/triggered');
expect(res.status).toBe(401);
});

it('returns 404 when alert does not exist', async () => {
(mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(null);

const res = await request(app)
.patch('/api/v1/alerts/nonexistent/triggered')
.set('Authorization', `Bearer ${tokenA}`);

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

it('returns 403 when caller is not the owner of the alert', async () => {
(mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue({
id: 'alert-1',
walletAddress: WALLET_A, // Owned by WALLET_A
isActive: true,
});

// WALLET_B attempts to trigger WALLET_A's alert
const res = await request(app)
.patch('/api/v1/alerts/alert-1/triggered')
.set('Authorization', `Bearer ${tokenB}`);

expect(res.status).toBe(403);
expect(mockedPrisma.priceAlert.update).not.toHaveBeenCalled();
});

it('successfully marks alert as triggered when caller is owner', async () => {
(mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue({
id: 'alert-1',
walletAddress: WALLET_A,
isActive: true,
});
(mockedPrisma.priceAlert.update as jest.Mock).mockResolvedValue({
id: 'alert-1',
walletAddress: WALLET_A,
isActive: false,
triggeredAt: new Date(),
});

const res = await request(app)
.patch('/api/v1/alerts/alert-1/triggered')
.set('Authorization', `Bearer ${tokenA}`);

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(mockedPrisma.priceAlert.update).toHaveBeenCalledWith({
where: { id: 'alert-1' },
data: expect.objectContaining({
isActive: false,
triggeredAt: expect.any(Date),
}),
});
});
});

describe('DELETE /api/v1/alerts/:alertId', () => {
it('returns 401 when unauthenticated', async () => {
const res = await request(app).delete('/api/v1/alerts/alert-1');
expect(res.status).toBe(401);
});

it('returns 404 when alert does not exist', async () => {
(mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(null);

const res = await request(app)
.delete('/api/v1/alerts/nonexistent')
.set('Authorization', `Bearer ${tokenA}`);

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

it('returns 403 when caller is not the owner of the alert', async () => {
(mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue({
id: 'alert-1',
walletAddress: WALLET_A, // Owned by WALLET_A
});

// WALLET_B attempts to delete WALLET_A's alert
const res = await request(app)
.delete('/api/v1/alerts/alert-1')
.set('Authorization', `Bearer ${tokenB}`);

expect(res.status).toBe(403);
expect(mockedPrisma.priceAlert.delete).not.toHaveBeenCalled();
});

it('successfully deletes alert when caller is owner', async () => {
(mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue({
id: 'alert-1',
walletAddress: WALLET_A,
});
(mockedPrisma.priceAlert.delete as jest.Mock).mockResolvedValue({
id: 'alert-1',
});

const res = await request(app)
.delete('/api/v1/alerts/alert-1')
.set('Authorization', `Bearer ${tokenA}`);

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toEqual({ id: 'alert-1' });
expect(mockedPrisma.priceAlert.delete).toHaveBeenCalledWith({
where: { id: 'alert-1' },
});
});
});
});
Loading
Loading