Skip to content

Commit ccb48e5

Browse files
authored
Merge pull request #177 from MeshJS/feature/api-transaction-fetching-and-signing
Feature/api-transaction-fetching-and-signing
2 parents f04a112 + 48aed94 commit ccb48e5

File tree

5 files changed

+1493
-95
lines changed

5 files changed

+1493
-95
lines changed
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals';
2+
import type { NextApiRequest, NextApiResponse } from 'next';
3+
4+
const addCorsCacheBustingHeadersMock = jest.fn<(res: NextApiResponse) => void>();
5+
const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise<void>>();
6+
7+
jest.mock(
8+
'@/lib/cors',
9+
() => ({
10+
__esModule: true,
11+
addCorsCacheBustingHeaders: addCorsCacheBustingHeadersMock,
12+
cors: corsMock,
13+
}),
14+
{ virtual: true },
15+
);
16+
17+
const verifyJwtMock = jest.fn<(token: string | undefined) => { address: string } | null>();
18+
19+
jest.mock(
20+
'@/lib/verifyJwt',
21+
() => ({
22+
__esModule: true,
23+
verifyJwt: verifyJwtMock,
24+
}),
25+
{ virtual: true },
26+
);
27+
28+
const createCallerMock = jest.fn();
29+
30+
jest.mock(
31+
'@/server/api/root',
32+
() => ({
33+
__esModule: true,
34+
createCaller: createCallerMock,
35+
}),
36+
{ virtual: true },
37+
);
38+
39+
const dbMock = { __type: 'dbMock' };
40+
41+
jest.mock(
42+
'@/server/db',
43+
() => ({
44+
__esModule: true,
45+
db: dbMock,
46+
}),
47+
{ virtual: true },
48+
);
49+
50+
type ResponseMock = NextApiResponse & { statusCode?: number };
51+
52+
function createMockResponse(): ResponseMock {
53+
const res = {
54+
statusCode: undefined as number | undefined,
55+
status: jest.fn<(code: number) => NextApiResponse>(),
56+
json: jest.fn<(payload: unknown) => unknown>(),
57+
end: jest.fn<() => void>(),
58+
setHeader: jest.fn<(name: string, value: string) => void>(),
59+
};
60+
61+
res.status.mockImplementation((code: number) => {
62+
res.statusCode = code;
63+
return res as unknown as NextApiResponse;
64+
});
65+
66+
res.json.mockImplementation((payload: unknown) => payload);
67+
68+
return res as unknown as ResponseMock;
69+
}
70+
71+
const walletGetWalletMock = jest.fn<(args: unknown) => Promise<unknown>>();
72+
const transactionGetPendingTransactionsMock = jest.fn<(args: unknown) => Promise<unknown>>();
73+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
74+
75+
let handler: (req: NextApiRequest, res: NextApiResponse) => Promise<void | NextApiResponse>;
76+
77+
beforeAll(async () => {
78+
({ default: handler } = await import('../pages/api/v1/pendingTransactions'));
79+
});
80+
81+
beforeEach(() => {
82+
jest.clearAllMocks();
83+
84+
walletGetWalletMock.mockReset();
85+
transactionGetPendingTransactionsMock.mockReset();
86+
verifyJwtMock.mockReset();
87+
createCallerMock.mockReset();
88+
addCorsCacheBustingHeadersMock.mockReset();
89+
corsMock.mockReset();
90+
91+
corsMock.mockResolvedValue(undefined);
92+
addCorsCacheBustingHeadersMock.mockImplementation(() => {
93+
// no-op
94+
});
95+
96+
createCallerMock.mockReturnValue({
97+
wallet: { getWallet: walletGetWalletMock },
98+
transaction: { getPendingTransactions: transactionGetPendingTransactionsMock },
99+
});
100+
});
101+
102+
afterEach(() => {
103+
consoleErrorSpy.mockClear();
104+
});
105+
106+
afterAll(() => {
107+
consoleErrorSpy.mockRestore();
108+
});
109+
110+
describe('pendingTransactions API route', () => {
111+
it('handles OPTIONS preflight requests', async () => {
112+
const req = {
113+
method: 'OPTIONS',
114+
headers: { authorization: 'Bearer token' },
115+
query: {},
116+
} as unknown as NextApiRequest;
117+
const res = createMockResponse();
118+
119+
await handler(req, res);
120+
121+
expect(addCorsCacheBustingHeadersMock).toHaveBeenCalledWith(res);
122+
expect(corsMock).toHaveBeenCalledWith(req, res);
123+
expect(res.status).toHaveBeenCalledWith(200);
124+
// eslint-disable-next-line @typescript-eslint/unbound-method
125+
expect(res.end).toHaveBeenCalled();
126+
});
127+
128+
it('returns 405 for unsupported methods', async () => {
129+
const req = {
130+
method: 'POST',
131+
headers: { authorization: 'Bearer token' },
132+
query: {},
133+
} as unknown as NextApiRequest;
134+
const res = createMockResponse();
135+
136+
await handler(req, res);
137+
138+
expect(res.status).toHaveBeenCalledWith(405);
139+
expect(res.json).toHaveBeenCalledWith({ error: 'Method Not Allowed' });
140+
});
141+
142+
it('returns pending transactions for valid request', async () => {
143+
const address = 'addr_test1qpvalidaddress';
144+
const walletId = 'wallet-valid';
145+
const token = 'valid-token';
146+
const pendingTransactions = [{ id: 'tx-1' }, { id: 'tx-2' }];
147+
148+
verifyJwtMock.mockReturnValue({ address });
149+
walletGetWalletMock.mockResolvedValue({
150+
id: walletId,
151+
signersAddresses: [address],
152+
});
153+
transactionGetPendingTransactionsMock.mockResolvedValue(pendingTransactions);
154+
155+
const req = {
156+
method: 'GET',
157+
headers: { authorization: `Bearer ${token}` },
158+
query: { walletId, address },
159+
} as unknown as NextApiRequest;
160+
const res = createMockResponse();
161+
162+
await handler(req, res);
163+
164+
expect(addCorsCacheBustingHeadersMock).toHaveBeenCalledWith(res);
165+
expect(corsMock).toHaveBeenCalledWith(req, res);
166+
expect(verifyJwtMock).toHaveBeenCalledWith(token);
167+
expect(createCallerMock).toHaveBeenCalledWith({
168+
db: dbMock,
169+
session: expect.objectContaining({
170+
user: { id: address },
171+
expires: expect.any(String),
172+
}),
173+
});
174+
expect(walletGetWalletMock).toHaveBeenCalledWith({ walletId, address });
175+
expect(transactionGetPendingTransactionsMock).toHaveBeenCalledWith({ walletId });
176+
expect(res.status).toHaveBeenCalledWith(200);
177+
expect(res.json).toHaveBeenCalledWith(pendingTransactions);
178+
});
179+
180+
it('returns 401 when authorization header is missing', async () => {
181+
const req = {
182+
method: 'GET',
183+
headers: {},
184+
query: { walletId: 'wallet', address: 'addr_test1qpmissingauth' },
185+
} as unknown as NextApiRequest;
186+
const res = createMockResponse();
187+
188+
await handler(req, res);
189+
190+
expect(res.status).toHaveBeenCalledWith(401);
191+
expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized - Missing token' });
192+
expect(verifyJwtMock).not.toHaveBeenCalled();
193+
expect(createCallerMock).not.toHaveBeenCalled();
194+
});
195+
196+
it('returns 401 when token verification fails', async () => {
197+
verifyJwtMock.mockReturnValue(null);
198+
199+
const req = {
200+
method: 'GET',
201+
headers: { authorization: 'Bearer invalid-token' },
202+
query: { walletId: 'wallet-id', address: 'addr_test1qpinvalidtoken' },
203+
} as unknown as NextApiRequest;
204+
const res = createMockResponse();
205+
206+
await handler(req, res);
207+
208+
expect(verifyJwtMock).toHaveBeenCalledWith('invalid-token');
209+
expect(res.status).toHaveBeenCalledWith(401);
210+
expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired token' });
211+
expect(createCallerMock).not.toHaveBeenCalled();
212+
});
213+
214+
it('returns 403 when JWT address mismatches query address', async () => {
215+
verifyJwtMock.mockReturnValue({ address: 'addr_test1qpjwtaddress' });
216+
217+
const req = {
218+
method: 'GET',
219+
headers: { authorization: 'Bearer mismatch-token' },
220+
query: {
221+
walletId: 'wallet-mismatch',
222+
address: 'addr_test1qpqueryaddress',
223+
},
224+
} as unknown as NextApiRequest;
225+
const res = createMockResponse();
226+
227+
await handler(req, res);
228+
229+
expect(res.status).toHaveBeenCalledWith(403);
230+
expect(res.json).toHaveBeenCalledWith({ error: 'Address mismatch' });
231+
expect(createCallerMock).not.toHaveBeenCalled();
232+
});
233+
234+
it('returns 400 when address parameter is invalid', async () => {
235+
verifyJwtMock.mockReturnValue({ address: 'addr_test1qpaddressparam' });
236+
237+
const req = {
238+
method: 'GET',
239+
headers: { authorization: 'Bearer token' },
240+
query: { walletId: 'wallet-id', address: ['addr'] },
241+
} as unknown as NextApiRequest;
242+
const res = createMockResponse();
243+
244+
await handler(req, res);
245+
246+
expect(res.status).toHaveBeenCalledWith(400);
247+
expect(res.json).toHaveBeenCalledWith({ error: 'Invalid address parameter' });
248+
});
249+
250+
it('returns 400 when walletId parameter is invalid', async () => {
251+
verifyJwtMock.mockReturnValue({ address: 'addr_test1qpwalletparam' });
252+
253+
const req = {
254+
method: 'GET',
255+
headers: { authorization: 'Bearer token' },
256+
query: { walletId: ['wallet'], address: 'addr_test1qpwalletparam' },
257+
} as unknown as NextApiRequest;
258+
const res = createMockResponse();
259+
260+
await handler(req, res);
261+
262+
expect(res.status).toHaveBeenCalledWith(400);
263+
expect(res.json).toHaveBeenCalledWith({ error: 'Invalid walletId parameter' });
264+
});
265+
266+
it('returns 404 when wallet is not found', async () => {
267+
const address = 'addr_test1qpwalletmissing';
268+
const walletId = 'wallet-missing';
269+
270+
verifyJwtMock.mockReturnValue({ address });
271+
walletGetWalletMock.mockResolvedValue(null);
272+
273+
const req = {
274+
method: 'GET',
275+
headers: { authorization: 'Bearer token' },
276+
query: { walletId, address },
277+
} as unknown as NextApiRequest;
278+
const res = createMockResponse();
279+
280+
await handler(req, res);
281+
282+
expect(walletGetWalletMock).toHaveBeenCalledWith({ walletId, address });
283+
expect(res.status).toHaveBeenCalledWith(404);
284+
expect(res.json).toHaveBeenCalledWith({ error: 'Wallet not found' });
285+
expect(transactionGetPendingTransactionsMock).not.toHaveBeenCalled();
286+
});
287+
288+
it('returns 500 when fetching pending transactions fails', async () => {
289+
const address = 'addr_test1qperrorcase';
290+
const walletId = 'wallet-error';
291+
const failure = new Error('database unavailable');
292+
293+
verifyJwtMock.mockReturnValue({ address });
294+
walletGetWalletMock.mockResolvedValue({ id: walletId });
295+
transactionGetPendingTransactionsMock.mockRejectedValue(failure);
296+
297+
const req = {
298+
method: 'GET',
299+
headers: { authorization: 'Bearer token' },
300+
query: { walletId, address },
301+
} as unknown as NextApiRequest;
302+
const res = createMockResponse();
303+
304+
await handler(req, res);
305+
306+
expect(transactionGetPendingTransactionsMock).toHaveBeenCalledWith({ walletId });
307+
expect(consoleErrorSpy).toHaveBeenCalled();
308+
expect(res.status).toHaveBeenCalledWith(500);
309+
expect(res.json).toHaveBeenCalledWith({ error: 'Internal Server Error' });
310+
});
311+
});
312+
313+

0 commit comments

Comments
 (0)