Skip to content

Commit 01eaba0

Browse files
Merge pull request #893 from Jessicaayegh/task/users-inttest-v7
Task/users inttest v7
2 parents 5701749 + 30084ac commit 01eaba0

5 files changed

Lines changed: 595 additions & 129 deletions

File tree

docs/integration-tests.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ This uses the `jest.preset.integration.js` preset which:
2020
3. Runs test files matching `tests/integration/**/*.test.ts`
2121
4. Stops and removes the container (global teardown)
2222

23+
## Suites
24+
25+
| File | Covers |
26+
|---|---|
27+
| `tests/integration/example.test.ts` | Pool configuration, raw SQL, Drizzle access to the migrated schema |
28+
| `tests/integration/users.test.ts` | `/api/users` end-to-end: `GET /me`, `GET /:address/predictions`, `GET /:addr/portfolio`, `GET /:address/profile` |
29+
30+
`users.test.ts` mounts `usersRouter` and `userPortfolioRouter` on a bare Express app in the same order as `src/index.ts` (plus the request-context middleware and the global error handler) and drives them through `supertest`. Nothing is mocked: JWTs are minted with the real `signAccessToken`, and every read hits the container database seeded through Drizzle. It asserts auth behaviour (403 for anonymous, forged, and orphaned-subject tokens), query validation, 404s, status filtering, keyset pagination (pages are disjoint and exhaustive; a tampered cursor restarts at page one) and cross-user isolation.
31+
32+
Modules under test open their own `pg.Pool` (`src/db/client` and `src/middleware/requireAuth`), so the suite subclasses `pg.Pool` to track and close every instance in `afterAll` — without that, idle clients keep the Jest worker alive after the tests finish.
33+
2334
## Writing integration tests
2435

2536
Place test files in `tests/integration/` with the `*.test.ts` extension.

jest.config.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,13 @@ module.exports = {
4646
statements: 90,
4747
},
4848
},
49-
// Separate E2E tests from unit tests
49+
// Separate E2E and Testcontainers-backed integration tests from unit tests.
50+
// Integration tests need the Postgres container started by
51+
// jest.integration.config.js — run them with `npm run test:integration`.
5052
testPathIgnorePatterns: [
5153
"/node_modules/",
5254
"/dist/",
55+
"/tests/integration/",
5356
],
5457
// Increase timeout for E2E tests
5558
testTimeout: 10000, // 10 seconds default, E2E tests override this

src/routes/users.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,20 @@ usersRouter.get(
326326
}
327327
const { address } = paramsParse.data;
328328

