Skip to content

Commit f736d65

Browse files
authored
Merge pull request #454 from pre-cious-Igwealor/feat/wallet-holdings-alerts-validation-421-423-447
feat(api): wallet holdings endpoint, price alert webhooks, Stellar address validation
2 parents c0ac0fe + 826e9da commit f736d65

14 files changed

Lines changed: 753 additions & 0 deletions

prisma/schema/alert.prisma

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// prisma/schema/alert.prisma
2+
3+
model PriceAlert {
4+
id String @id @default(cuid())
5+
creatorId String
6+
walletAddress String
7+
targetPrice Decimal
8+
direction String // "above" | "below"
9+
callbackUrl String
10+
isActive Boolean @default(true)
11+
triggeredAt DateTime?
12+
createdAt DateTime @default(now())
13+
14+
@@index([creatorId])
15+
@@index([walletAddress])
16+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Unit tests for alert.service.ts (#423)
2+
//
3+
// Covers: createAlert, listAlerts, deleteAlert.
4+
// Uses Jest mocks for prisma — no database required.
5+
6+
import { createAlert, listAlerts, deleteAlert } from '../alert.service';
7+
import { prisma } from '../../../utils/prisma.utils';
8+
9+
jest.mock('../../../utils/prisma.utils', () => ({
10+
prisma: {
11+
priceAlert: {
12+
create: jest.fn(),
13+
findMany: jest.fn(),
14+
findFirst: jest.fn(),
15+
delete: jest.fn(),
16+
},
17+
},
18+
}));
19+
20+
const mockedPrisma = prisma as jest.Mocked<typeof prisma>;
21+
22+
const VALID_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
23+
24+
const BASE_INPUT = {
25+
creator_id: 'creator-1',
26+
wallet_address: VALID_ADDRESS,
27+
target_price: 100,
28+
direction: 'above' as const,
29+
callback_url: 'https://example.com/callback',
30+
};
31+
32+
const DB_ALERT = {
33+
id: 'alert-1',
34+
creatorId: 'creator-1',
35+
walletAddress: VALID_ADDRESS,
36+
targetPrice: 100,
37+
direction: 'above',
38+
callbackUrl: 'https://example.com/callback',
39+
isActive: true,
40+
triggeredAt: null,
41+
createdAt: new Date('2026-01-01T00:00:00Z'),
42+
};
43+
44+
describe('createAlert', () => {
45+
afterEach(() => jest.clearAllMocks());
46+
47+
it('calls prisma.priceAlert.create with correct data', async () => {
48+
(mockedPrisma.priceAlert.create as jest.Mock).mockResolvedValue(DB_ALERT);
49+
50+
const result = await createAlert(BASE_INPUT);
51+
52+
expect(mockedPrisma.priceAlert.create).toHaveBeenCalledWith({
53+
data: {
54+
creatorId: 'creator-1',
55+
walletAddress: VALID_ADDRESS,
56+
targetPrice: 100,
57+
direction: 'above',
58+
callbackUrl: 'https://example.com/callback',
59+
},
60+
});
61+
expect(result).toEqual(DB_ALERT);
62+
});
63+
64+
it('creates a below-direction alert', async () => {
65+
const input = { ...BASE_INPUT, direction: 'below' as const, target_price: 50 };
66+
(mockedPrisma.priceAlert.create as jest.Mock).mockResolvedValue({
67+
...DB_ALERT,
68+
direction: 'below',
69+
targetPrice: 50,
70+
});
71+
72+
const result = await createAlert(input);
73+
expect(result.direction).toBe('below');
74+
});
75+
});
76+
77+
describe('listAlerts', () => {
78+
afterEach(() => jest.clearAllMocks());
79+
80+
it('returns active alerts for a wallet address', async () => {
81+
(mockedPrisma.priceAlert.findMany as jest.Mock).mockResolvedValue([DB_ALERT]);
82+
83+
const result = await listAlerts(VALID_ADDRESS);
84+
85+
expect(mockedPrisma.priceAlert.findMany).toHaveBeenCalledWith({
86+
where: { walletAddress: VALID_ADDRESS, isActive: true },
87+
orderBy: { createdAt: 'desc' },
88+
});
89+
expect(result).toHaveLength(1);
90+
expect(result[0].id).toBe('alert-1');
91+
});
92+
93+
it('returns empty array when no alerts exist', async () => {
94+
(mockedPrisma.priceAlert.findMany as jest.Mock).mockResolvedValue([]);
95+
96+
const result = await listAlerts(VALID_ADDRESS);
97+
expect(result).toEqual([]);
98+
});
99+
});
100+
101+
describe('deleteAlert', () => {
102+
afterEach(() => jest.clearAllMocks());
103+
104+
it('deletes the alert and returns its id when found', async () => {
105+
(mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(DB_ALERT);
106+
(mockedPrisma.priceAlert.delete as jest.Mock).mockResolvedValue(DB_ALERT);
107+
108+
const result = await deleteAlert('alert-1', VALID_ADDRESS);
109+
110+
expect(mockedPrisma.priceAlert.findFirst).toHaveBeenCalledWith({
111+
where: { id: 'alert-1', walletAddress: VALID_ADDRESS },
112+
});
113+
expect(mockedPrisma.priceAlert.delete).toHaveBeenCalledWith({
114+
where: { id: 'alert-1' },
115+
});
116+
expect(result).toEqual({ id: 'alert-1' });
117+
});
118+
119+
it('returns null when the alert is not found', async () => {
120+
(mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(null);
121+
122+
const result = await deleteAlert('nonexistent', VALID_ADDRESS);
123+
124+
expect(result).toBeNull();
125+
expect(mockedPrisma.priceAlert.delete).not.toHaveBeenCalled();
126+
});
127+
128+
it('does not delete an alert belonging to a different wallet address', async () => {
129+
(mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(null);
130+
131+
const result = await deleteAlert('alert-1', 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB');
132+
expect(result).toBeNull();
133+
});
134+
});
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { Request, Response, NextFunction } from 'express';
2+
import {
3+
CreateAlertSchema,
4+
ListAlertsQuerySchema,
5+
AlertParamsSchema,
6+
DeleteAlertBodySchema,
7+
} from './alert.schemas';
8+
import { createAlert, listAlerts, deleteAlert } from './alert.service';
9+
import {
10+
sendSuccess,
11+
sendValidationError,
12+
sendNotFound,
13+
} from '../../utils/api-response.utils';
14+
15+
/**
16+
* POST /api/v1/alerts
17+
* Register a new price alert.
18+
*/
19+
export async function httpCreateAlert(
20+
req: Request,
21+
res: Response,
22+
next: NextFunction
23+
): Promise<void> {
24+
try {
25+
const parsed = CreateAlertSchema.safeParse(req.body);
26+
if (!parsed.success) {
27+
sendValidationError(
28+
res,
29+
'Invalid alert input',
30+
parsed.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({
31+
field: issue.path.join('.'),
32+
message: issue.message,
33+
}))
34+
);
35+
return;
36+
}
37+
38+
const alert = await createAlert(parsed.data);
39+
sendSuccess(res, alert, 201);
40+
} catch (error) {
41+
next(error);
42+
}
43+
}
44+
45+
/**
46+
* GET /api/v1/alerts?wallet_address=...
47+
* List all active price alerts for a wallet address.
48+
*/
49+
export async function httpListAlerts(
50+
req: Request,
51+
res: Response,
52+
next: NextFunction
53+
): Promise<void> {
54+
try {
55+
const parsed = ListAlertsQuerySchema.safeParse(req.query);
56+
if (!parsed.success) {
57+
sendValidationError(
58+
res,
59+
'Invalid query parameters',
60+
parsed.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({
61+
field: issue.path.join('.'),
62+
message: issue.message,
63+
}))
64+
);
65+
return;
66+
}
67+
68+
const alerts = await listAlerts(parsed.data.wallet_address);
69+
sendSuccess(res, { items: alerts, total: alerts.length });
70+
} catch (error) {
71+
next(error);
72+
}
73+
}
74+
75+
/**
76+
* DELETE /api/v1/alerts/:id
77+
* Delete a price alert by id, scoped to the wallet address in the request body.
78+
*/
79+
export async function httpDeleteAlert(
80+
req: Request,
81+
res: Response,
82+
next: NextFunction
83+
): Promise<void> {
84+
try {
85+
const parsedParams = AlertParamsSchema.safeParse(req.params);
86+
if (!parsedParams.success) {
87+
sendValidationError(
88+
res,
89+
'Invalid alert id',
90+
parsedParams.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({
91+
field: issue.path.join('.'),
92+
message: issue.message,
93+
}))
94+
);
95+
return;
96+
}
97+
98+
const parsedBody = DeleteAlertBodySchema.safeParse(req.body);
99+
if (!parsedBody.success) {
100+
sendValidationError(
101+
res,
102+
'Invalid request body',
103+
parsedBody.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({
104+
field: issue.path.join('.'),
105+
message: issue.message,
106+
}))
107+
);
108+
return;
109+
}
110+
111+
const result = await deleteAlert(parsedParams.data.id, parsedBody.data.wallet_address);
112+
113+
if (!result) {
114+
sendNotFound(res, 'Alert');
115+
return;
116+
}
117+
118+
sendSuccess(res, result);
119+
} catch (error) {
120+
next(error);
121+
}
122+
}

src/modules/alerts/alert.router.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Router } from 'express';
2+
import { httpCreateAlert, httpListAlerts, httpDeleteAlert } from './alert.controllers';
3+
4+
const alertsRouter = Router();
5+
6+
/**
7+
* POST /api/v1/alerts
8+
* Register a new price alert for a creator key price threshold.
9+
*/
10+
alertsRouter.post('/', httpCreateAlert);
11+
12+
/**
13+
* GET /api/v1/alerts?wallet_address=...
14+
* List all active price alerts for the given Stellar wallet address.
15+
*/
16+
alertsRouter.get('/', httpListAlerts);
17+
18+
/**
19+
* DELETE /api/v1/alerts/:id
20+
* Delete a price alert by id (wallet_address required in body for authorization).
21+
*/
22+
alertsRouter.delete('/:id', httpDeleteAlert);
23+
24+
export default alertsRouter;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { z } from 'zod';
2+
import { isValidStellarAddress } from '../wallet/wallet.utils';
3+
4+
export const CreateAlertSchema = z.object({
5+
creator_id: z.string().min(1, 'creator_id is required'),
6+
wallet_address: z
7+
.string()
8+
.refine(isValidStellarAddress, { message: 'Invalid Stellar wallet address' }),
9+
target_price: z
10+
.number({ invalid_type_error: 'target_price must be a number' })
11+
.positive('target_price must be positive'),
12+
direction: z.enum(['above', 'below'], {
13+
errorMap: () => ({ message: "direction must be 'above' or 'below'" }),
14+
}),
15+
callback_url: z.string().url('callback_url must be a valid URL'),
16+
});
17+
18+
export type CreateAlertInput = z.infer<typeof CreateAlertSchema>;
19+
20+
export const ListAlertsQuerySchema = z.object({
21+
wallet_address: z
22+
.string()
23+
.refine(isValidStellarAddress, { message: 'Invalid Stellar wallet address' }),
24+
});
25+
26+
export type ListAlertsQueryType = z.infer<typeof ListAlertsQuerySchema>;
27+
28+
export const AlertParamsSchema = z.object({
29+
id: z.string().min(1, 'Alert id is required'),
30+
});
31+
32+
export const DeleteAlertBodySchema = z.object({
33+
wallet_address: z
34+
.string()
35+
.refine(isValidStellarAddress, { message: 'Invalid Stellar wallet address' }),
36+
});
37+
38+
export type DeleteAlertBodyType = z.infer<typeof DeleteAlertBodySchema>;
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { prisma } from '../../utils/prisma.utils';
2+
import { CreateAlertInput } from './alert.schemas';
3+
4+
/**
5+
* Creates a new price alert for a wallet address watching a creator's key price.
6+
*/
7+
export async function createAlert(input: CreateAlertInput) {
8+
return await prisma.priceAlert.create({
9+
data: {
10+
creatorId: input.creator_id,
11+
walletAddress: input.wallet_address,
12+
targetPrice: input.target_price,
13+
direction: input.direction,
14+
callbackUrl: input.callback_url,
15+
},
16+
});
17+
}
18+
19+
/**
20+
* Lists all active price alerts for a given wallet address.
21+
*/
22+
export async function listAlerts(walletAddress: string) {
23+
return await prisma.priceAlert.findMany({
24+
where: { walletAddress, isActive: true },
25+
orderBy: { createdAt: 'desc' },
26+
});
27+
}
28+
29+
/**
30+
* Deletes a price alert by id, scoped to the wallet address for authorization.
31+
* Returns the deleted record id or null if not found.
32+
*/
33+
export async function deleteAlert(
34+
id: string,
35+
walletAddress: string
36+
): Promise<{ id: string } | null> {
37+
const existing = await prisma.priceAlert.findFirst({
38+
where: { id, walletAddress },
39+
});
40+
41+
if (!existing) {
42+
return null;
43+
}
44+
45+
await prisma.priceAlert.delete({ where: { id } });
46+
return { id };
47+
}

0 commit comments

Comments
 (0)