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
11 changes: 11 additions & 0 deletions docs/integration-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ This uses the `jest.preset.integration.js` preset which:
3. Runs test files matching `tests/integration/**/*.test.ts`
4. Stops and removes the container (global teardown)

## Suites

| File | Covers |
|---|---|
| `tests/integration/example.test.ts` | Pool configuration, raw SQL, Drizzle access to the migrated schema |
| `tests/integration/users.test.ts` | `/api/users` end-to-end: `GET /me`, `GET /:address/predictions`, `GET /:addr/portfolio`, `GET /:address/profile` |

`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.

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.

## Writing integration tests

Place test files in `tests/integration/` with the `*.test.ts` extension.
Expand Down
5 changes: 4 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,13 @@ module.exports = {
statements: 90,
},
},
// Separate E2E tests from unit tests
// Separate E2E and Testcontainers-backed integration tests from unit tests.
// Integration tests need the Postgres container started by
// jest.integration.config.js — run them with `npm run test:integration`.
testPathIgnorePatterns: [
"/node_modules/",
"/dist/",
"/tests/integration/",
],
// Increase timeout for E2E tests
testTimeout: 10000, // 10 seconds default, E2E tests override this
Expand Down
72 changes: 72 additions & 0 deletions src/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,20 @@ usersRouter.get(
});
}

// ── 2. Service call ──────────────────────────────────────────────────
// Validate and coerce query parameters with zod.
{ reqId, stellarAddress: req.params.stellarAddress },
"user_profile_validation_failed",
);
return res.status(400).json({
error: {
code: "validation_error",
message: "invalid stellar address",
requestId: reqId,
},
});
}

// ── 2. Service call ──────────────────────────────────────────────────
// Validate and coerce query parameters with zod.
const queryParse = userPredictionsQuerySchema.safeParse(req.query);
Expand All @@ -345,6 +359,64 @@ usersRouter.get(
requestId: reqId,
},
});
const { status, cursor, limit: rawLimit } = queryParse.data;
// clampLimit is a belt-and-suspenders guard; zod already enforces 1–100.
const limit = clampLimit(rawLimit);

logger.debug({ reqId, address, status, limit, hasCursor: !!cursor }, "predictions_request");

const user = await getUserByAddress(address);
if (!user) {
logger.debug({ reqId, address }, "predictions_user_not_found");
return res.status(404).json({ error: { code: "not_found", requestId: reqId } });
}

const page = await getUserPredictions(user.id, { status, limit, cursor });
const user = await getUserByAddress(address);
if (!user) {
logger.debug({ reqId, address }, "predictions_user_not_found");
return res.status(404).json({ error: { code: "not_found", requestId: reqId } });
}

const page = await getUserPredictions(user.id, { status, limit, cursor });

logger.info(
{ reqId, address, userId: user.id, count: page.data.length, hasNext: !!page.nextCursor },
"predictions_page_served",
);

return res.json({ data: page.data, nextCursor: page.nextCursor });
} catch (e) {
return next(e);
}
});

usersRouter.get(
"/:stellarAddress/profile",
async (req, res, next) => {
const reqId = getRequestId();

const parseResult = stellarAddressSchema.safeParse(req.params.stellarAddress);
if (!parseResult.success) {
logger.warn(
{ reqId, stellarAddress: req.params.stellarAddress, issues: parseResult.error.issues },
"user_profile_validation_failed",
);
return next(
RouteErrorFactory.badRequest(parseResult.error.issues[0]?.message ?? "invalid stellar address"),
);
}

const stellarAddress = parseResult.data;

try {
logger.debug({ reqId, stellarAddress }, "user_profile_lookup");

const profile = await getUserProfile(stellarAddress);

if (!profile) {
logger.debug({ reqId, stellarAddress }, "user_profile_not_found");
throw RouteErrorFactory.notFound("no user found with that stellar address");
}

const { status, cursor, limit: rawLimit } = queryParse.data;
Expand Down
150 changes: 150 additions & 0 deletions src/services/portfolioExportService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { and, eq } from "drizzle-orm";
import { getDb } from "../db/client";
import { claims, markets, predictions, users } from "../db/schema";

export interface PortfolioExportMarket {
marketId: string;
question: string;
status: string;
resolutionTime: string;
outcome: string;
predictions: number;
totalStaked: string;
claimable: string;
latestPredictionAt: string;
}

export interface PortfolioExportSummary {
totalMarketsParticipated: number;
totalPredictions: number;
totalStaked: string;
totalClaimable: string;
outcomes: {
won: number;
lost: number;
pending: number;
confirmed: number;
claimed: number;
};
}

export interface PortfolioExportSnapshot {
version: 1;
exportedAt: string;
address: string;
summary: PortfolioExportSummary;
markets: PortfolioExportMarket[];
}

function parseAmount(amount: string | null | undefined): bigint {
if (!amount || !/^\d+$/.test(amount)) return 0n;
return BigInt(amount);
}

function addDecimalStrings(a: string, b: string): string {
return (parseAmount(a) + parseAmount(b)).toString();
}

export async function getPortfolioExport(address: string): Promise<PortfolioExportSnapshot | null> {
const db = getDb();

const userRows = await db
.select({ id: users.id, stellarAddress: users.stellarAddress })
.from(users)
.where(eq(users.stellarAddress, address))
.limit(1);
const user = userRows[0];
if (!user) return null;

const [predictionRows, claimRows] = await Promise.all([
db
.select({
id: predictions.id,
marketId: predictions.marketId,
question: markets.question,
marketStatus: markets.status,
resolutionTime: markets.resolutionTime,
outcome: predictions.outcome,
amount: predictions.amount,
status: predictions.status,
createdAt: predictions.createdAt,
})
.from(predictions)
.innerJoin(markets, eq(predictions.marketId, markets.id))
.where(eq(predictions.userId, user.id)),
db
.select({ marketId: claims.marketId, amount: claims.amount })
.from(claims)
.where(and(eq(claims.userId, user.id), eq(claims.status, "pending"))),
]);

const claimableByMarket = new Map<string, string>();
for (const row of claimRows) {
claimableByMarket.set(
row.marketId,
addDecimalStrings(claimableByMarket.get(row.marketId) ?? "0", row.amount),
);
}

const byMarket = new Map<string, PortfolioExportMarket>();
const summary: PortfolioExportSummary = {
totalMarketsParticipated: 0,
totalPredictions: 0,
totalStaked: "0",
totalClaimable: "0",
outcomes: {
won: 0,
lost: 0,
pending: 0,
confirmed: 0,
claimed: 0,
},
};

for (const row of predictionRows) {
summary.totalPredictions += 1;
summary.totalStaked = addDecimalStrings(summary.totalStaked, row.amount);

const status = row.status as keyof typeof summary.outcomes;
if (status in summary.outcomes) {
summary.outcomes[status] += 1;
}

const createdAt = row.createdAt.toISOString();
const existing = byMarket.get(row.marketId);
if (existing) {
existing.predictions += 1;
existing.totalStaked = addDecimalStrings(existing.totalStaked, row.amount);
if (createdAt > existing.latestPredictionAt) {
existing.latestPredictionAt = createdAt;
}
} else {
byMarket.set(row.marketId, {
marketId: row.marketId,
question: row.question,
status: row.marketStatus,
resolutionTime: row.resolutionTime.toISOString(),
outcome: row.outcome,
predictions: 1,
totalStaked: row.amount,
claimable: claimableByMarket.get(row.marketId) ?? "0",
latestPredictionAt: createdAt,
});
}
}

summary.totalMarketsParticipated = byMarket.size;
for (const amount of claimableByMarket.values()) {
summary.totalClaimable = addDecimalStrings(summary.totalClaimable, amount);
}

return {
version: 1,
exportedAt: new Date().toISOString(),
address: user.stellarAddress,
summary,
markets: [...byMarket.values()].sort((a, b) =>
b.latestPredictionAt.localeCompare(a.latestPredictionAt),
),
};
}
Loading
Loading