329+
// Validate and coerce query parameters with zod.
330+
{ reqId, stellarAddress: req.params.stellarAddress },
331+
"user_profile_validation_failed",
332+
);
333+
return res.status(400).json({
334+
error: {
335+
code: "validation_error",
336+
message: "invalid stellar address",
337+
requestId: reqId,
338+
},
339+
});
340+
}
341+
342+
// ── 2. Service call ──────────────────────────────────────────────────
329343
// Validate and coerce query parameters with zod.
330344
const queryParse = userPredictionsQuerySchema.safeParse(req.query);
331345
if (!queryParse.success) {
@@ -341,6 +355,64 @@ usersRouter.get(
341355
requestId: reqId,
342356
},
343357
});
358+
const { status, cursor, limit: rawLimit } = queryParse.data;
359+
// clampLimit is a belt-and-suspenders guard; zod already enforces 1–100.
360+
const limit = clampLimit(rawLimit);
361+
362+
logger.debug({ reqId, address, status, limit, hasCursor: !!cursor }, "predictions_request");
363+
364+
const user = await getUserByAddress(address);
365+
if (!user) {
366+
logger.debug({ reqId, address }, "predictions_user_not_found");
367+
return res.status(404).json({ error: { code: "not_found", requestId: reqId } });
368+
}
369+
370+
const page = await getUserPredictions(user.id, { status, limit, cursor });
371+
const user = await getUserByAddress(address);
372+
if (!user) {
373+
logger.debug({ reqId, address }, "predictions_user_not_found");
374+
return res.status(404).json({ error: { code: "not_found", requestId: reqId } });
375+
}
376+
377+
const page = await getUserPredictions(user.id, { status, limit, cursor });
378+
379+
logger.info(
380+
{ reqId, address, userId: user.id, count: page.data.length, hasNext: !!page.nextCursor },
381+
"predictions_page_served",
382+
);
383+
384+
return res.json({ data: page.data, nextCursor: page.nextCursor });
385+
} catch (e) {
386+
return next(e);
387+
}
388+
});
389+
390+
usersRouter.get(
391+
"/:stellarAddress/profile",
392+
async (req, res, next) => {
393+
const reqId = getRequestId();
394+
395+
const parseResult = stellarAddressSchema.safeParse(req.params.stellarAddress);
396+
if (!parseResult.success) {
397+
logger.warn(
398+
{ reqId, stellarAddress: req.params.stellarAddress, issues: parseResult.error.issues },
399+
"user_profile_validation_failed",
400+
);
401+
return next(
402+
RouteErrorFactory.badRequest(parseResult.error.issues[0]?.message ?? "invalid stellar address"),
403+
);
404+
}
405+
406+
const stellarAddress = parseResult.data;
407+
408+
try {
409+
logger.debug({ reqId, stellarAddress }, "user_profile_lookup");
410+
411+
const profile = await getUserProfile(stellarAddress);
412+
413+
if (!profile) {
414+
logger.debug({ reqId, stellarAddress }, "user_profile_not_found");
415+
throw RouteErrorFactory.notFound("no user found with that stellar address");
344416
}
345417

346418
const { status, cursor, limit: rawLimit } = queryParse.data;
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { and, eq } from "drizzle-orm";
2+
import { getDb } from "../db/client";
3+
import { claims, markets, predictions, users } from "../db/schema";
4+
5+
export interface PortfolioExportMarket {
6+
marketId: string;
7+
question: string;
8+
status: string;
9+
resolutionTime: string;
10+
outcome: string;
11+
predictions: number;
12+
totalStaked: string;
13+
claimable: string;
14+
latestPredictionAt: string;
15+
}
16+
17+
export interface PortfolioExportSummary {
18+
totalMarketsParticipated: number;
19+
totalPredictions: number;
20+
totalStaked: string;
21+
totalClaimable: string;
22+
outcomes: {
23+
won: number;
24+
lost: number;
25+
pending: number;
26+
confirmed: number;
27+
claimed: number;
28+
};
29+
}
30+
31+
export interface PortfolioExportSnapshot {
32+
version: 1;
33+
exportedAt: string;
34+
address: string;
35+
summary: PortfolioExportSummary;
36+
markets: PortfolioExportMarket[];
37+
}
38+
39+
function parseAmount(amount: string | null | undefined): bigint {
40+
if (!amount || !/^\d+$/.test(amount)) return 0n;
41+
return BigInt(amount);
42+
}
43+
44+
function addDecimalStrings(a: string, b: string): string {
45+
return (parseAmount(a) + parseAmount(b)).toString();
46+
}
47+
48+
export async function getPortfolioExport(address: string): Promise<PortfolioExportSnapshot | null> {
49+
const db = getDb();
50+
51+
const userRows = await db
52+
.select({ id: users.id, stellarAddress: users.stellarAddress })
53+
.from(users)
54+
.where(eq(users.stellarAddress, address))
55+
.limit(1);
56+
const user = userRows[0];
57+
if (!user) return null;
58+
59+
const [predictionRows, claimRows] = await Promise.all([
60+
db
61+
.select({
62+
id: predictions.id,
63+
marketId: predictions.marketId,
64+
question: markets.question,
65+
marketStatus: markets.status,
66+
resolutionTime: markets.resolutionTime,
67+
outcome: predictions.outcome,
68+
amount: predictions.amount,
69+
status: predictions.status,
70+
createdAt: predictions.createdAt,
71+
})
72+
.from(predictions)
73+
.innerJoin(markets, eq(predictions.marketId, markets.id))
74+
.where(eq(predictions.userId, user.id)),
75+
db
76+
.select({ marketId: claims.marketId, amount: claims.amount })
77+
.from(claims)
78+
.where(and(eq(claims.userId, user.id), eq(claims.status, "pending"))),
79+
]);
80+
81+
const claimableByMarket = new Map<string, string>();
82+
for (const row of claimRows) {
83+
claimableByMarket.set(
84+
row.marketId,
85+
addDecimalStrings(claimableByMarket.get(row.marketId) ?? "0", row.amount),
86+
);
87+
}
88+
89+
const byMarket = new Map<string, PortfolioExportMarket>();
90+
const summary: PortfolioExportSummary = {
91+
totalMarketsParticipated: 0,
92+
totalPredictions: 0,
93+
totalStaked: "0",
94+
totalClaimable: "0",
95+
outcomes: {
96+
won: 0,
97+
lost: 0,
98+
pending: 0,
99+
confirmed: 0,
100+
claimed: 0,
101+
},
102+
};
103+
104+
for (const row of predictionRows) {
105+
summary.totalPredictions += 1;
106+
summary.totalStaked = addDecimalStrings(summary.totalStaked, row.amount);
107+
108+
const status = row.status as keyof typeof summary.outcomes;
109+
if (status in summary.outcomes) {
110+
summary.outcomes[status] += 1;
111+
}
112+
113+
const createdAt = row.createdAt.toISOString();
114+
const existing = byMarket.get(row.marketId);
115+
if (existing) {
116+
existing.predictions += 1;
117+
existing.totalStaked = addDecimalStrings(existing.totalStaked, row.amount);
118+
if (createdAt > existing.latestPredictionAt) {
119+
existing.latestPredictionAt = createdAt;
120+
}
121+
} else {
122+
byMarket.set(row.marketId, {
123+
marketId: row.marketId,
124+
question: row.question,
125+
status: row.marketStatus,
126+
resolutionTime: row.resolutionTime.toISOString(),
127+
outcome: row.outcome,
128+
predictions: 1,
129+
totalStaked: row.amount,
130+
claimable: claimableByMarket.get(row.marketId) ?? "0",
131+
latestPredictionAt: createdAt,
132+
});
133+
}
134+
}
135+
136+
summary.totalMarketsParticipated = byMarket.size;
137+
for (const amount of claimableByMarket.values()) {
138+
summary.totalClaimable = addDecimalStrings(summary.totalClaimable, amount);
139+
}
140+
141+
return {
142+
version: 1,
143+
exportedAt: new Date().toISOString(),
144+
address: user.stellarAddress,
145+
summary,
146+
markets: [...byMarket.values()].sort((a, b) =>
147+
b.latestPredictionAt.localeCompare(a.latestPredictionAt),
148+
),
149+
};
150+
}

0 commit comments

Comments
 (0)