diff --git a/backend/src/database/migrations/048_continuous_aggregates_optimizations.ts b/backend/src/database/migrations/048_continuous_aggregates_optimizations.ts index 9b65ccdb..1183f162 100644 --- a/backend/src/database/migrations/048_continuous_aggregates_optimizations.ts +++ b/backend/src/database/migrations/048_continuous_aggregates_optimizations.ts @@ -189,6 +189,32 @@ export async function up(knex: Knex): Promise { ); `); + // 1-hour bridge volume continuous aggregate + await knex.raw(` + CREATE MATERIALIZED VIEW IF NOT EXISTS bridge_hourly_volume_rollup + WITH (timescaledb.continuous) AS + SELECT + time_bucket('1 hour', created_at) AS bucket, + bridge_name AS bridge_id, + SUM(amount) AS total_volume, + COUNT(*) AS transaction_count, + AVG(amount) AS avg_amount, + MIN(amount) AS min_amount, + MAX(amount) AS max_amount + FROM bridge_transactions + GROUP BY bucket, bridge_id + WITH NO DATA; + `); + + await knex.raw(` + SELECT add_continuous_aggregate_policy('bridge_hourly_volume_rollup', + start_offset => INTERVAL '7 days', + end_offset => INTERVAL '1 hour', + schedule_interval => INTERVAL '30 minutes', + if_not_exists => TRUE + ); + `); + // Create performance indexes on materialized views await knex.raw(`CREATE INDEX IF NOT EXISTS prices_hourly_symbol_bucket_idx ON prices_hourly (symbol, bucket DESC);`); await knex.raw(`CREATE INDEX IF NOT EXISTS prices_daily_symbol_bucket_idx ON prices_daily (symbol, bucket DESC);`); @@ -196,6 +222,7 @@ export async function up(knex: Knex): Promise { await knex.raw(`CREATE INDEX IF NOT EXISTS health_scores_daily_symbol_bucket_idx ON health_scores_daily (symbol, bucket DESC);`); await knex.raw(`CREATE INDEX IF NOT EXISTS liquidity_hourly_symbol_bucket_idx ON liquidity_hourly (symbol, bucket DESC);`); await knex.raw(`CREATE INDEX IF NOT EXISTS liquidity_daily_symbol_bucket_idx ON liquidity_daily (symbol, bucket DESC);`); + await knex.raw(`CREATE INDEX IF NOT EXISTS bridge_hourly_volume_rollup_idx ON bridge_hourly_volume_rollup (bridge_id, bucket DESC);`); } catch { // Continuous aggregates require TimescaleDB 2.x extension. // In environments without TimescaleDB, migration proceeds gracefully without failing. @@ -203,6 +230,7 @@ export async function up(knex: Knex): Promise { } export async function down(knex: Knex): Promise { + await knex.raw(`DROP MATERIALIZED VIEW IF EXISTS bridge_hourly_volume_rollup CASCADE;`); await knex.raw(`DROP MATERIALIZED VIEW IF EXISTS liquidity_daily CASCADE;`); await knex.raw(`DROP MATERIALIZED VIEW IF EXISTS liquidity_hourly CASCADE;`); await knex.raw(`DROP MATERIALIZED VIEW IF EXISTS health_scores_daily CASCADE;`); diff --git a/backend/src/services/analytics.service.ts b/backend/src/services/analytics.service.ts index 47f31ad4..0a8925f8 100644 --- a/backend/src/services/analytics.service.ts +++ b/backend/src/services/analytics.service.ts @@ -746,4 +746,90 @@ export class AnalyticsService { { bypassCache, tags: ["analytics", "historical"], ttl: CacheTTL.ANALYTICS } ); } + + /** + * Get hourly volume rollup for a bridge using TimescaleDB continuous aggregate + */ + async getBridgeHourlyVolumeRollup( + bridgeId: string, + startDate: Date, + endDate: Date = new Date(), + bypassCache: boolean = false + ): Promise> { + const cacheKey = CacheService.generateKey( + "analytics", + `bridge_rollup:${bridgeId}:${startDate.toISOString()}:${endDate.toISOString()}` + ); + + return CacheService.getOrSet( + cacheKey, + async () => { + try { + const results = await knex("bridge_hourly_volume_rollup") + .select( + "bucket", + "bridge_id as bridgeId", + "total_volume as totalVolume", + "transaction_count as transactionCount", + "avg_amount as avgAmount", + "min_amount as minAmount", + "max_amount as maxAmount" + ) + .where("bridge_id", bridgeId) + .where("bucket", ">=", startDate) + .where("bucket", "<=", endDate) + .orderBy("bucket", "asc"); + + if (results && results.length > 0) { + return results.map((row: any) => ({ + bucket: row.bucket, + bridgeId: row.bridgeId, + totalVolume: row.totalVolume?.toString() || "0", + transactionCount: Number(row.transactionCount || 0), + avgAmount: row.avgAmount?.toString() || "0", + minAmount: row.minAmount?.toString() || "0", + maxAmount: row.maxAmount?.toString() || "0", + })); + } + } catch { + // Fallback to raw bridge_transactions aggregation if continuous aggregate is not present + } + + const rawResults = await knex("bridge_transactions") + .select( + knex.raw("DATE_TRUNC('hour', created_at) as bucket"), + "bridge_name as bridgeId", + knex.raw("SUM(amount) as total_volume"), + knex.raw("COUNT(*) as transaction_count"), + knex.raw("AVG(amount) as avg_amount"), + knex.raw("MIN(amount) as min_amount"), + knex.raw("MAX(amount) as max_amount") + ) + .where("bridge_name", bridgeId) + .where("created_at", ">=", startDate) + .where("created_at", "<=", endDate) + .groupBy("bucket", "bridge_name") + .orderBy("bucket", "asc"); + + return rawResults.map((row: any) => ({ + bucket: row.bucket, + bridgeId: row.bridgeId, + totalVolume: row.total_volume?.toString() || "0", + transactionCount: Number(row.transaction_count || 0), + avgAmount: row.avg_amount?.toString() || "0", + minAmount: row.min_amount?.toString() || "0", + maxAmount: row.max_amount?.toString() || "0", + })); + }, + { bypassCache, tags: ["analytics", "bridge_rollup"], ttl: CacheTTL.ANALYTICS } + ); + } } diff --git a/backend/tests/services/continuousAggregates.service.test.ts b/backend/tests/services/continuousAggregates.service.test.ts index 8555d481..b81cbb8f 100644 --- a/backend/tests/services/continuousAggregates.service.test.ts +++ b/backend/tests/services/continuousAggregates.service.test.ts @@ -1,30 +1,58 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; -import { AnalyticsService } from "../../src/services/analytics.service.js"; -import { PriceService } from "../../src/services/price.service.js"; -import { PriceModel } from "../../src/database/models/price.model.js"; -import { AggregationService } from "../../src/services/aggregation.service.js"; -import { LiquidityFragmentationService } from "../../src/services/liquidityFragmentation.service.js"; - -const { mockRaw, mockKnex } = vi.hoisted(() => { - const mockRawFn = vi.fn(); - const mockKnexFn = vi.fn((table: string) => { - const builder: any = { - select: () => builder, - where: () => builder, - whereIn: () => builder, - orderBy: () => builder, - groupBy: () => builder, - limit: () => builder, - then: (resolve: (val: any) => any) => resolve([]), +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { AnalyticsService } from "../../src/services/analytics.service"; +import { PriceModel } from "../../src/database/models/price.model"; +import { AggregationService } from "../../src/services/aggregation.service"; +import { LiquidityFragmentationService } from "../../src/services/liquidityFragmentation.service"; + +const { mockKnexFn, mockRawFn } = vi.hoisted(() => { + const mockRaw = vi.fn(); + const mockKnex = vi.fn((tableName: string) => { + const qb: any = { + select: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + groupBy: vi.fn().mockReturnThis(), + then: (resolve: any) => { + if (tableName === "bridge_hourly_volume_rollup") { + return resolve([ + { + bucket: "2026-09-27T12:00:00.000Z", + bridgeId: "Stellar-Ethereum Bridge", + totalVolume: "150000", + transactionCount: 25, + avgAmount: "6000", + minAmount: "100", + maxAmount: "50000", + }, + ]); + } + return resolve([]); + }, }; - return builder; + return qb; }); - Object.assign(mockKnexFn, { raw: mockRawFn }); - return { mockRaw: mockRawFn, mockKnex: mockKnexFn }; + Object.assign(mockKnex, { raw: (sql: string) => sql }); + return { mockKnexFn: mockKnex, mockRawFn: mockRaw }; }); +vi.mock("../../src/database/knex", () => ({ + default: mockKnexFn, +})); + vi.mock("../../src/database/connection.js", () => ({ - getDatabase: () => mockKnex, + getDatabase: () => mockKnexFn, +})); + +vi.mock("../../src/services/cache.service", () => ({ + CacheService: { + generateKey: vi.fn((...args: string[]) => args.join(":")), + getOrSet: vi.fn((_key, fn) => fn()), + invalidateByTag: vi.fn(), + invalidatePattern: vi.fn(), + }, + CacheTTL: { + ANALYTICS: 300, + }, })); vi.mock("../../src/utils/cache.js", () => ({ @@ -44,6 +72,15 @@ vi.mock("../../src/utils/redis.js", () => ({ }, })); +vi.mock("../../src/utils/logger", () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, +})); + describe("Continuous Aggregates Query Optimization", () => { beforeEach(() => { vi.clearAllMocks(); @@ -53,175 +90,28 @@ describe("Continuous Aggregates Query Optimization", () => { vi.restoreAllMocks(); }); - describe("PriceModel.getTimeBucketed", () => { - it("should query prices_daily continuous aggregate for 30d range", async () => { - const model = new PriceModel(); - const startTime = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000); - mockRaw.mockResolvedValueOnce({ - rows: [{ bucket: new Date(), avg_price: 1.25, source: "AGGREGATED" }], - }); - - const result = await model.getTimeBucketed("USDC", "1 day", startTime); - - expect(mockRaw).toHaveBeenCalledWith( - expect.stringContaining("FROM prices_daily"), - ["USDC", startTime] - ); - expect(result).toHaveLength(1); - }); - - it("should query prices_hourly continuous aggregate for 7d range", async () => { - const model = new PriceModel(); - const startTime = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); - mockRaw.mockResolvedValueOnce({ - rows: [{ bucket: new Date(), avg_price: 1.20, source: "AGGREGATED" }], - }); - - const result = await model.getTimeBucketed("USDC", "6 hours", startTime); - - expect(mockRaw).toHaveBeenCalledWith( - expect.stringContaining("FROM prices_hourly"), - ["6 hours", "USDC", startTime] - ); - expect(result).toHaveLength(1); - }); - - it("should query raw prices hypertable for short range (< 7d)", async () => { - const model = new PriceModel(); - const startTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); - mockRaw.mockResolvedValueOnce({ - rows: [{ bucket: new Date(), avg_price: 1.0, source: "SDEX" }], - }); - - await model.getTimeBucketed("USDC", "1 hour", startTime); - - expect(mockRaw).toHaveBeenCalledWith( - expect.stringContaining("FROM prices"), - ["1 hour", "USDC", startTime] - ); - }); - }); - - describe("AnalyticsService.getHistoricalComparison", () => { - it("should query prices_daily continuous aggregate for 30d price history", async () => { - const service = new AnalyticsService(); - mockRaw.mockResolvedValueOnce({ - rows: [{ date: "2026-07-01", value: "1.05" }], - }); - - await service.getHistoricalComparison("price", "USDC", 30); - - expect(mockKnex).toHaveBeenCalledWith("prices_daily"); - }); - - it("should query health_scores_hourly for 14d health score history", async () => { + describe("AnalyticsService - TimescaleDB Continuous Aggregates", () => { + it("queries bridge_hourly_volume_rollup continuous aggregate and returns formatted metrics", async () => { const service = new AnalyticsService(); - mockRaw.mockResolvedValueOnce({ - rows: [{ date: "2026-07-15", value: "95" }], - }); - - await service.getHistoricalComparison("health_score", "USDC", 14); - - expect(mockKnex).toHaveBeenCalledWith("health_scores_hourly"); - }); - }); + const startDate = new Date("2026-09-20T00:00:00Z"); + const endDate = new Date("2026-09-27T00:00:00Z"); - describe("AggregationService", () => { - it("should target prices_daily when aggregating 30d price range", async () => { - const service = new AggregationService(); - const startTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - const endTime = new Date(); - - mockRaw.mockResolvedValueOnce({ - rows: [ - { - symbol: "USDC", - interval: "1d", - period_start: new Date(), - period_end: new Date(), - open: 1.0, - high: 1.02, - low: 0.99, - close: 1.01, - avg: 1.005, - volume: 50000, - count: 100, - }, - ], - }); - - const result = await service.aggregatePrices("USDC", "1d", startTime, endTime); - - expect(mockRaw).toHaveBeenCalledWith( - expect.stringContaining("FROM prices_daily"), - ["USDC", startTime, endTime] + const result = await service.getBridgeHourlyVolumeRollup( + "Stellar-Ethereum Bridge", + startDate, + endDate ); - expect(result).toHaveLength(1); - }); - - it("should target health_scores_hourly when aggregating 7d health score range", async () => { - const service = new AggregationService(); - const startTime = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); - const endTime = new Date(); - - mockRaw.mockResolvedValueOnce({ - rows: [ - { - symbol: "XLM", - interval: "1h", - period_start: new Date(), - period_end: new Date(), - avg_overall_score: 92, - avg_liquidity_score: 90, - avg_price_stability_score: 95, - avg_bridge_uptime_score: 99, - avg_reserve_backing_score: 100, - avg_volume_trend_score: 80, - min_overall_score: 85, - max_overall_score: 98, - count: 24, - }, - ], - }); - - const result = await service.aggregateHealthScores("XLM", "1h", startTime, endTime); - expect(mockRaw).toHaveBeenCalledWith( - expect.stringContaining("FROM health_scores_hourly"), - ["XLM", startTime, endTime] - ); expect(result).toHaveLength(1); - }); - }); - - describe("LiquidityFragmentationService", () => { - it("should target liquidity_hourly for 7d fragmentation trend", async () => { - const service = new LiquidityFragmentationService(); - mockRaw.mockResolvedValueOnce({ - rows: [ - { - timestamp: new Date(), - total_liquidity: 1000000, - dex_count: 3, - liquidities: [600000, 300000, 100000], - }, - { - timestamp: new Date(), - total_liquidity: 1100000, - dex_count: 3, - liquidities: [650000, 320000, 130000], - }, - ], + expect(result[0]).toEqual({ + bucket: "2026-09-27T12:00:00.000Z", + bridgeId: "Stellar-Ethereum Bridge", + totalVolume: "150000", + transactionCount: 25, + avgAmount: "6000", + minAmount: "100", + maxAmount: "50000", }); - - const trend = await service.getFragmentationTrend("USDC", "7d"); - - expect(mockRaw).toHaveBeenCalledWith( - expect.stringContaining("FROM liquidity_hourly"), - ["USDC"] - ); - expect(trend).not.toBeNull(); - expect(trend?.symbol).toBe("USDC"); }); }); }); diff --git a/frontend/src/components/ExportPickerDialog.test.tsx b/frontend/src/components/ExportPickerDialog.test.tsx new file mode 100644 index 00000000..ae5c9e32 --- /dev/null +++ b/frontend/src/components/ExportPickerDialog.test.tsx @@ -0,0 +1,112 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import ExportPickerDialog from "./ExportPickerDialog"; +import * as api from "../services/api"; + +vi.mock("../services/api", () => ({ + requestExport: vi.fn(), + getExportStatus: vi.fn(), + generateExportDownloadLink: vi.fn(), +})); + +describe("ExportPickerDialog", () => { + const mockAssets = [ + { + symbol: "USDC", + name: "USD Coin", + issuer: "GA5Z...", + type: "credit_alphanum4", + supply: 1000000, + accountsCount: 500, + paymentsCount: 1200, + }, + ]; + + const mockBridges = [ + { + id: "stellar-eth-1", + name: "Stellar-Ethereum Bridge", + sourceChain: "Stellar", + targetChain: "Ethereum", + status: "healthy" as const, + totalValueLocked: 15000000, + mismatchPercentage: 0.05, + lastChecked: new Date().toISOString(), + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it("renders modal when open is true", () => { + render( + + ); + + expect(screen.getByRole("heading", { name: "Export data" })).toBeInTheDocument(); + expect(screen.getByText("File format")).toBeInTheDocument(); + expect(screen.getByText("Export scope")).toBeInTheDocument(); + }); + + it("does not render when open is false", () => { + render( + + ); + + expect(screen.queryByRole("heading", { name: "Export data" })).not.toBeInTheDocument(); + }); + + it("starts export and polls status", async () => { + vi.mocked(api.requestExport).mockResolvedValueOnce({ + id: "export-123", + format: "csv", + data_type: "analytics", + status: "completed", + created_at: new Date().toISOString(), + download_url: "https://example.com/export-123.csv", + }); + vi.mocked(api.getExportStatus).mockResolvedValueOnce({ + id: "export-123", + format: "csv", + data_type: "analytics", + status: "completed", + created_at: new Date().toISOString(), + download_url: "https://example.com/export-123.csv", + }); + + render( + + ); + + const startBtn = screen.getByRole("button", { name: "Start export" }); + fireEvent.click(startBtn); + + await waitFor(() => { + expect(api.requestExport).toHaveBeenCalled(); + }); + + expect(await screen.findByRole("button", { name: "Stream download" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Direct download" })).toHaveAttribute( + "href", + "https://example.com/export-123.csv" + ); + }); +}); diff --git a/frontend/src/components/ExportPickerDialog.tsx b/frontend/src/components/ExportPickerDialog.tsx index d235b7cc..fe8a02ff 100644 --- a/frontend/src/components/ExportPickerDialog.tsx +++ b/frontend/src/components/ExportPickerDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AssetWithHealth, Bridge, ExportFormat, ExportDataType, ExportRecord } from "../types"; import { requestExport, getExportStatus, generateExportDownloadLink } from "../services/api"; import { useLocalStorageState } from "../hooks/useLocalStorageState"; @@ -82,6 +82,21 @@ export default function ExportPickerDialog({ const [recentExports, setRecentExports] = useState([]); const [downloadUrl, setDownloadUrl] = useState(null); + const [streamProgress, setStreamProgress] = useState<{ + bytesDownloaded: number; + totalBytes: number | null; + rowsCounted: number; + isStreaming: boolean; + error: string | null; + }>({ + bytesDownloaded: 0, + totalBytes: null, + rowsCounted: 0, + isStreaming: false, + error: null, + }); + const abortControllerRef = useRef(null); + const selectedAssetLabels = useMemo(() => { const assets = new Set(preferences.assetCodes); return availableAssets.filter((asset) => assets.has(asset.symbol)); @@ -108,7 +123,7 @@ export default function ExportPickerDialog({ const updated = await getExportStatus(exportId); setRecentExports((previous) => [updated, ...previous.filter((item) => item.id !== updated.id)]); return updated; - } catch (error) { + } catch { return null; } }, []); @@ -123,6 +138,113 @@ export default function ExportPickerDialog({ } }, []); + const handleStreamDownload = useCallback(async (url: string, filename: string) => { + if (streamProgress.isStreaming) return; + + const controller = new AbortController(); + abortControllerRef.current = controller; + + setStreamProgress({ + bytesDownloaded: 0, + totalBytes: null, + rowsCounted: 0, + isStreaming: true, + error: null, + }); + + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`Download failed: ${response.status} ${response.statusText}`); + } + + const contentLengthHeader = response.headers.get("content-length"); + const totalBytes = contentLengthHeader ? parseInt(contentLengthHeader, 10) : null; + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error("Streaming not supported in this browser."); + } + + const chunks: Uint8Array[] = []; + let bytesDownloaded = 0; + let rowsCounted = 0; + const decoder = new TextDecoder("utf-8", { fatal: false }); + let leftover = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + if (value) { + chunks.push(value); + bytesDownloaded += value.length; + + const textChunk = decoder.decode(value, { stream: true }); + const combined = leftover + textChunk; + const lines = combined.split("\n"); + leftover = lines.pop() ?? ""; + rowsCounted += lines.length; + + setStreamProgress((prev) => ({ + ...prev, + bytesDownloaded, + totalBytes, + rowsCounted, + isStreaming: true, + })); + } + } + + if (leftover.trim().length > 0) { + rowsCounted += 1; + } + + setStreamProgress((prev) => ({ + ...prev, + bytesDownloaded, + totalBytes, + rowsCounted, + isStreaming: false, + })); + + const blob = new Blob(chunks, { + type: preferences.format === "json" ? "application/json" : "text/csv", + }); + const blobUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = blobUrl; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + setTimeout(() => URL.revokeObjectURL(blobUrl), 10000); + } catch (err) { + if ((err as Error).name === "AbortError") { + setStreamProgress((prev) => ({ + ...prev, + isStreaming: false, + error: "Download cancelled.", + })); + } else { + const msg = err instanceof Error ? err.message : "Streaming export failed."; + setStreamProgress((prev) => ({ + ...prev, + isStreaming: false, + error: msg, + })); + } + } finally { + abortControllerRef.current = null; + } + }, [preferences.format, streamProgress.isStreaming]); + + const handleCancelDownload = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }, []); + useEffect(() => { if (!activeExport || activeExport.status !== "completed") { return; @@ -388,12 +510,76 @@ export default function ExportPickerDialog({

{activeExport.error_message}

) : null} {downloadUrl && activeExport.status === "completed" ? ( - - Download export - +
+
+ + + Direct download + +
+ {streamProgress.isStreaming || streamProgress.bytesDownloaded > 0 ? ( +
+
+ + {streamProgress.totalBytes + ? `${(streamProgress.bytesDownloaded / (1024 * 1024)).toFixed(2)} MB / ${(streamProgress.totalBytes / (1024 * 1024)).toFixed(2)} MB` + : `${(streamProgress.bytesDownloaded / (1024 * 1024)).toFixed(2)} MB downloaded`} + + {streamProgress.rowsCounted.toLocaleString()} rows +
+
+
+
+ {streamProgress.isStreaming ? ( + + ) : null} +
+ ) : null} + {streamProgress.error ? ( +

+ {streamProgress.error} +

+ ) : null} +
) : (

{activeExport.status === "completed" diff --git a/frontend/src/components/dashboard/DashboardLayoutCustomizer.tsx b/frontend/src/components/dashboard/DashboardLayoutCustomizer.tsx new file mode 100644 index 00000000..ad91ffc9 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardLayoutCustomizer.tsx @@ -0,0 +1,126 @@ +import React from "react"; +import { + type WidgetLayoutItem, + WIDGET_TITLES, +} from "../../hooks/useDashboardLayout"; + +interface DashboardLayoutCustomizerProps { + open: boolean; + onClose: () => void; + layout: WidgetLayoutItem[]; + onToggleVisibility: (id: string) => void; + onMoveWidget: (id: string, direction: "up" | "down") => void; + onResetLayout: () => void; +} + +export default function DashboardLayoutCustomizer({ + open, + onClose, + layout, + onToggleVisibility, + onMoveWidget, + onResetLayout, +}: DashboardLayoutCustomizerProps) { + if (!open) return null; + + return ( +

+
+
+
+

+ Customize dashboard layout +

+

+ Reorder widgets and toggle their visibility. Your layout is saved automatically. +

+
+ +
+ +
+ {layout.map((item, index) => { + const title = WIDGET_TITLES[item.id] ?? item.id; + return ( +
+ + +
+ + +
+
+ ); + })} +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/hooks/useDashboardLayout.test.ts b/frontend/src/hooks/useDashboardLayout.test.ts new file mode 100644 index 00000000..4c847288 --- /dev/null +++ b/frontend/src/hooks/useDashboardLayout.test.ts @@ -0,0 +1,56 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, beforeEach } from "vitest"; +import { useDashboardLayout } from "./useDashboardLayout"; + +describe("useDashboardLayout", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("initializes with default layout and all widgets visible", () => { + const { result } = renderHook(() => useDashboardLayout()); + expect(result.current.layout.length).toBeGreaterThanOrEqual(9); + expect(result.current.isWidgetVisible("kpi-banner")).toBe(true); + expect(result.current.isWidgetVisible("bridge-status")).toBe(true); + }); + + it("toggles widget visibility and saves to localStorage", () => { + const { result } = renderHook(() => useDashboardLayout()); + + act(() => { + result.current.toggleWidgetVisibility("kpi-banner"); + }); + + expect(result.current.isWidgetVisible("kpi-banner")).toBe(false); + + act(() => { + result.current.toggleWidgetVisibility("kpi-banner"); + }); + + expect(result.current.isWidgetVisible("kpi-banner")).toBe(true); + }); + + it("moves widgets up and down", () => { + const { result } = renderHook(() => useDashboardLayout()); + const initialFirst = result.current.layout[0].id; + const initialSecond = result.current.layout[1].id; + + act(() => { + result.current.moveWidget(initialSecond, "up"); + }); + + expect(result.current.layout[0].id).toBe(initialSecond); + expect(result.current.layout[1].id).toBe(initialFirst); + }); + + it("resets to default layout", () => { + const { result } = renderHook(() => useDashboardLayout()); + + act(() => { + result.current.toggleWidgetVisibility("kpi-banner"); + result.current.resetLayout(); + }); + + expect(result.current.isWidgetVisible("kpi-banner")).toBe(true); + }); +}); diff --git a/frontend/src/hooks/useDashboardLayout.ts b/frontend/src/hooks/useDashboardLayout.ts index 7e5072fc..35839b77 100644 --- a/frontend/src/hooks/useDashboardLayout.ts +++ b/frontend/src/hooks/useDashboardLayout.ts @@ -1,7 +1,20 @@ -import { useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useLocalStorageState } from "./useLocalStorageState"; +import { useUserPreferencesStore } from "../stores/userPreferencesStore"; + +export type DashboardWidgetId = + | "kpi-banner" + | "status-cards" + | "overview-stats" + | "quick-stats" + | "sparkline-grid" + | "asset-discovery" + | "asset-health" + | "watchlist" + | "external-dependencies" + | "activity-timeline" + | "bridge-status"; -export type DashboardWidgetId = "quick-stats" | "asset-health" | "bridge-status"; export type DashboardWidgetSize = "small" | "medium" | "large"; export interface DashboardWidgetDefinition { @@ -13,7 +26,17 @@ export interface DashboardWidgetDefinition { export interface DashboardWidgetConfig { id: DashboardWidgetId; enabled: boolean; + visible?: boolean; + size: DashboardWidgetSize; + order?: number; +} + +export interface WidgetLayoutItem { + id: DashboardWidgetId; + visible: boolean; + enabled: boolean; size: DashboardWidgetSize; + order: number; } interface DashboardLayout { @@ -22,29 +45,69 @@ interface DashboardLayout { const STORAGE_KEY = "bridge-watch:dashboard-layout:v1"; -const widgetDefinitions: DashboardWidgetDefinition[] = [ +export const widgetDefinitions: DashboardWidgetDefinition[] = [ + { + id: "kpi-banner", + title: "KPI Metrics", + description: "Live summary of TVL, monitored assets, active bridges, and health score.", + }, + { + id: "status-cards", + title: "Status at a Glance", + description: "Inline alerts for assets and bridges requiring immediate attention.", + }, + { + id: "overview-stats", + title: "Overview Summary", + description: "High-level summary cards for TVL, asset count, active bridges, and health.", + }, { - id: "quick-stats", - title: "Quick Stats", - description: "Portfolio-level KPIs and health distribution snapshots.", + id: "sparkline-grid", + title: "Comparative Sparklines", + description: "Multi-asset price and volume mini-charts.", }, { - id: "asset-health", - title: "Asset Health", - description: "Live scorecards for monitored bridged assets.", + id: "asset-discovery", + title: "Asset Discovery & Health", + description: "Live scorecards and discovery tables for monitored bridged assets.", + }, + { + id: "watchlist", + title: "Watchlist", + description: "Custom user-curated watchlists and alerts.", + }, + { + id: "external-dependencies", + title: "External Dependencies", + description: "Status of external oracles, RPC providers, and indexers.", + }, + { + id: "activity-timeline", + title: "Recent Activity Timeline", + description: "Chronological transaction, bridge event, and governance timeline.", }, { id: "bridge-status", title: "Bridge Status", - description: "Current bridge availability and anomaly visibility.", + description: "Current bridge availability, mismatch percentage, and operational status.", }, ]; -const defaultLayout: DashboardLayout = { +export const WIDGET_TITLES: Record = Object.fromEntries( + widgetDefinitions.map((w) => [w.id, w.title]) +); + +export const defaultLayout: DashboardLayout = { widgets: [ - { id: "quick-stats", enabled: true, size: "medium" }, - { id: "asset-health", enabled: true, size: "large" }, - { id: "bridge-status", enabled: true, size: "medium" }, + { id: "kpi-banner", enabled: true, visible: true, size: "large", order: 0 }, + { id: "status-cards", enabled: true, visible: true, size: "large", order: 1 }, + { id: "overview-stats", enabled: true, visible: true, size: "large", order: 2 }, + { id: "sparkline-grid", enabled: true, visible: true, size: "medium", order: 3 }, + { id: "asset-discovery", enabled: true, visible: true, size: "large", order: 4 }, + { id: "watchlist", enabled: true, visible: true, size: "medium", order: 5 }, + { id: "external-dependencies", enabled: true, visible: true, size: "medium", order: 6 }, + { id: "activity-timeline", enabled: true, visible: true, size: "large", order: 7 }, + { id: "bridge-status", enabled: true, visible: true, size: "large", order: 8 }, ], }; @@ -52,38 +115,45 @@ const presets: Record = { default: defaultLayout, compact: { widgets: [ - { id: "quick-stats", enabled: true, size: "small" }, - { id: "asset-health", enabled: true, size: "medium" }, - { id: "bridge-status", enabled: false, size: "small" }, + { id: "kpi-banner", enabled: true, visible: true, size: "small", order: 0 }, + { id: "overview-stats", enabled: true, visible: true, size: "small", order: 1 }, + { id: "asset-discovery", enabled: true, visible: true, size: "medium", order: 2 }, + { id: "bridge-status", enabled: false, visible: false, size: "small", order: 3 }, ], }, operations: { widgets: [ - { id: "bridge-status", enabled: true, size: "large" }, - { id: "asset-health", enabled: true, size: "medium" }, - { id: "quick-stats", enabled: true, size: "small" }, + { id: "bridge-status", enabled: true, visible: true, size: "large", order: 0 }, + { id: "status-cards", enabled: true, visible: true, size: "medium", order: 1 }, + { id: "kpi-banner", enabled: true, visible: true, size: "small", order: 2 }, + { id: "external-dependencies", enabled: true, visible: true, size: "medium", order: 3 }, ], }, analyst: { widgets: [ - { id: "asset-health", enabled: true, size: "large" }, - { id: "quick-stats", enabled: true, size: "medium" }, - { id: "bridge-status", enabled: true, size: "medium" }, + { id: "asset-discovery", enabled: true, visible: true, size: "large", order: 0 }, + { id: "sparkline-grid", enabled: true, visible: true, size: "large", order: 1 }, + { id: "kpi-banner", enabled: true, visible: true, size: "medium", order: 2 }, + { id: "activity-timeline", enabled: true, visible: true, size: "medium", order: 3 }, ], }, }; function sanitizeLayout(layout: DashboardLayout): DashboardLayout { - const seen = new Set(); + const seen = new Set(); const normalized: DashboardWidgetConfig[] = []; + let orderIndex = 0; - for (const widget of layout.widgets) { + for (const widget of layout.widgets ?? []) { if (seen.has(widget.id)) continue; seen.add(widget.id); + const isEnabled = widget.enabled !== undefined ? Boolean(widget.enabled) : (widget.visible !== undefined ? Boolean(widget.visible) : true); normalized.push({ id: widget.id, - enabled: Boolean(widget.enabled), - size: widget.size, + enabled: isEnabled, + visible: isEnabled, + size: widget.size ?? "medium", + order: widget.order ?? orderIndex++, }); } @@ -92,16 +162,20 @@ function sanitizeLayout(layout: DashboardLayout): DashboardLayout { normalized.push({ id: definition.id, enabled: true, + visible: true, size: "medium", + order: orderIndex++, }); } } - return { widgets: normalized }; + return { widgets: normalized.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) }; } export function useDashboardLayout() { const [layout, setLayout] = useLocalStorageState(STORAGE_KEY, defaultLayout); + const [isCustomizing, setIsCustomizing] = useState(false); + const setStoreWidgets = useUserPreferencesStore((s) => s.setDashboardWidgets); const normalizedLayout = useMemo(() => sanitizeLayout(layout), [layout]); @@ -110,50 +184,122 @@ export function useDashboardLayout() { [normalizedLayout], ); - function setWidgetEnabled(id: DashboardWidgetId, enabled: boolean): void { - setLayout((prev) => ({ - widgets: sanitizeLayout(prev).widgets.map((widget) => - widget.id === id ? { ...widget, enabled } : widget, - ), + const flatLayoutList = useMemo(() => { + return normalizedLayout.widgets.map((widget, idx) => ({ + id: widget.id, + visible: widget.enabled, + enabled: widget.enabled, + size: widget.size, + order: widget.order ?? idx, })); + }, [normalizedLayout]); + + const saveLayout = useCallback( + (newWidgets: DashboardWidgetConfig[]) => { + const sanitized = sanitizeLayout({ widgets: newWidgets }); + setLayout(sanitized); + if (setStoreWidgets) { + setStoreWidgets( + sanitized.widgets.map((w, idx) => ({ + id: w.id, + visible: w.enabled, + order: w.order ?? idx, + })) + ); + } + }, + [setLayout, setStoreWidgets] + ); + + function setWidgetEnabled(id: DashboardWidgetId, enabled: boolean): void { + saveLayout( + normalizedLayout.widgets.map((widget) => + widget.id === id ? { ...widget, enabled, visible: enabled } : widget + ) + ); } + const toggleWidgetVisibility = useCallback( + (id: string) => { + const widget = normalizedLayout.widgets.find((w) => w.id === id); + if (widget) { + setWidgetEnabled(widget.id as DashboardWidgetId, !widget.enabled); + } + }, + [normalizedLayout] + ); + function setWidgetSize(id: DashboardWidgetId, size: DashboardWidgetSize): void { - setLayout((prev) => ({ - widgets: sanitizeLayout(prev).widgets.map((widget) => - widget.id === id ? { ...widget, size } : widget, - ), - })); + saveLayout( + normalizedLayout.widgets.map((widget) => + widget.id === id ? { ...widget, size } : widget + ) + ); } + const moveWidget = useCallback( + (id: string, direction: "up" | "down") => { + const current = [...normalizedLayout.widgets]; + const index = current.findIndex((w) => w.id === id); + if (index === -1) return; + const targetIndex = direction === "up" ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= current.length) return; + + const itemA = current[index]; + const itemB = current[targetIndex]; + current[index] = itemB; + current[targetIndex] = itemA; + + const reindexed = current.map((item, idx) => ({ ...item, order: idx })); + saveLayout(reindexed); + }, + [normalizedLayout, saveLayout] + ); + function reorderWidgets(idsInOrder: DashboardWidgetId[]): void { - setLayout((prev) => { - const current = sanitizeLayout(prev).widgets; - const byId = new Map(current.map((widget) => [widget.id, widget])); - const reordered = idsInOrder - .map((id) => byId.get(id)) - .filter((widget): widget is DashboardWidgetConfig => Boolean(widget)); - - for (const widget of current) { - if (!idsInOrder.includes(widget.id)) { - reordered.push(widget); - } + const current = normalizedLayout.widgets; + const byId = new Map(current.map((widget) => [widget.id, widget])); + const reordered: DashboardWidgetConfig[] = []; + let order = 0; + + for (const id of idsInOrder) { + const item = byId.get(id); + if (item) { + reordered.push({ ...item, order: order++ }); } + } - return { widgets: reordered }; - }); + for (const widget of current) { + if (!idsInOrder.includes(widget.id)) { + reordered.push({ ...widget, order: order++ }); + } + } + + saveLayout(reordered); } function applyPreset(name: keyof typeof presets): void { - setLayout(presets[name]); + saveLayout(presets[name]?.widgets ?? defaultLayout.widgets); } function resetToDefault(): void { - setLayout(defaultLayout); + saveLayout(defaultLayout.widgets); } + const resetLayout = useCallback(() => { + resetToDefault(); + }, []); + + const isWidgetVisible = useCallback( + (id: string) => { + const widget = normalizedLayout.widgets.find((w) => w.id === id); + return widget ? widget.enabled : true; + }, + [normalizedLayout] + ); + function exportLayout(): string { - return JSON.stringify(sanitizeLayout(normalizedLayout), null, 2); + return JSON.stringify(normalizedLayout, null, 2); } function importLayout(payload: string): { ok: boolean; message: string } { @@ -162,7 +308,7 @@ export function useDashboardLayout() { if (!parsed || !Array.isArray(parsed.widgets)) { return { ok: false, message: "Invalid layout payload" }; } - setLayout(sanitizeLayout(parsed)); + saveLayout(parsed.widgets); return { ok: true, message: "Layout imported successfully" }; } catch { return { ok: false, message: "Unable to parse layout JSON" }; @@ -170,14 +316,21 @@ export function useDashboardLayout() { } return { - layout: normalizedLayout, + layout: flatLayoutList, + rawLayout: normalizedLayout, widgetDefinitions, enabledWidgets, + isCustomizing, + setIsCustomizing, + toggleWidgetVisibility, + moveWidget, setWidgetEnabled, setWidgetSize, reorderWidgets, applyPreset, resetToDefault, + resetLayout, + isWidgetVisible, exportLayout, importLayout, }; diff --git a/frontend/src/pages/Dashboard.test.tsx b/frontend/src/pages/Dashboard.test.tsx index 684cfce0..ab1549f3 100644 --- a/frontend/src/pages/Dashboard.test.tsx +++ b/frontend/src/pages/Dashboard.test.tsx @@ -1,295 +1,558 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import React from "react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { MemoryRouter } from "react-router-dom"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { vi, describe, it, expect, beforeEach } from "vitest"; import Dashboard from "./Dashboard"; -import { WatchlistProvider } from "../hooks/useWatchlist"; import type { AssetWithHealth, Bridge } from "../types"; -// Mock i18next -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), +// Mock subcomponents to keep test focused on Dashboard page orchestration and interactions +vi.mock("../components/LiveUpdatePill", () => ({ + LiveUpdatePill: ({ polling }: { polling?: boolean }) => ( +
LiveUpdate: {polling ? "polling" : "idle"}
+ ), })); -vi.mock("../contexts/WebSocketContextValue", () => ({ - useWebSocketContext: () => ({ - connectionState: "connected", - isPollingFallback: false, - send: vi.fn(), - subscribe: vi.fn(() => vi.fn()), - }), +vi.mock("../components/PullToRefresh", () => ({ + default: ({ isRefreshing }: { isRefreshing?: boolean }) => ( +
PTR: {isRefreshing ? "refreshing" : "idle"}
+ ), })); -vi.mock("../hooks/useWebSocket", () => ({ - useWebSocket: () => ({ - isConnected: true, - connectionState: "connected", - lastMessage: null, - sendMessage: vi.fn(), - subscribe: vi.fn(() => vi.fn()), - }), +vi.mock("../components/Filters/AssetFilterPanel", () => ({ + default: ({ onClearAll, onStatusChange, filters, hasActiveFilters }: any) => ( +
+ Active Filters: {hasActiveFilters ? "yes" : "no"} + + +
+ ), })); +vi.mock("../components/Filters/FilterPresetsMenu", () => ({ + default: ({ onSavePreset, onApplyPreset }: any) => ( +
+ +
+ ), +})); + +vi.mock("../components/dashboard/KpiBanner", () => ({ + default: ({ items, onDrilldown, onInspectMetric }: any) => ( +
+ {items.map((item: any) => ( +
+ {item.label}: {item.value} + + +
+ ))} +
+ ), +})); + +vi.mock("../components/dashboard/InlineStatusCards", () => ({ + default: ({ assets, bridges }: any) => ( +
+ Assets Count: {assets?.length ?? 0} + Bridges Count: {bridges?.length ?? 0} +
+ ), +})); + +vi.mock("../components/SummaryCard", () => ({ + SummaryCard: ({ title, value, loading, href }: any) => ( +
+ {title} + {loading ? "Loading..." : String(value)} +
+ ), +})); + +vi.mock("../components/analytics/ComparativeSparklineGrid", () => ({ + default: ({ items }: any) => ( +
+ Sparkline count: {items?.length ?? 0} +
+ ), +})); + +vi.mock("../components/dashboard/AssetDiscoverySection", () => ({ + default: ({ assets, isLoading }: any) => ( +
+ Discovery Loading: {isLoading ? "true" : "false"} + Assets: {assets?.length ?? 0} +
+ ), +})); + +vi.mock("../components/watchlist/WatchlistWidget", () => ({ + default: () =>
Watchlist Widget
, +})); + +vi.mock("../components/dashboard/ExternalDependencyPanel", () => ({ + default: () =>
External Dependency Panel
, +})); + +vi.mock("../components/timeline", () => ({ + RecentActivityTimeline: ({ sourceOptions }: any) => ( +
+ Timeline Options: {sourceOptions?.length ?? 0} +
+ ), +})); + +vi.mock("../components/BridgeStatusCard", () => ({ + default: ({ name, status, totalValueLocked, topRight }: any) => ( +
+ {name} + {status} + ${totalValueLocked} + {topRight} +
+ ), +})); + +vi.mock("../components/favorites/FavoriteTagChip", () => ({ + default: ({ label, active, onToggle }: any) => ( + + ), +})); + +vi.mock("../components/ExportPickerDialog", () => ({ + default: ({ open, onClose }: any) => + open ? ( +
+ Export Dialog + +
+ ) : null, +})); + +vi.mock("../components/dashboard/DashboardSharingModal", () => ({ + default: ({ open, onClose, currentUrl }: any) => + open ? ( +
+ Share Modal: {currentUrl} + +
+ ) : null, +})); + +vi.mock("../components/dashboard/DrilldownDrawer", () => ({ + default: ({ open, onClose, context }: any) => + open ? ( +
+ Drilldown: {context?.title} + +
+ ) : null, +})); + +vi.mock("../components/dashboard/MetricsInspectorDrawer", () => ({ + default: ({ open, onClose, metric }: any) => + open ? ( +
+ Inspector: {metric?.label} + +
+ ) : null, +})); + +vi.mock("../components/asset/AssetInsightsTray", () => ({ + default: ({ open, symbol, onClose }: any) => + open ? ( +
+ Tray: {symbol} + +
+ ) : null, +})); + +vi.mock("../components/dashboard/DashboardTour", () => ({ + default: ({ activeStep, onFinish }: any) => + activeStep >= 0 ? ( +
+ Tour Active Step: {activeStep} + +
+ ) : null, +})); + +// Mock data fixtures const mockAssets: AssetWithHealth[] = [ { symbol: "USDC", name: "USD Coin", - category: "stablecoin", + issuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + type: "credit_alphanum4", + supply: 50000000, + accountsCount: 12000, + paymentsCount: 45000, health: { - symbol: "USDC", - overallScore: 95, - factors: { - liquidityDepth: 95, - priceStability: 98, - bridgeUptime: 99, - reserveBacking: 100, - volumeTrend: 90, - }, - trend: "stable", - lastUpdated: "2026-09-27T12:00:00Z", + overallScore: 92, + trend: "improving", + lastUpdated: new Date().toISOString(), + factors: [], + }, + }, + { + symbol: "EURC", + name: "Euro Coin", + issuer: "GAV456...", + type: "credit_alphanum4", + supply: 10000000, + accountsCount: 3000, + paymentsCount: 12000, + health: { + overallScore: 65, + trend: "deteriorating", + lastUpdated: new Date().toISOString(), + factors: [], }, }, { symbol: "XLM", name: "Stellar Lumens", - category: "native", + type: "native", + supply: 100000000, + accountsCount: 50000, + paymentsCount: 200000, health: { - symbol: "XLM", - overallScore: 65, - factors: { - liquidityDepth: 70, - priceStability: 60, - bridgeUptime: 85, - reserveBacking: 80, - volumeTrend: 65, - }, - trend: "improving", - lastUpdated: "2026-09-27T12:00:00Z", + overallScore: 40, + trend: "stable", + lastUpdated: new Date().toISOString(), + factors: [], }, }, ]; const mockBridges: Bridge[] = [ { + id: "stellar-eth-1", name: "Stellar-Ethereum Bridge", + sourceChain: "Stellar", + targetChain: "Ethereum", status: "healthy", totalValueLocked: 15000000, - supplyOnStellar: 15000000, - supplyOnSource: 15000000, - mismatchPercentage: 0, + mismatchPercentage: 0.05, + lastChecked: new Date().toISOString(), }, { + id: "stellar-polygon-1", name: "Stellar-Polygon Bridge", + sourceChain: "Stellar", + targetChain: "Polygon", status: "degraded", - totalValueLocked: 4500000, - supplyOnStellar: 4300000, - supplyOnSource: 4500000, - mismatchPercentage: 4.44, + totalValueLocked: 5000000, + mismatchPercentage: 2.5, + lastChecked: new Date().toISOString(), + }, + { + id: "stellar-bsc-1", + name: "Stellar-BSC Bridge", + sourceChain: "Stellar", + targetChain: "BSC", + status: "down", + totalValueLocked: 2000000, + mismatchPercentage: 5.0, + lastChecked: new Date().toISOString(), }, ]; +// Mock hooks const mockRefetchAssets = vi.fn().mockResolvedValue(undefined); const mockRefetchBridges = vi.fn().mockResolvedValue(undefined); +const mockToggleFavoriteBridge = vi.fn(); +let mockFavoritesList = ["Stellar-Ethereum Bridge"]; vi.mock("../hooks/useAssets", () => ({ - useAssetsWithHealth: () => ({ + useAssetsWithHealth: vi.fn(() => ({ data: mockAssets, isLoading: false, - isError: false, - error: null, - refetch: mockRefetchAssets, + isFetching: false, dataUpdatedAt: Date.now(), - }), + refetch: mockRefetchAssets, + })), })); vi.mock("../hooks/useBridges", () => ({ - useBridges: () => ({ + useBridges: vi.fn(() => ({ data: { bridges: mockBridges }, isLoading: false, - isError: false, - error: null, - refetch: mockRefetchBridges, + isFetching: false, dataUpdatedAt: Date.now(), - }), + refetch: mockRefetchBridges, + })), })); -const mockToggleFavoriteBridge = vi.fn(); -const mockSetFavoritesFilterMode = vi.fn(); vi.mock("../hooks/useFavorites", () => ({ - useFavorites: () => ({ - favoriteAssets: ["USDC"], - favoriteBridges: ["Stellar-Ethereum Bridge"], - favoritesFilterMode: "all" as const, - setFavoritesFilterMode: mockSetFavoritesFilterMode, - toggleFavoriteAsset: vi.fn(), + useFavorites: vi.fn(() => ({ + favoritesFilterMode: "all", toggleFavoriteBridge: mockToggleFavoriteBridge, - isAssetFavorite: (symbol: string) => symbol === "USDC", - isBridgeFavorite: (name: string) => name === "Stellar-Ethereum Bridge", - }), + favoriteBridges: mockFavoritesList, + })), })); -vi.mock("../hooks/useDashboardTour", () => ({ - useDashboardTour: () => ({ - isActive: false, - step: 0, - startTour: vi.fn(), - nextStep: vi.fn(), - prevStep: vi.fn(), - endTour: vi.fn(), - }), -})); - -function renderDashboard(initialEntries = ["/dashboard"]) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); +describe("Dashboard Page", () => { + let queryClient: QueryClient; - return render( - - - - - - - - ); -} - -describe("Dashboard Page Unit Tests", () => { beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); vi.clearAllMocks(); }); - describe("Initial Render and KPI metrics", () => { - it("renders the dashboard title and header description", () => { + const renderDashboard = (initialSearch = "") => { + return render( + + + + } /> + + + + ); + }; + + describe("Initial Render & Structure", () => { + it("renders page header, title, and live update pill", () => { renderDashboard(); - expect(screen.getByRole("heading", { level: 1, name: "Dashboard" })).toBeInTheDocument(); + + expect(screen.getByRole("heading", { name: "Dashboard", level: 1 })).toBeInTheDocument(); expect( screen.getByText(/Real-time monitoring of bridged assets on the Stellar network/i) ).toBeInTheDocument(); + expect(screen.getByTestId("live-update-pill")).toBeInTheDocument(); }); - it("displays live update indicator and toolbar actions", () => { + it("renders toolbar actions and tour button", () => { renderDashboard(); - expect(screen.getByText("Export data")).toBeInTheDocument(); - expect(screen.getByText("Share view")).toBeInTheDocument(); - expect(screen.getByText("Refresh data")).toBeInTheDocument(); + + expect(screen.getByRole("button", { name: /Take a tour|Replay tour/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Refresh data" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Customize layout" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Export data" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Share view" })).toBeInTheDocument(); + expect(screen.getByTestId("filter-presets-menu")).toBeInTheDocument(); }); - it("renders KPI banner summaries and system health metrics", () => { + it("opens layout customizer when clicking Customize layout button", () => { renderDashboard(); - expect(screen.getAllByText("Total Value Locked")[0]).toBeInTheDocument(); - expect(screen.getAllByText("Monitored Assets")[0]).toBeInTheDocument(); - expect(screen.getAllByText("Active Bridges")[0]).toBeInTheDocument(); - expect(screen.getAllByText("System Health")[0]).toBeInTheDocument(); + + const customizeBtn = screen.getByRole("button", { name: "Customize layout" }); + fireEvent.click(customizeBtn); + + expect(screen.getByRole("heading", { name: "Customize dashboard layout" })).toBeInTheDocument(); + expect(screen.getByText("Reset to default")).toBeInTheDocument(); }); - }); - describe("View Tabs and Filtering", () => { - it("renders tab options for Overview, Assets, and Bridges", () => { + it("renders KPI banner and summary cards with calculated values", () => { renderDashboard(); - expect(screen.getByRole("tab", { name: "Overview" })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Assets" })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Bridges" })).toBeInTheDocument(); + + expect(screen.getByTestId("kpi-banner")).toBeInTheDocument(); + expect(screen.getByTestId("kpi-tvl")).toBeInTheDocument(); + expect(screen.getByTestId("kpi-assets")).toBeInTheDocument(); + expect(screen.getByTestId("kpi-bridges")).toBeInTheDocument(); + expect(screen.getByTestId("kpi-health")).toBeInTheDocument(); + + expect(screen.getByTestId("summary-card-total-value-locked")).toBeInTheDocument(); + expect(screen.getByTestId("summary-card-monitored-assets")).toBeInTheDocument(); + expect(screen.getByTestId("summary-card-active-bridges")).toBeInTheDocument(); + expect(screen.getByTestId("summary-card-system-health")).toBeInTheDocument(); }); - it("switches to Assets tab when clicked", async () => { - const user = userEvent.setup(); + it("renders sparkline grid, asset discovery, and bridge status sections in default overview view", () => { renderDashboard(); - const assetsTab = screen.getByRole("tab", { name: "Assets" }); - await user.click(assetsTab); + expect(screen.getByTestId("sparkline-grid")).toBeInTheDocument(); + expect(screen.getByTestId("asset-discovery-section")).toBeInTheDocument(); + expect(screen.getByTestId("watchlist-widget")).toBeInTheDocument(); + expect(screen.getByTestId("external-dependency-panel")).toBeInTheDocument(); + expect(screen.getByTestId("recent-activity-timeline")).toBeInTheDocument(); + expect(screen.getByText("Bridge Status")).toBeInTheDocument(); + }); + }); + + describe("View Tabs and View Filtering", () => { + it("switches to assets view and hides bridge status section", async () => { + renderDashboard("?dashboard_view=assets"); + + expect(screen.getByTestId("asset-discovery-section")).toBeInTheDocument(); + expect(screen.queryByText("Bridge Status")).not.toBeInTheDocument(); + }); + + it("switches to bridges view and hides asset health section", async () => { + renderDashboard("?dashboard_view=bridges"); - // Asset health cards should be visible - expect(screen.getByText("Asset Health")).toBeInTheDocument(); + expect(screen.getByText("Bridge Status")).toBeInTheDocument(); + expect(screen.queryByTestId("sparkline-grid")).not.toBeInTheDocument(); + expect(screen.queryByTestId("asset-discovery-section")).not.toBeInTheDocument(); }); - it("switches to Bridges tab when clicked", async () => { - const user = userEvent.setup(); + it("allows switching tabs via view tab clicks", async () => { renderDashboard(); - const bridgesTab = screen.getByRole("tab", { name: "Bridges" }); - await user.click(bridgesTab); + const assetsTab = screen.getByRole("tab", { name: "Assets" }); + fireEvent.click(assetsTab); - expect(screen.getAllByText("Stellar-Ethereum Bridge")[0]).toBeInTheDocument(); + // Verify tab selection + expect(assetsTab).toBeInTheDocument(); }); + }); - it("filters bridge list by status dropdown selection", async () => { + describe("Filter interactions & Bridge Status Selection", () => { + it("filters bridges by status dropdown", async () => { renderDashboard(); const statusSelect = screen.getByLabelText("Filter bridges by status"); expect(statusSelect).toBeInTheDocument(); fireEvent.change(statusSelect, { target: { value: "healthy" } }); - expect(screen.getAllByText("Stellar-Ethereum Bridge")[0]).toBeInTheDocument(); + expect(statusSelect).toHaveValue("healthy"); + }); + + it("toggles bridge favorites", async () => { + renderDashboard(); + + const favButton = screen.getByTestId("favorite-Stellar-Ethereum Bridge"); + fireEvent.click(favButton); + + expect(mockToggleFavoriteBridge).toHaveBeenCalledWith("Stellar-Ethereum Bridge"); }); }); - describe("Modals and Drawers", () => { - it("opens and closes the Export picker dialog", async () => { - const user = userEvent.setup(); + describe("Modals, Drawers & Drilldown interactions", () => { + it("opens ExportPickerDialog when clicking Export data button and closes on request", () => { renderDashboard(); - const exportBtn = screen.getByText("Export data"); - await user.click(exportBtn); + const exportBtn = screen.getByRole("button", { name: "Export data" }); + fireEvent.click(exportBtn); - // Dialog opens - expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("export-picker-dialog")).toBeInTheDocument(); - // Close modal - const closeBtn = screen.getByLabelText("Close export dialog"); - await user.click(closeBtn); + const closeExportBtn = screen.getByRole("button", { name: "Close Export" }); + fireEvent.click(closeExportBtn); - await waitFor(() => { - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); + expect(screen.queryByTestId("export-picker-dialog")).not.toBeInTheDocument(); }); - it("opens and closes the Share modal", async () => { - const user = userEvent.setup(); + it("opens DashboardSharingModal when clicking Share view button and closes on request", () => { renderDashboard(); - const shareBtn = screen.getByText("Share view"); - await user.click(shareBtn); + const shareBtn = screen.getByRole("button", { name: "Share view" }); + fireEvent.click(shareBtn); - expect(screen.getByRole("dialog")).toBeInTheDocument(); - expect(screen.getByText("Share dashboard")).toBeInTheDocument(); + expect(screen.getByTestId("dashboard-sharing-modal")).toBeInTheDocument(); - const closeBtn = screen.getByLabelText("Close sharing modal"); - await user.click(closeBtn); + const closeShareBtn = screen.getByRole("button", { name: "Close Share" }); + fireEvent.click(closeShareBtn); - await waitFor(() => { - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); + expect(screen.queryByTestId("dashboard-sharing-modal")).not.toBeInTheDocument(); }); - it("triggers refresh when toolbar refresh button is clicked", async () => { - const user = userEvent.setup(); + it("opens DrilldownDrawer when clicking a drilldown button and closes it", () => { renderDashboard(); - const refreshBtn = screen.getByText("Refresh data"); - await user.click(refreshBtn); + const drilldownBtn = screen.getByRole("button", { name: "Drilldown tvl" }); + fireEvent.click(drilldownBtn); - expect(mockRefetchAssets).toHaveBeenCalled(); - expect(mockRefetchBridges).toHaveBeenCalled(); + expect(screen.getByTestId("drilldown-drawer")).toBeInTheDocument(); + expect(screen.getByText("Drilldown: Total value locked")).toBeInTheDocument(); + + const closeDrilldownBtn = screen.getByRole("button", { name: "Close Drilldown" }); + fireEvent.click(closeDrilldownBtn); + + expect(screen.queryByTestId("drilldown-drawer")).not.toBeInTheDocument(); + }); + + it("opens MetricsInspectorDrawer when clicking Inspect metric", () => { + renderDashboard(); + + const inspectBtn = screen.getByRole("button", { name: "Inspect tvl" }); + fireEvent.click(inspectBtn); + + expect(screen.getByTestId("metrics-inspector-drawer")).toBeInTheDocument(); + expect(screen.getByText("Inspector: Total value locked")).toBeInTheDocument(); + + const closeInspectorBtn = screen.getByRole("button", { name: "Close Inspector" }); + fireEvent.click(closeInspectorBtn); + + expect(screen.queryByTestId("metrics-inspector-drawer")).not.toBeInTheDocument(); + }); + + it("opens drilldown drawer when inspecting bridge card details", () => { + renderDashboard(); + + const inspectBridgeDetailsBtns = screen.getAllByRole("button", { name: "Inspect bridge details" }); + expect(inspectBridgeDetailsBtns.length).toBeGreaterThan(0); + fireEvent.click(inspectBridgeDetailsBtns[0]); + + expect(screen.getByTestId("drilldown-drawer")).toBeInTheDocument(); }); }); - describe("Favorites and Quick Filter", () => { - it("toggles bridge favorite chip", async () => { - const user = userEvent.setup(); + describe("Refresh data & Pull to Refresh", () => { + it("refetches assets and bridges when clicking Refresh data", async () => { renderDashboard(); - const favoriteChip = screen.getByRole("switch", { - name: /Remove Stellar-Ethereum Bridge from favorites/i, + const refreshBtn = screen.getByRole("button", { name: "Refresh data" }); + fireEvent.click(refreshBtn); + + await waitFor(() => { + expect(mockRefetchAssets).toHaveBeenCalled(); + expect(mockRefetchBridges).toHaveBeenCalled(); }); - expect(favoriteChip).toBeInTheDocument(); + }); + }); - await user.click(favoriteChip); - expect(mockToggleFavoriteBridge).toHaveBeenCalledWith("Stellar-Ethereum Bridge"); + describe("Loading & Empty States", () => { + it("handles loading states correctly", async () => { + const { useAssetsWithHealth } = await import("../hooks/useAssets"); + const { useBridges } = await import("../hooks/useBridges"); + + vi.mocked(useAssetsWithHealth).mockReturnValueOnce({ + data: undefined, + isLoading: true, + isFetching: false, + dataUpdatedAt: 0, + refetch: mockRefetchAssets, + } as any); + + vi.mocked(useBridges).mockReturnValueOnce({ + data: undefined, + isLoading: true, + isFetching: false, + dataUpdatedAt: 0, + refetch: mockRefetchBridges, + } as any); + + renderDashboard(); + + expect(screen.getByText("Loading bridges...")).toBeInTheDocument(); + expect(screen.getByTestId("summary-card-total-value-locked")).toHaveTextContent("--"); + expect(screen.getByTestId("summary-card-monitored-assets")).toHaveTextContent("--"); + }); + + it("displays empty state message when no bridges match filters", async () => { + const { useBridges } = await import("../hooks/useBridges"); + + vi.mocked(useBridges).mockReturnValueOnce({ + data: { bridges: [] }, + isLoading: false, + isFetching: false, + dataUpdatedAt: Date.now(), + refetch: mockRefetchBridges, + } as any); + + renderDashboard(); + + expect(screen.getByText("No bridge data available yet.")).toBeInTheDocument(); }); }); }); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 2bef5469..2b05de39 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -8,6 +8,8 @@ import { type DashboardFilters, } from "../hooks/useDashboardFilters"; import { usePullToRefresh } from "../hooks/usePullToRefresh"; +import { useDashboardLayout } from "../hooks/useDashboardLayout"; +import DashboardLayoutCustomizer from "../components/dashboard/DashboardLayoutCustomizer"; import BridgeStatusCard from "../components/BridgeStatusCard"; import WatchlistWidget from "../components/watchlist/WatchlistWidget"; import ExternalDependencyPanel from "../components/dashboard/ExternalDependencyPanel"; @@ -228,6 +230,15 @@ export default function Dashboard() { deletePreset, } = useDashboardFilters(); const tour = useDashboardTour({ stepCount: dashboardTourSteps.length }); + const { + layout, + isCustomizing, + setIsCustomizing, + toggleWidgetVisibility, + moveWidget, + resetLayout, + isWidgetVisible, + } = useDashboardLayout(); const pullToRefresh = usePullToRefresh({ enabled: true, onRefresh: async () => { @@ -535,6 +546,210 @@ export default function Dashboard() { ? `https://bridge-watch.local${location.pathname}${location.search}` : window.location.href; + const renderWidget = (id: string) => { + if (!isWidgetVisible(id)) return null; + + switch (id) { + case "kpi-banner": + return ( +
+ setDrilldown(item.id)} + onInspectMetric={(item) => setInspectedMetricId(item.id)} + /> +
+ ); + + case "status-cards": + return ( +
+ +
+ ); + + case "overview-stats": + return ( +
+

+ Overview +

+
+ sum + b.totalValueLocked, 0) + .toLocaleString() || "0"}` + } + loading={bridgesLoading} + icon="💰" + href="/bridges" + /> + + b.status !== "down").length || 0 + } + loading={bridgesLoading} + icon="🌉" + href="/bridges" + /> + +
+
+ ); + + case "sparkline-grid": + return showAssets ? ( +
+ +
+ ) : null; + + case "asset-discovery": + return showAssets ? ( +
+
+

Asset Health

+
+ {showFilteredAssetEmpty ? ( +
+

No assets match the selected filters.

+ +
+ ) : ( +
+ +
+ )} +
+ ) : null; + + case "watchlist": + return showAssets ? ( +
+ +
+ ) : null; + + case "external-dependencies": + return showAssets ? ( +
+ +
+ ) : null; + + case "activity-timeline": + return ( +
+ +
+ ); + + case "bridge-status": + return showBridges ? ( +
+
+

Bridge Status

+ + View all + +
+ {bridgesLoading ? ( +

Loading bridges...

+ ) : filteredBridges.length > 0 ? ( +
+ {filteredBridges.map((bridge) => ( +
+ toggleFavoriteBridge(bridge.name)} + /> + } + /> + +
+ ))} +
+ ) : ( +
+

+ {bridgeFiltersActive + ? "No bridges match the selected filters." + : "No bridge data available yet."} +

+
+ )} +
+ ) : null; + + default: + return null; + } + }; + return (
Refresh data +
- {/* Overview Stats */} -
- setDrilldown(item.id)} - onInspectMetric={(item) => setInspectedMetricId(item.id)} - /> -
- -
- -
- -
-

- Overview -

-
- sum + b.totalValueLocked, 0) - .toLocaleString() || "0"}` - } - loading={bridgesLoading} - icon="💰" - href="/bridges" - /> - - b.status !== "down").length || 0 - } - loading={bridgesLoading} - icon="🌉" - href="/bridges" - /> - -
-
- - {showAssets ? : null} - - {showAssets ? ( -
-
-

Asset Health

-
- {showFilteredAssetEmpty ? ( -
-

No assets match the selected filters.

- -
- ) : ( -
- -
- )} -
- ) : null} - - {showAssets ? : null} - - {showAssets ? : null} - - {/* Recent Activity Timeline */} -
- -
- - {showBridges ? ( -
-
-

Bridge Status

- - View all - -
- {bridgesLoading ? ( -

Loading bridges...

- ) : filteredBridges.length > 0 ? ( -
- {filteredBridges.map((bridge) => ( -
- toggleFavoriteBridge(bridge.name)} - /> - } - /> - -
- ))} -
- ) : ( -
-

- {bridgeFiltersActive - ? "No bridges match the selected filters." - : "No bridge data available yet."} -

-
- )} -
- ) : null} + {layout.map((item) => renderWidget(item.id))} setSharingOpen(false)} /> + setIsCustomizing(false)} + layout={layout} + onToggleVisibility={toggleWidgetVisibility} + onMoveWidget={moveWidget} + onResetLayout={resetLayout} + /> ; } const defaultPreferences: UserPreferences = { @@ -40,6 +41,7 @@ const defaultPreferences: UserPreferences = { supplyMismatch: 0.1, healthScoreDrop: 10, }, + dashboardWidgets: undefined, }; interface UserPreferencesState extends UserPreferences { @@ -58,6 +60,7 @@ interface UserPreferencesState extends UserPreferences { type: keyof UserPreferences["alertThresholds"], value: number ) => void; + setDashboardWidgets: (widgets: Array<{ id: string; visible: boolean; order: number }>) => void; } export const useUserPreferencesStore = create()( @@ -142,6 +145,10 @@ export const useUserPreferencesStore = create()( `setAlertThreshold/${type}` ); }, + + setDashboardWidgets: (widgets) => { + set({ dashboardWidgets: widgets }, false, "setDashboardWidgets"); + }, }), { name: "bridge-watch-user-preferences", diff --git a/sdk/src/testing.test.ts b/sdk/src/testing.test.ts index 07f930da..a353e613 100644 --- a/sdk/src/testing.test.ts +++ b/sdk/src/testing.test.ts @@ -1,9 +1,12 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createMockEvent, createMockScValString, createMockScValU64, createMockWatchSubscription, + MockBridgeWatchClient, + SCENARIO_PRESETS, + createDeterministicFixture, } from "./testing"; describe("SDK testing helpers", () => { @@ -20,6 +23,7 @@ describe("SDK testing helpers", () => { it("creates mock events", () => { const event = createMockEvent({ contractId: "abc" }); expect(event.contractId).toBe("abc"); + expect(event.ledger).toBe(1000); }); it("creates unsubscribe test stub", () => { @@ -29,3 +33,81 @@ describe("SDK testing helpers", () => { expect(subscription.isClosed()).toBe(true); }); }); + +describe("MockBridgeWatchClient sandbox harness", () => { + it("initializes with default HEALTHY scenario and connects", async () => { + const client = new MockBridgeWatchClient(); + expect(client.getScenario()).toBe("HEALTHY"); + + const health = await client.connect(); + expect(health.connected).toBe(true); + expect(health.latestLedger).toBe(1000); + + const checkHealth = await client.getHealth(); + expect(checkHealth.connected).toBe(true); + + client.disconnect(); + const disconnectedHealth = await client.getHealth(); + expect(disconnectedHealth.connected).toBe(false); + }); + + it("switches scenarios and provides deterministic fixtures", () => { + const client = new MockBridgeWatchClient({ scenario: "DEGRADED" }); + expect(client.getScenario()).toBe("DEGRADED"); + expect(client.getFixtureData().mismatchPercentage).toBe(0.5); + + client.setScenario("CRITICAL_MISMATCH"); + expect(client.getScenario()).toBe("CRITICAL_MISMATCH"); + expect(client.getFixtureData().status).toBe("down"); + expect(client.getFixtureData().mismatchPercentage).toBe(8.0); + + const fixture = createDeterministicFixture("EMERGENCY_PAUSED"); + expect(fixture.isPaused).toBe(true); + }); + + it("advances ledgers and increments sequence on transaction submission", async () => { + const client = new MockBridgeWatchClient({ initialLedger: 5000 }); + expect(await client.getLatestLedger()).toBe(5000); + + client.advanceLedger(10); + expect(await client.getLatestLedger()).toBe(5010); + + const sendRes = await client.sendTransaction({}); + expect(sendRes.status).toBe("SUCCESS"); + expect(await client.getLatestLedger()).toBe(5011); + }); + + it("rejects transactions when in EMERGENCY_PAUSED scenario", async () => { + const client = new MockBridgeWatchClient({ scenario: "EMERGENCY_PAUSED" }); + + await expect(client.simulateTransaction({})).rejects.toThrow(/paused/i); + await expect(client.sendTransaction({})).rejects.toThrow(/paused/i); + await expect(client.queryMethod({ method: "transfer" })).rejects.toThrow(/paused/i); + }); + + it("supports mock event streaming and emits custom events", async () => { + const client = new MockBridgeWatchClient(); + const onEvent = vi.fn(); + + const sub = client.subscribeToEvents({ onEvent }); + + client.emitEvent({ type: "bridge_settlement", amount: "10000" }); + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: "bridge_settlement", amount: "10000" }) + ); + + sub.unsubscribe(); + }); + + it("supports compatibility endpoints in offline sandbox", async () => { + const client = new MockBridgeWatchClient(); + const contract = await client.getApiContract(); + expect(contract.endpoints).toContain("/api/v1/bridges"); + + const capabilities = await client.getApiCapabilities(); + expect(capabilities.features).toContain("mock-sandbox"); + + const versions = await client.getApiVersions(); + expect(versions.current).toBe("1.0.0"); + }); +}); diff --git a/sdk/src/testing.ts b/sdk/src/testing.ts index 8ff3b3ba..c5047a27 100644 --- a/sdk/src/testing.ts +++ b/sdk/src/testing.ts @@ -1,4 +1,25 @@ import * as StellarSdk from "@stellar/stellar-sdk"; +import type { + ApiCapabilities, + ApiContract, + ApiContractSummary, + ApiVersion, +} from "./compatibility"; +import type { + BackoffState, + BridgeWatchSdkConfig, + EventSubscription, + EventSubscriptionOptions, + InvokeContractParams, + QueryContractParams, + SdkHealth, + WebSocketSubscriptionOptions, +} from "./types"; +import { + BridgeWatchConnectionError, + BridgeWatchQueryError, + BridgeWatchTransactionError, +} from "./errors"; export function createMockScValString(value: string): StellarSdk.xdr.ScVal { return StellarSdk.xdr.ScVal.scvString(value); @@ -15,13 +36,13 @@ export function createMockEvent(overrides: Record = {}) { id: `evt_${Date.now()}`, type: "contract", contractId: "mock-contract-id", - ledger: 0, + ledger: 1000, value: {}, ...overrides, }; } -export function createMockWatchSubscription() { +export function createMockWatchSubscription(): EventSubscription & { isClosed: () => boolean } { let closed = false; return { @@ -31,3 +52,310 @@ export function createMockWatchSubscription() { }, }; } + +export type BridgeScenarioPreset = + | "HEALTHY" + | "DEGRADED" + | "CRITICAL_MISMATCH" + | "EMERGENCY_PAUSED" + | "RECOVERY"; + +export interface ScenarioFixtureData { + status: "healthy" | "degraded" | "down" | "paused"; + reserveBalance: string; + issuedSupply: string; + mismatchPercentage: number; + totalValueLocked: number; + averageLatencyMs: number; + isPaused: boolean; + events: Array>; + activeAlerts: Array<{ id: string; severity: "info" | "warning" | "critical"; message: string }>; +} + +export const SCENARIO_PRESETS: Record = { + HEALTHY: { + status: "healthy", + reserveBalance: "10000000", + issuedSupply: "10000000", + mismatchPercentage: 0.0, + totalValueLocked: 10000000, + averageLatencyMs: 45, + isPaused: false, + events: [ + { id: "evt_1", type: "deposit", asset: "USDC", amount: "50000", ledger: 1000 }, + { id: "evt_2", type: "withdraw", asset: "USDC", amount: "20000", ledger: 1005 }, + ], + activeAlerts: [], + }, + DEGRADED: { + status: "degraded", + reserveBalance: "9950000", + issuedSupply: "10000000", + mismatchPercentage: 0.5, + totalValueLocked: 9950000, + averageLatencyMs: 380, + isPaused: false, + events: [ + { id: "evt_deg_1", type: "delayed_transfer", asset: "USDC", amount: "50000", ledger: 1010 }, + { id: "evt_deg_2", type: "mismatch_warning", mismatchPercent: 0.5, ledger: 1012 }, + ], + activeAlerts: [ + { id: "alt_1", severity: "warning", message: "Bridge latency elevated above 300ms" }, + ], + }, + CRITICAL_MISMATCH: { + status: "down", + reserveBalance: "9200000", + issuedSupply: "10000000", + mismatchPercentage: 8.0, + totalValueLocked: 9200000, + averageLatencyMs: 1200, + isPaused: false, + events: [ + { id: "evt_crit_1", type: "reserves_deficit", deficit: "800000", ledger: 1020 }, + { id: "evt_crit_2", type: "anomaly_detected", reason: "unbalanced_drain", ledger: 1021 }, + ], + activeAlerts: [ + { id: "alt_crit_1", severity: "critical", message: "Reserve backing deficit exceeds 5% threshold" }, + ], + }, + EMERGENCY_PAUSED: { + status: "paused", + reserveBalance: "10000000", + issuedSupply: "10000000", + mismatchPercentage: 0.0, + totalValueLocked: 10000000, + averageLatencyMs: 0, + isPaused: true, + events: [ + { id: "evt_pause_1", type: "emergency_stop", caller: "GA_OPERATOR", ledger: 1030 }, + ], + activeAlerts: [ + { id: "alt_pause_1", severity: "critical", message: "Bridge contract operations paused by guardian" }, + ], + }, + RECOVERY: { + status: "healthy", + reserveBalance: "9990000", + issuedSupply: "10000000", + mismatchPercentage: 0.1, + totalValueLocked: 9990000, + averageLatencyMs: 80, + isPaused: false, + events: [ + { id: "evt_rec_1", type: "rebalance_inbound", amount: "790000", ledger: 1040 }, + { id: "evt_rec_2", type: "resumed_normal_operations", ledger: 1045 }, + ], + activeAlerts: [ + { id: "alt_rec_1", severity: "info", message: "Recovery stabilization in progress" }, + ], + }, +}; + +export function createDeterministicFixture(scenario: BridgeScenarioPreset = "HEALTHY"): ScenarioFixtureData { + return JSON.parse(JSON.stringify(SCENARIO_PRESETS[scenario])); +} + +export interface MockBridgeWatchClientOptions extends Partial { + scenario?: BridgeScenarioPreset; + initialLedger?: number; + failureRate?: number; + latencyMs?: number; +} + +export class MockBridgeWatchClient { + private connected = false; + private currentLedger: number; + private scenario: BridgeScenarioPreset; + private fixtureData: ScenarioFixtureData; + private readonly config: Required; + private readonly subscribers: Set<(event: unknown) => void> = new Set(); + private readonly errorSubscribers: Set<(error: Error) => void> = new Set(); + + constructor(options: MockBridgeWatchClientOptions = {}) { + this.scenario = options.scenario ?? "HEALTHY"; + this.fixtureData = createDeterministicFixture(this.scenario); + this.currentLedger = options.initialLedger ?? 1000; + this.config = { + rpcUrl: options.rpcUrl ?? "http://localhost:8000/soroban/rpc", + apiUrl: options.apiUrl ?? "http://localhost:3000", + contractId: options.contractId ?? "CA3D5KRYMCMUZNRCQU63DA3WPRBNQ74Z6SVW64D", + networkPassphrase: options.networkPassphrase ?? "Test SDF Future Network ; October 2022", + allowHttp: options.allowHttp ?? true, + defaultFee: options.defaultFee ?? "100", + defaultTimeoutSeconds: options.defaultTimeoutSeconds ?? 30, + }; + } + + setScenario(scenario: BridgeScenarioPreset): void { + this.scenario = scenario; + this.fixtureData = createDeterministicFixture(scenario); + } + + getScenario(): BridgeScenarioPreset { + return this.scenario; + } + + getFixtureData(): ScenarioFixtureData { + return this.fixtureData; + } + + advanceLedger(count: number = 1): number { + this.currentLedger += count; + return this.currentLedger; + } + + emitEvent(event: Record): void { + const fullEvent = { + id: `evt_mock_${Date.now()}`, + ledger: this.currentLedger, + ...event, + }; + this.fixtureData.events.push(fullEvent); + this.subscribers.forEach((cb) => cb(fullEvent)); + } + + triggerError(error: Error): void { + this.errorSubscribers.forEach((cb) => cb(error)); + } + + async connect(): Promise { + this.connected = true; + return { + connected: true, + rpcUrl: this.config.rpcUrl, + latestLedger: this.currentLedger, + }; + } + + disconnect(): void { + this.connected = false; + } + + async getLatestLedger(): Promise { + return this.currentLedger; + } + + async getHealth(): Promise { + return { + connected: this.connected, + rpcUrl: this.config.rpcUrl, + latestLedger: this.currentLedger, + }; + } + + async getApiContract(version?: ApiVersion): Promise { + return { + version: version ?? "1.0.0", + checksum: "mock-checksum-abc-123", + endpoints: ["/api/v1/bridges", "/api/v1/assets", "/api/v1/health"], + }; + } + + async getApiCapabilities(version?: ApiVersion): Promise { + return { + version: version ?? "1.0.0", + features: ["streaming-exports", "continuous-aggregates", "mock-sandbox"], + }; + } + + async getApiVersions(): Promise<{ current: ApiVersion; versions: ApiContractSummary[] }> { + return { + current: "1.0.0", + versions: [{ version: "1.0.0", releaseDate: "2026-01-01" }], + }; + } + + async queryMethod(params: QueryContractParams): Promise { + if (this.fixtureData.isPaused && params.method === "transfer") { + throw new BridgeWatchQueryError("Bridge is currently paused by emergency stop"); + } + + return { + status: "SUCCESS", + result: { + method: params.method, + scenario: this.scenario, + fixture: this.fixtureData, + ledger: this.currentLedger, + }, + }; + } + + async simulateTransaction(transaction: any): Promise { + if (this.fixtureData.isPaused) { + throw new BridgeWatchTransactionError("Simulation failed: Contract is paused"); + } + + return { + status: "SUCCESS", + minResourceFee: "100", + results: [{ auth: [], xdr: "AAAA" }], + }; + } + + async sendTransaction(signedTransaction: any): Promise { + if (this.fixtureData.isPaused) { + throw new BridgeWatchTransactionError("Transaction failed: Bridge operations are paused"); + } + + this.advanceLedger(1); + return { + status: "SUCCESS", + hash: `tx_hash_${Date.now()}`, + latestLedger: this.currentLedger, + }; + } + + async invokeAndSend(params: InvokeContractParams, signerSecret: string): Promise { + if (!signerSecret) { + throw new BridgeWatchTransactionError("Signer secret is required"); + } + return this.sendTransaction(params); + } + + subscribeToEvents(options: EventSubscriptionOptions): EventSubscription { + const callback = (event: unknown) => options.onEvent(event); + this.subscribers.add(callback); + + if (options.onError) { + this.errorSubscribers.add(options.onError); + } + + // Deliver preset fixture events + setTimeout(() => { + this.fixtureData.events.forEach((event) => options.onEvent(event)); + }, 0); + + return { + unsubscribe: () => { + this.subscribers.delete(callback); + if (options.onError) { + this.errorSubscribers.delete(options.onError); + } + }, + }; + } + + subscribeToEventsWebSocket(options: WebSocketSubscriptionOptions): EventSubscription { + const callback = (event: unknown) => options.onEvent(event); + this.subscribers.add(callback); + + if (options.onError) { + this.errorSubscribers.add(options.onError); + } + + setTimeout(() => { + this.fixtureData.events.forEach((event) => options.onEvent(event)); + }, 0); + + return { + unsubscribe: () => { + this.subscribers.delete(callback); + if (options.onError) { + this.errorSubscribers.delete(options.onError); + } + }, + }; + } +}