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
6 changes: 5 additions & 1 deletion API.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,11 @@ Portfolio-level aggregated impact and credit quality metrics across projects.

### `GET /v1/portfolio/:address`

Indexed deposit/withdrawal transaction history, share count, and position value for a Stellar account address.
Indexed deposit/withdrawal transaction history, share count, and position value for a Stellar account address. The
simulated share price is seeded from the address, so `current_value` is
deterministic per `(address, clock hour)` — the same address returns the same
value within a clock hour, and `current_value` stays between `1.5x` and `2.0x`
of `current_shares`.

**Response `200`**

Expand Down
17 changes: 9 additions & 8 deletions src/__tests__/admin-response-shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import request from "supertest";
import express, { Express } from "express";
import adminRouter from "../routes/admin";
import { errorHandler } from "../middleware/errors";
import { resetIdempotencyState } from "../lib/scoreService";
import * as registry from "../lib/registry";
import * as iot from "../routes/iot";
import * as scoring from "../lib/scoring";
import { resetIdempotencyState } from "../lib/scoreService";

jest.mock("../lib/registry", () => {
class RpcDegradedError extends Error {
Expand Down Expand Up @@ -45,6 +45,7 @@ describe("admin /update-scores response shape", () => {
resetIdempotencyState();
app = buildApp();
jest.clearAllMocks();
resetIdempotencyState();
(iot.getSolarData as jest.Mock).mockReturnValue({
efficiency_pct: 85,
power_output_kw: 500,
Expand Down Expand Up @@ -92,17 +93,13 @@ describe("admin /update-scores response shape", () => {
expect(Array.isArray(res.body.errors)).toBe(true);
});

it("response shape matches { updated, results, errors }", async () => {
it("response shape matches { updated, results, errors, skipped }", async () => {
const res = await request(app)
.post("/api/admin/update-scores")
.set(authHeader)
.send({})
.expect(200);
expect(
Object.keys(res.body)
.filter((k) => k !== "skipped")
.sort(),
).toEqual(["errors", "results", "updated"]);
expect(Object.keys(res.body).sort()).toEqual(["errors", "results", "skipped", "updated"]);
});

it("results entries have correct shape", async () => {
Expand Down Expand Up @@ -137,6 +134,10 @@ describe("admin /update-scores response shape", () => {
expect(entry).toHaveProperty("project_id");
expect(entry).toHaveProperty("error");
expect(typeof entry.project_id).toBe("number");
expect(typeof entry.error).toBe("object");
// Matches the documented error contract: { error: { code, message } }.
expect(entry.error).toMatchObject({
code: "update_failed",
message: expect.any(String),
});
});
});
2 changes: 1 addition & 1 deletion src/__tests__/admin-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import request from "supertest";
import express, { Express } from "express";
import adminRouter from "../routes/admin";
import { errorHandler } from "../middleware/errors";
import { resetIdempotencyState } from "../lib/scoreService";
import * as registry from "../lib/registry";
import * as iot from "../routes/iot";
import * as scoring from "../lib/scoring";
import { resetIdempotencyState } from "../lib/scoreService";

// Factory mock avoids loading the real registry module, which throws at import
// time when PROJECT_REGISTRY_CONTRACT_ID is unset (e.g. in CI).
Expand Down
6 changes: 4 additions & 2 deletions src/__tests__/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import request from "supertest";
import express, { Express } from "express";
import adminRouter from "../routes/admin";
import { errorHandler } from "../middleware/errors";
import { resetIdempotencyState } from "../lib/scoreService";
import * as registry from "../lib/registry";
import * as iot from "../routes/iot";
import * as scoring from "../lib/scoring";
import { resetIdempotencyState } from "../lib/scoreService";

jest.mock("../lib/registry", () => {
class RpcDegradedError extends Error {
Expand Down Expand Up @@ -42,9 +42,11 @@ describe("admin routes", () => {
let app: Express;

beforeEach(() => {
resetIdempotencyState();
app = buildApp();
jest.clearAllMocks();
// The route goes through the real scoreService, whose module-level
// idempotency map must be cleared between tests — earlier tests in this
// file submit the same project ids.
resetIdempotencyState();
(iot.getSolarData as jest.Mock).mockReturnValue({
efficiency_pct: 85,
Expand Down
5 changes: 2 additions & 3 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,14 @@ describe("Environment Config Module (Issue #272)", () => {
});

describe("Optional environment variable defaults", () => {
it("provides fallback defaults for optional configuration fields", () => {
it("provides fallback defaults for optional configuration fields", async () => {
delete process.env.STELLAR_NETWORK;
delete process.env.RPC_URL;
delete process.env.PORT;
delete process.env.FRONTEND_URL;

jest.resetModules();
// eslint-disable-next-line @typescript-eslint/no-require-imports
const freshConfig = require("../config").config as typeof config;
const freshConfig = (await import("../config")).config as typeof config;

expect(freshConfig.STELLAR_NETWORK).toBe("testnet");
expect(freshConfig.RPC_URL).toBe("https://soroban-testnet.stellar.org");
Expand Down
8 changes: 4 additions & 4 deletions src/__tests__/deployment-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,13 @@ describe("deployment workflow tests (#284)", () => {
});

describe("dependency audit workflow", () => {
it("ci.yml includes a dependency audit job", () => {
const content = readWorkflow("ci.yml");
expect(content).toMatch(/audit|Audit/);
it("security-audit.yml includes a dependency audit job", () => {
const content = readWorkflow("security-audit.yml");
expect(content).toMatch(/dependency-audit/i);
});

it("audit runs npm audit or equivalent", () => {
const content = readWorkflow("ci.yml");
const content = readWorkflow("security-audit.yml");
expect(content).toMatch(/npm audit|yarn audit|bun audit/);
});
});
Expand Down
40 changes: 26 additions & 14 deletions src/__tests__/duplicate-detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,28 @@ jest.mock("../lib/logger", () => ({
},
}));

import { tryBeginUpdate, markCompleted, markFailed } from "../lib/duplicate-detection";
import { logger } from "../lib/logger";
let tryBeginUpdate: typeof import("../lib/duplicate-detection").tryBeginUpdate;
let markCompleted: typeof import("../lib/duplicate-detection").markCompleted;
let markFailed: typeof import("../lib/duplicate-detection").markFailed;
let logger: typeof import("../lib/logger").logger;

describe("duplicate-detection (cron concurrency guard)", () => {
beforeEach(() => {
beforeEach(async () => {
// Re-import the real module (with the logger mock applied) so its
// in-memory lock map starts empty: the map is module-level state, so
// jest.resetModules() alone keeps the original bindings (and their locks)
// around. jest.requireMock would hand back an automatic mock whose
// functions return undefined, so requireActual is used instead.
jest.resetModules();
const fresh = jest.requireActual("../lib/duplicate-detection");
tryBeginUpdate = fresh.tryBeginUpdate;
markCompleted = fresh.markCompleted;
markFailed = fresh.markFailed;
// After resetModules the fresh module resolves a NEW logger mock instance;
// re-import it so assertions see the same functions it calls.
const freshLogger = await import("../lib/logger");
logger = freshLogger.logger;
jest.clearAllMocks();
markCompleted("project-1");
markCompleted("project-2");
markCompleted(123);
markCompleted("concurrent-test");
});

it("allows first update attempt for a given ID", () => {
Expand All @@ -45,7 +57,7 @@ describe("duplicate-detection (cron concurrency guard)", () => {
expect(result.allowed).toBe(false);
expect(result.key).toBe("");
expect(result.reason).toMatch(/Update already in progress since/);
expect(logger.warn).toHaveBeenCalledWith(
expect(logger.warn as jest.Mock).toHaveBeenCalledWith(
expect.stringContaining("[duplicate-detection] Skipping update for project-1:"),
);
});
Expand All @@ -56,7 +68,7 @@ describe("duplicate-detection (cron concurrency guard)", () => {

expect(result1.allowed).toBe(true);
expect(result2.allowed).toBe(true);
expect(logger.warn).not.toHaveBeenCalled();
expect(logger.warn as jest.Mock).not.toHaveBeenCalled();
});

it("releases lock after successful completion", () => {
Expand All @@ -67,7 +79,7 @@ describe("duplicate-detection (cron concurrency guard)", () => {
// Should allow new attempt after lock is released
const result = tryBeginUpdate("project-1");
expect(result.allowed).toBe(true);
expect(logger.debug).toHaveBeenCalledWith(
expect(logger.debug as jest.Mock).toHaveBeenCalledWith(
"[duplicate-detection] Lock released for project-1 after successful completion",
);
});
Expand All @@ -80,7 +92,7 @@ describe("duplicate-detection (cron concurrency guard)", () => {
// Should allow new attempt after lock is released
const result = tryBeginUpdate("project-1");
expect(result.allowed).toBe(true);
expect(logger.debug).toHaveBeenCalledWith(
expect(logger.debug as jest.Mock).toHaveBeenCalledWith(
"[duplicate-detection] Lock released for project-1 after failure",
);
});
Expand All @@ -96,7 +108,7 @@ describe("duplicate-detection (cron concurrency guard)", () => {
const secondRun = tryBeginUpdate(projectId);
expect(secondRun.allowed).toBe(false);
expect(secondRun.reason).toMatch(/Update already in progress/);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn as jest.Mock).toHaveBeenCalledTimes(1);

// Complete first run
markCompleted(projectId);
Expand All @@ -121,10 +133,10 @@ describe("duplicate-detection (cron concurrency guard)", () => {
tryBeginUpdate("project-1");
tryBeginUpdate("project-1");

expect(logger.warn).toHaveBeenCalledWith(
expect(logger.warn as jest.Mock).toHaveBeenCalledWith(
expect.stringContaining("[duplicate-detection] Skipping update for project-1:"),
);
expect(logger.warn).toHaveBeenCalledWith(
expect(logger.warn as jest.Mock).toHaveBeenCalledWith(
expect.stringContaining("Update already in progress since"),
);
});
Expand Down
3 changes: 3 additions & 0 deletions src/__tests__/error-response-consistency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ jest.mock("../lib/registry", () => ({
}
},
}));
// Note: the IoT router is deliberately NOT mocked — this suite verifies the
// real validation path returns structured errors, which an auto-mock router
// would bypass (requests would fall through to the error handler).
jest.mock("../lib/scoring");
jest.mock("../config", () => ({
config: {
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ jest.mock("../config", () => ({
TX_MAX_RETRIES: 4,
TX_RETRY_BASE_DELAY_MS: 200,
TX_RETRY_MAX_DELAY_MS: 10000,
MAX_POWER_KW: 1000,
CRON_TIMEZONE: "UTC",
CRON_FAILURE_THRESHOLD: 0.5,
SHUTDOWN_TIMEOUT_MS: 30000,
Expand All @@ -49,7 +50,6 @@ jest.mock("../config", () => ({
RATE_LIMIT_WINDOW_MS: 60000,
RATE_LIMIT_MAX: 100,
RATE_LIMIT_ADMIN_WINDOW_MS: 60000,
MAX_POWER_KW: 1000,
RATE_LIMIT_ADMIN_MAX: 20,
ADMIN_IP_WHITELIST: "",
ADMIN_IP_WHITELIST_BYPASS_PRIVATE: "true",
Expand Down
91 changes: 91 additions & 0 deletions src/__tests__/portfolio.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import request from "supertest";
import express from "express";
import portfolioRouter from "../routes/portfolio";
import { indexer } from "../lib/indexer";

const app = express();
app.use("/api/portfolio", portfolioRouter);

const ADDRESS = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const OTHER_ADDRESS = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";

beforeAll(() => {
// Seed the singleton indexer so the endpoint has events to value.
indexer.addEvent({
id: "portfolio-test-deposit-1",
type: "deposit",
address: ADDRESS,
amount: 500,
shares: 42,
timestamp: 1718150400000,
ledger: 1,
txHash: "testtxhash1",
});
indexer.addEvent({
id: "portfolio-test-deposit-2",
type: "deposit",
address: ADDRESS,
amount: 250,
shares: 8,
timestamp: 1718150401000,
ledger: 2,
txHash: "testtxhash2",
});
// Same share count (42 + 8 = 50) as ADDRESS so the address-keyed price is
// the only variable when comparing the two portfolios.
indexer.addEvent({
id: "portfolio-test-deposit-other-1",
type: "deposit",
address: OTHER_ADDRESS,
amount: 275,
shares: 42,
timestamp: 1718150402000,
ledger: 3,
txHash: "testtxhash3",
});
indexer.addEvent({
id: "portfolio-test-deposit-other-2",
type: "deposit",
address: OTHER_ADDRESS,
amount: 275,
shares: 8,
timestamp: 1718150403000,
ledger: 4,
txHash: "testtxhash4",
});
});

describe("GET /api/portfolio/:address — deterministic pricing", () => {
it("returns 200 with correct fields", async () => {
const res = await request(app).get(`/api/portfolio/${ADDRESS}`).expect(200);
expect(res.body).toHaveProperty("address", ADDRESS);
expect(res.body).toHaveProperty("current_shares");
expect(res.body).toHaveProperty("current_value");
expect(res.body).toHaveProperty("events");
});

it("returns the same current_value across requests within the same hour", async () => {
const first = await request(app).get(`/api/portfolio/${ADDRESS}`).expect(200);
const second = await request(app).get(`/api/portfolio/${ADDRESS}`).expect(200);

expect(first.body.current_shares).toBe(50);
expect(second.body.current_value).toBe(first.body.current_value);
});

it("keeps current_value in the documented 1.5x–2.0x range of current_shares", async () => {
const res = await request(app).get(`/api/portfolio/${ADDRESS}`).expect(200);
const ratio = res.body.current_value / res.body.current_shares;
expect(ratio).toBeGreaterThanOrEqual(1.5);
expect(ratio).toBeLessThanOrEqual(2.0);
});

it("varies current_value by address for equal share counts", async () => {
const a = await request(app).get(`/api/portfolio/${ADDRESS}`).expect(200);
const b = await request(app).get(`/api/portfolio/${OTHER_ADDRESS}`).expect(200);

expect(a.body.current_shares).toBe(50);
expect(b.body.current_shares).toBe(50);
// Same shares, different address seed: prices must not collapse to one value.
expect(b.body.current_value).not.toBe(a.body.current_value);
});
});
14 changes: 6 additions & 8 deletions src/__tests__/process-exit-codes.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { spawnSync } from "child_process";
import http from "http";
import { createServer } from "http";
import path from "path";

const repoRoot = path.resolve(__dirname, "../..");
Expand All @@ -12,7 +12,7 @@ describe("process exit codes", () => {
PROJECT_REGISTRY_CONTRACT_ID: "",
PORT: "0",
},
["-e", "require('./src/config').validateRequiredEnv();"],
["-e", "require('ts-node/register'); require('./src/config').validateRequiredEnv();"],
);

expect(result.status).toBe(1);
Expand All @@ -22,7 +22,7 @@ describe("process exit codes", () => {
it("exits with code 1 when the port is already in use", () => {
const port = 41000 + Math.floor(Math.random() * 1000);

const firstServer = http.createServer();
const firstServer = createServer();
firstServer.listen(port);

try {
Expand All @@ -32,12 +32,10 @@ describe("process exit codes", () => {
PROJECT_REGISTRY_CONTRACT_ID: "x",
PORT: String(port),
},
[
"-e",
"const http = require('http'); const server = http.createServer(); server.on('error', () => process.exit(1)); server.listen(process.env.PORT);",
],
["-e", "require('ts-node/register'); require('./src/index')"],
);
expect(result.status).toBe(1);
expect(result.stderr + result.stdout).toContain("already in use");
} finally {
firstServer.close();
}
Expand Down Expand Up @@ -75,7 +73,7 @@ describe("process exit codes", () => {
function spawnSyncWithEnv(env: Record<string, string>, args: string[]) {
return spawnSync(process.execPath, ["-r", "ts-node/register", ...args], {
cwd: repoRoot,
env: { ...process.env, ...env },
env: { ...process.env, TS_NODE_TRANSPILE_ONLY: "true", ...env },
encoding: "utf8",
});
}
Loading
Loading