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
52 changes: 52 additions & 0 deletions backend/src/__tests__/timeoutMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import express, { Request, Response } from 'express';
import request from 'supertest';
import { timeoutMiddleware, createTimeoutFor } from '../middleware/timeoutMiddleware';

describe('timeoutMiddleware', () => {
it('returns a structured fallback payload when the route exceeds its budget', async () => {
const app = express();
app.get(
'/slow',
createTimeoutFor.read({
timeoutMs: 20,
routeName: '/slow',
fallbackResponse: () => ({
error: 'Service Unavailable',
status: 503,
code: 'SLOW_ROUTE_TIMEOUT',
message: 'Slow route timed out',
stale: true,
}),
}),
async (_req: Request, res: Response) => {
await new Promise((resolve) => setTimeout(resolve, 50));
res.status(200).json({ ok: true });
},
);

const res = await request(app).get('/slow');
expect(res.status).toBe(503);
expect(res.body).toMatchObject({
error: 'Service Unavailable',
status: 503,
code: 'SLOW_ROUTE_TIMEOUT',
message: 'Slow route timed out',
stale: true,
});
});

it('allows fast requests to complete normally', async () => {
const app = express();
app.get(
'/fast',
timeoutMiddleware({ timeoutMs: 50 }),
(_req: Request, res: Response) => {
res.status(200).json({ ok: true });
},
);

const res = await request(app).get('/fast');
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
});
});
40 changes: 40 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,20 @@ app.get(
'/api/v1/vault/summary',
readsLimiter,
cacheMiddleware({ ttl: cacheVaultMetricsTtl }),
createTimeoutFor.read({
timeoutMs: 1500,
routeName: '/api/v1/vault/summary',
message: 'Vault summary took too long to load',
fallbackResponse: () => ({
error: 'Service Unavailable',
status: 503,
code: 'VAULT_SUMMARY_TIMEOUT',
message: 'Vault summary is temporarily unavailable. Please refresh in a moment.',
stale: true,
data: buildVaultSummaryResponse(),
timestamp: new Date().toISOString(),
}),
}),
(_req: Request, res: Response) => {
res.json(buildVaultSummaryResponse());
},
Expand All @@ -876,6 +890,19 @@ app.get(
app.get(
'/api/v1/vault/metrics',
cacheMiddleware({ ttl: cacheVaultMetricsTtl }),
createTimeoutFor.read({
timeoutMs: 2000,
routeName: '/api/v1/vault/metrics',
message: 'Vault metrics took too long to load',
fallbackResponse: () => ({
error: 'Service Unavailable',
status: 503,
code: 'VAULT_METRICS_TIMEOUT',
message: 'Vault metrics are temporarily unavailable. Showing the last known state.',
stale: true,
timestamp: new Date().toISOString(),
}),
}),
(_req: Request, res: Response) => {
res.json({
message: 'Vault metrics',
Expand All @@ -890,6 +917,19 @@ app.get(
app.get(
'/api/v1/vault/apy',
cacheMiddleware({ ttl: cacheVaultMetricsTtl }),
createTimeoutFor.read({
timeoutMs: 1200,
routeName: '/api/v1/vault/apy',
message: 'Vault APY took too long to load',
fallbackResponse: () => ({
error: 'Service Unavailable',
status: 503,
code: 'VAULT_APY_TIMEOUT',
message: 'Vault APY is temporarily unavailable. Please try again shortly.',
stale: true,
timestamp: new Date().toISOString(),
}),
}),
(_req: Request, res: Response) => {
res.json({
message: 'Vault APY',
Expand Down
37 changes: 32 additions & 5 deletions backend/src/middleware/timeoutMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export interface TimeoutOptions {
fallbackResponse?: (req: Request) => Record<string, unknown>;
/** Optional: custom message for the timeout */
message?: string;
/** Optional HTTP status for the fallback response */
fallbackStatusCode?: number;
/** Optional route label used in structured fallback payloads */
routeName?: string;
}

/**
Expand All @@ -19,6 +23,7 @@ export interface TimeoutOptions {
export const DEFAULT_TIMEOUT_OPTIONS: TimeoutOptions = {
timeoutMs: 30000, // 30 seconds default
message: 'Request timed out',
fallbackStatusCode: 408,
};

/**
Expand Down Expand Up @@ -57,12 +62,14 @@ export function timeoutMiddleware(options: TimeoutOptions = DEFAULT_TIMEOUT_OPTI
const response = fallbackResponse ? fallbackResponse(req) : {
error: 'Request Timeout',
status: 408,
code: 'TIMEOUT',
message: message || DEFAULT_TIMEOUT_OPTIONS.message,
retryAfter: Math.ceil(timeoutMs / 1000),
route: routeName ?? req.originalUrl ?? req.path,
timestamp: new Date().toISOString(),
};

res.status(408).json(response);
res.status(fallbackStatusCode ?? 408).json(response);
cleanup();
}
}, timeoutMs);
Expand Down Expand Up @@ -102,11 +109,31 @@ export function timeoutMiddleware(options: TimeoutOptions = DEFAULT_TIMEOUT_OPTI
*/
export const createTimeoutFor = {
/** Read‑heavy endpoints (5 seconds) */
read: () => timeoutMiddleware({ timeoutMs: 5000 }),
read: (options: Partial<TimeoutOptions> = {}) =>
timeoutMiddleware({
timeoutMs: 5000,
fallbackStatusCode: 503,
...options,
}),
/** Write operations (15 seconds) */
write: () => timeoutMiddleware({ timeoutMs: 15000 }),
write: (options: Partial<TimeoutOptions> = {}) =>
timeoutMiddleware({
timeoutMs: 15000,
fallbackStatusCode: 503,
...options,
}),
/** Admin operations (30 seconds) */
admin: () => timeoutMiddleware({ timeoutMs: 30000 }),
admin: (options: Partial<TimeoutOptions> = {}) =>
timeoutMiddleware({
timeoutMs: 30000,
fallbackStatusCode: 503,
...options,
}),
/** Export/download endpoints (60 seconds) */
export: () => timeoutMiddleware({ timeoutMs: 60000 }),
export: (options: Partial<TimeoutOptions> = {}) =>
timeoutMiddleware({
timeoutMs: 60000,
fallbackStatusCode: 503,
...options,
}),
};
12 changes: 12 additions & 0 deletions frontend/src/components/APYTrendChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import { usePolling } from "../hooks/usePolling";
import { useStaleIndicator } from "../hooks/useStaleIndicator";
import ChartWidgetPlaceholder from "./ui/ChartWidgetPlaceholder";
import { ChartModeToggle } from "./ChartModeToggle";
import Badge from "./Badge";
import { Clock3 } from "lucide-react";

// ─── Types ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -303,6 +305,16 @@ const APYTrendChart: React.FC<APYTrendChartProps> = ({ data = ALL_HISTORY }) =>
<p style={{ color: "var(--text-secondary)", fontSize: "0.82rem" }}>
Comparative APY across selectable time windows
</p>
<div style={{ marginTop: "8px" }}>
<Badge
variant="pill"
color={isStale ? "warning" : "cyan"}
size="compact"
icon={<Clock3 size={10} />}
>
{isStale ? `Stale ${ageText || ""}`.trim() : ageText ? `Fresh ${ageText}` : "Live"}
</Badge>
</div>
</div>

{/* Primary range selector */}
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/components/VaultDashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import * as tokenAllowanceHooks from "../hooks/useTokenAllowance";
import * as vaultMutations from "../hooks/useVaultMutations";
import type { UseQueryResult } from "@tanstack/react-query";
import type { PortfolioHolding } from "../lib/portfolioApi";
import confetti from "canvas-confetti";

vi.mock("canvas-confetti", () => ({
default: vi.fn(),
}));

vi.mock("../lib/vaultApi", async (importOriginal) => {
const actual = await importOriginal<typeof vaultApi>();
Expand Down Expand Up @@ -171,6 +176,8 @@ describe("VaultDashboard", () => {
approve: vi.fn().mockResolvedValue(undefined),
resetApproval: vi.fn(),
});
window.matchMedia = vi.fn().mockReturnValue({ matches: false } as MediaQueryList);
localStorage.clear();
});

afterEach(() => {
Expand Down Expand Up @@ -256,6 +263,28 @@ describe("VaultDashboard", () => {
}, { timeout: 10000 });
}, 15000);

it("plays the first deposit confetti once per wallet", async () => {
const mutateAsync = vi.fn().mockResolvedValue({});
vi.mocked(vaultMutations.useDepositMutation).mockReturnValue({
mutateAsync,
isPending: false,
} as unknown as ReturnType<typeof vaultMutations.useDepositMutation>);

renderDashboard("GFIRSTDEPOSITWALLET000000000000000000000000000000");

const input = await screen.findByPlaceholderText("0.00");
fireEvent.change(input, { target: { value: "100" } });
fireEvent.click(screen.getByRole("button", { name: "Review Transaction" }));
fireEvent.click(await screen.findByRole("button", { name: /Confirm deposit/i }));

await waitFor(() => {
expect(mutateAsync).toHaveBeenCalled();
});

expect(confetti).toHaveBeenCalled();
expect(localStorage.getItem("yieldvault:first-deposit:GFIRSTDEPOSITWALLET000000000000000000000000000000")).toBe("true");
});

it("fills the input with max allowable amount via MAX button", async () => {
renderDashboard("GABC123");

Expand Down
Loading
Loading