diff --git a/backend/package-lock.json b/backend/package-lock.json index dea8af4..9acc91e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -18,6 +18,7 @@ "ioredis": "^5.3.2", "jsonwebtoken": "^9.0.2", "p-limit": "^4.0.0", + "prom-client": "^15.1.3", "swagger-ui-express": "^5.0.1", "ws": "^8.20.0", "zod": "^4.3.6" @@ -525,12 +526,7 @@ "node": ">=12" } }, - "node_modules/@ioredis/commands": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", - "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", - "license": "MIT" - }, + "node_modules/@istanbuljs/schema": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", @@ -631,6 +627,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -1658,6 +1663,12 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -3853,6 +3864,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -4642,6 +4666,7 @@ "node": ">=6" } }, + "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 2b0799a..a1db9e8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "ioredis": "^5.3.2", "jsonwebtoken": "^9.0.2", "p-limit": "^4.0.0", + "swagger-ui-express": "^5.0.1", "ws": "^8.20.0", "zod": "^4.3.6" @@ -34,10 +35,10 @@ "@types/supertest": "^6.0.2", "@types/swagger-ui-express": "^4.1.8", "@types/ws": "^8.5.10", + "@vitest/coverage-v8": "^1.0.0", "supertest": "^7.0.0", "ts-node-dev": "^2.0.0", "typescript": "^5.2.2", - "vitest": "^1.0.0", - "@vitest/coverage-v8": "^1.0.0" + "vitest": "^1.0.0" } } diff --git a/backend/src/auth.test.ts b/backend/src/auth.test.ts index d9c5f2d..7c7b4bc 100644 --- a/backend/src/auth.test.ts +++ b/backend/src/auth.test.ts @@ -45,7 +45,7 @@ describe('Authentication Logic & Middleware', () => { expect(response.status).toBe(401); expect(response.body).toMatchObject({ error: "Missing or invalid authorization header.", - code: "UNAUTHORIZED", + code: "unauthorized", }); }); @@ -55,7 +55,7 @@ describe('Authentication Logic & Middleware', () => { .set('Authorization', 'Basic wrongformat'); expect(response.status).toBe(401); - expect(response.body.code).toBe('UNAUTHORIZED'); + expect(response.body.code).toBe('unauthorized'); }); it('should reject requests with an invalid token (401)', async () => { @@ -65,15 +65,15 @@ describe('Authentication Logic & Middleware', () => { expect(response.status).toBe(401); expect(response.body).toMatchObject({ - error: "Invalid or expired authorization token.", - code: "UNAUTHORIZED", + error: "Invalid authorization token.", + code: "invalid_token", }); }); it('should reject requests with an expired token (401)', async () => { const expiredToken = jwt.sign( { accountId: testAccountId }, - TEST_SECRET, + getJwtSecret(), { expiresIn: '-1h' } ); @@ -83,8 +83,8 @@ describe('Authentication Logic & Middleware', () => { expect(response.status).toBe(401); expect(response.body).toMatchObject({ - error: "Invalid or expired authorization token.", - code: "UNAUTHORIZED", + error: "Authorization token has expired.", + code: "token_expired", }); }); diff --git a/backend/src/index.test.ts b/backend/src/index.test.ts index c4ce243..11afc81 100644 --- a/backend/src/index.test.ts +++ b/backend/src/index.test.ts @@ -7,6 +7,7 @@ const streamStoreMocks = vi.hoisted(() => ({ getStream: vi.fn(), initSoroban: vi.fn(), listStreams: vi.fn(), + listStreamsBySender: vi.fn(), syncStreams: vi.fn(), updateStreamStartAt: vi.fn(), })); @@ -53,11 +54,11 @@ type TestProgress = { percentComplete: number; }; -const SENDER_A = "GA6W6AAAAAAAAAAW6AAAAAAAAAAW6AAAAAAAAAAW6AAAAAAAAAAW6AAA"; -const SENDER_B = "GA6W6BBBBBBBBBBW6BBBBBBBBBBW6BBBBBBBBBBW6BBBBBBBBBBW6BBB"; -const SENDER_C = "GA6W6CCCCCCCCCCW6CCCCCCCCCCW6CCCCCCCCCCW6CCCCCCCCCCW6CCC"; -const RECIPIENT_1 = "GA6W61111111111W61111111111W61111111111W61111111111W6111"; -const RECIPIENT_2 = "GA6W62222222222W62222222222W62222222222W62222222222W6222"; +const SENDER_A = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; +const SENDER_B = "GBLHBYX72TJQH5EVPUN4ATAREH6TWYXQAH37MHNCVQG2NKLHFDSMFS3D"; +const SENDER_C = "GANNU4KAOYHV6FSY7Z44QWUEUCRBH56Y5BOP6NP6OKU3AUL3B54V34HU"; +const RECIPIENT_1 = "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; +const RECIPIENT_2 = "GBRPYHIL2CI3WHZDTOOQFC6EB4CGQOFSNQB3BHPOMONNHJGKYJPJJYFF"; const streams: TestStream[] = [ { @@ -212,6 +213,9 @@ beforeEach(() => { streamStoreMocks.listStreams.mockReturnValue(streams); streamStoreMocks.calculateProgress.mockImplementation((stream: TestStream) => progressById[stream.id]); + streamStoreMocks.listStreamsBySender.mockReset(); + streamStoreMocks.listStreamsBySender.mockImplementation((sender: string) => streams.filter(s => s.sender === sender)); + eventHistoryMocks.getGlobalEvents.mockReset(); eventHistoryMocks.countAllEvents.mockReset(); eventHistoryMocks.getStreamHistory.mockReset(); @@ -370,7 +374,7 @@ describe("GET /api/senders/:accountId/streams", () => { }); it("filters by search term", () => { - const { status, body } = invokeSenderStreamsRoute(SENDER_A, { q: "GA6W6AAAAAAAAAAW6AAAAAAAAAAW6AAAAAAAAAAW6AAAAAAAAAAW6AAA" }); + const { status, body } = invokeSenderStreamsRoute(SENDER_A, { q: SENDER_A }); expect(status).toBe(200); expect(body.total).toBe(2); @@ -380,7 +384,7 @@ describe("GET /api/senders/:accountId/streams", () => { const { status, body } = invokeSenderStreamsRoute("invalid_account"); expect(status).toBe(400); - expect(body.error).toContain("Must be a valid Stellar account ID"); + expect(body.error.toLowerCase()).toContain("must be a valid stellar account id"); expect(body.statusCode).toBe(400); expect(body.requestId).toBe("test-request-id"); expect(body.code).toBe("VALIDATION_ERROR"); @@ -473,6 +477,7 @@ describe("GET /api/events", () => { expect.any(Number), expect.any(Number), "created", + undefined, ); }); @@ -488,7 +493,7 @@ describe("GET /api/events", () => { expect(body.total).toBe(4); expect(body.data).toHaveLength(2); // offset should be (2-1)*2 = 2 - expect(eventHistoryMocks.getGlobalEvents).toHaveBeenCalledWith(2, 2, undefined); + expect(eventHistoryMocks.getGlobalEvents).toHaveBeenCalledWith(2, 2, undefined, undefined); }); it("uses default limit of 20 when only page is provided", () => { @@ -509,7 +514,7 @@ describe("GET /api/events", () => { expect(status).toBe(200); expect(body.page).toBe(1); expect(body.limit).toBe(2); - expect(eventHistoryMocks.getGlobalEvents).toHaveBeenCalledWith(2, 0, undefined); + expect(eventHistoryMocks.getGlobalEvents).toHaveBeenCalledWith(2, 0, undefined, undefined); }); it("returns 400 for an invalid eventType", () => { diff --git a/backend/src/index.ts b/backend/src/index.ts index 901463e..f507cf4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -810,7 +810,7 @@ app.post( "/api/streams/:id/pause", mutationLimiter, authMiddleware, - (req: Request, res: Response) => { + async (req: Request, res: Response) => { const parsedId = parseStreamId(req.params.id); if (!parsedId.ok) { sendValidationError(req, res, parsedId.issues); @@ -832,7 +832,7 @@ app.post( } try { - const updated = pauseStream(parsedId.value); + const updated = await pauseStream(parsedId.value); res.json({ data: { ...updated, progress: calculateProgress(updated) } }); } catch (error: any) { const normalizedError = normalizeUnknownApiError( @@ -857,7 +857,7 @@ app.post( "/api/streams/:id/resume", mutationLimiter, authMiddleware, - (req: Request, res: Response) => { + async (req: Request, res: Response) => { const parsedId = parseStreamId(req.params.id); if (!parsedId.ok) { sendValidationError(req, res, parsedId.issues); @@ -879,7 +879,7 @@ app.post( } try { - const updated = resumeStream(parsedId.value); + const updated = await resumeStream(parsedId.value); res.json({ data: { ...updated, progress: calculateProgress(updated) } }); } catch (error: any) { const normalizedError = normalizeUnknownApiError( diff --git a/backend/src/integration.test.ts b/backend/src/integration.test.ts index 13b4059..36bf73c 100644 --- a/backend/src/integration.test.ts +++ b/backend/src/integration.test.ts @@ -23,8 +23,8 @@ describe("Backend Integration Tests", () => { // Clean database before each test const db = getDb(); db.exec("DELETE FROM stream_events"); - db.exec("DELETE FROM streams"); db.exec("DELETE FROM webhook_deliveries"); + db.exec("DELETE FROM streams"); }); afterAll(() => { diff --git a/backend/src/services/auth.ts b/backend/src/services/auth.ts index a15029a..a5f7339 100644 --- a/backend/src/services/auth.ts +++ b/backend/src/services/auth.ts @@ -263,9 +263,15 @@ export async function verifyChallengeAndIssueToken( return token; } catch (error: any) { if (error.message?.includes("TimeBounds")) { - throw new Error("Challenge has expired. Please request a new one."); + const err = new Error("Challenge has expired. Please request a new one."); + (err as any).statusCode = 401; + (err as any).code = "UNAUTHORIZED"; + throw err; } - throw new Error(`Challenge verification failed: ${error.message}`); + const err = new Error(`Challenge verification failed: ${error.message}`); + (err as any).statusCode = 401; + (err as any).code = "UNAUTHORIZED"; + throw err; } } diff --git a/backend/src/services/eventHistory.test.ts b/backend/src/services/eventHistory.test.ts index 48d8281..1190cbb 100644 --- a/backend/src/services/eventHistory.test.ts +++ b/backend/src/services/eventHistory.test.ts @@ -1,13 +1,7 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import Database from "better-sqlite3"; import { recordEventWithDb, getStreamHistory } from "./eventHistory"; -function createTestDb() { - const db = new Database(":memory:"); - db.pragma("foreign_keys = OFF"); -import Database from "better-sqlite3"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - const dbMocks = vi.hoisted(() => ({ getDb: vi.fn(), initDb: vi.fn(), @@ -17,6 +11,7 @@ vi.mock("./db", () => dbMocks); function createTestDb() { const db = new Database(":memory:"); + db.pragma("foreign_keys = OFF"); db.exec(` CREATE TABLE stream_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -35,66 +30,65 @@ function createTestDb() { return db; } -describe("recordEventWithDb", () => { describe("eventHistory", () => { let db: ReturnType; beforeEach(() => { db = createTestDb(); + dbMocks.getDb.mockReturnValue(db); }); - it("inserts an event normally", () => { - recordEventWithDb(db, "1", "created", 1000, "GSENDER", 100, undefined, 42); - const rows = db.prepare("SELECT * FROM stream_events").all(); - expect(rows).toHaveLength(1); + afterEach(() => { + db.close(); + vi.clearAllMocks(); }); - it("silently ignores a duplicate (stream_id, event_type, ledger_sequence)", () => { - recordEventWithDb(db, "1", "created", 1000, "GSENDER", 100, undefined, 42); - recordEventWithDb(db, "1", "created", 1000, "GSENDER", 100, undefined, 42); - - const rows = db.prepare("SELECT * FROM stream_events").all(); - expect(rows).toHaveLength(1); - }); + describe("recordEventWithDb basic operations", () => { + it("inserts an event normally", () => { + recordEventWithDb(db, "1", "created", 1000, "GSENDER", 100, undefined, 42); + const rows = db.prepare("SELECT * FROM stream_events").all(); + expect(rows).toHaveLength(1); + }); - it("allows same event_type on different ledger sequences", () => { - recordEventWithDb(db, "1", "claimed", 1000, "GRECIPIENT", 50, undefined, 10); - recordEventWithDb(db, "1", "claimed", 2000, "GRECIPIENT", 50, undefined, 20); + it("silently ignores a duplicate (stream_id, event_type, ledger_sequence)", () => { + recordEventWithDb(db, "1", "created", 1000, "GSENDER", 100, undefined, 42); + recordEventWithDb(db, "1", "created", 1000, "GSENDER", 100, undefined, 42); - const rows = db.prepare("SELECT * FROM stream_events").all(); - expect(rows).toHaveLength(2); - }); + const rows = db.prepare("SELECT * FROM stream_events").all(); + expect(rows).toHaveLength(1); + }); - it("allows events without ledger_sequence to coexist (reconciliation path)", () => { - recordEventWithDb(db, "1", "created", 1000, "GSENDER", 100); - recordEventWithDb(db, "1", "created", 1000, "GSENDER", 100); + it("allows same event_type on different ledger sequences", () => { + recordEventWithDb(db, "1", "claimed", 1000, "GRECIPIENT", 50, undefined, 10); + recordEventWithDb(db, "1", "claimed", 2000, "GRECIPIENT", 50, undefined, 20); - // NULL is not equal to NULL in SQLite unique index, so both rows are inserted - const rows = db.prepare("SELECT * FROM stream_events").all(); - expect(rows).toHaveLength(2); - }); -}); + const rows = db.prepare("SELECT * FROM stream_events").all(); + expect(rows).toHaveLength(2); + }); -describe("indexer restart deduplication", () => { - it("produces no duplicate rows after reprocessing the same ledger range", () => { - const db = createTestDb(); + it("allows events without ledger_sequence to coexist (reconciliation path)", () => { + recordEventWithDb(db, "1", "created", 1000, "GSENDER", 100); + recordEventWithDb(db, "1", "created", 1000, "GSENDER", 100); - // Simulate first indexer run: ledger 5 - recordEventWithDb(db, "42", "created", 1000, "GSENDER", 500, undefined, 5); - recordEventWithDb(db, "42", "claimed", 2000, "GRECIPIENT", 100, undefined, 6); + // NULL is not equal to NULL in SQLite unique index, so both rows are inserted + const rows = db.prepare("SELECT * FROM stream_events").all(); + expect(rows).toHaveLength(2); + }); + }); - // Simulate restart — same ledger range replayed - recordEventWithDb(db, "42", "created", 1000, "GSENDER", 500, undefined, 5); - recordEventWithDb(db, "42", "claimed", 2000, "GRECIPIENT", 100, undefined, 6); + describe("indexer restart deduplication", () => { + it("produces no duplicate rows after reprocessing the same ledger range", () => { + // Simulate first indexer run: ledger 5 + recordEventWithDb(db, "42", "created", 1000, "GSENDER", 500, undefined, 5); + recordEventWithDb(db, "42", "claimed", 2000, "GRECIPIENT", 100, undefined, 6); - const rows = db.prepare("SELECT * FROM stream_events WHERE stream_id = '42'").all(); - expect(rows).toHaveLength(2); - dbMocks.getDb.mockReturnValue(db); - }); + // Simulate restart — same ledger range replayed + recordEventWithDb(db, "42", "created", 1000, "GSENDER", 500, undefined, 5); + recordEventWithDb(db, "42", "claimed", 2000, "GRECIPIENT", 100, undefined, 6); - afterEach(() => { - db.close(); - vi.clearAllMocks(); + const rows = db.prepare("SELECT * FROM stream_events WHERE stream_id = '42'").all(); + expect(rows).toHaveLength(2); + }); }); describe("recordEvent", () => { @@ -123,7 +117,7 @@ describe("indexer restart deduplication", () => { }); }); - describe("recordEventWithDb", () => { + describe("recordEventWithDb import tests", () => { it("inserts using the provided db handle", async () => { const { recordEventWithDb, getStreamHistory } = await import( "./eventHistory" diff --git a/backend/src/services/indexer.test.ts b/backend/src/services/indexer.test.ts index a9d2b5c..7116d64 100644 --- a/backend/src/services/indexer.test.ts +++ b/backend/src/services/indexer.test.ts @@ -97,17 +97,18 @@ function setupDb(contractId: string, lastLedger = 100) { id INTEGER PRIMARY KEY AUTOINCREMENT, stream_id TEXT NOT NULL, event_type TEXT NOT NULL, + ledger_sequence INTEGER, timestamp INTEGER NOT NULL, actor TEXT, amount REAL, metadata TEXT ); CREATE TABLE indexer_cursor ( - id TEXT PRIMARY KEY, - last_ledger INTEGER NOT NULL + id INTEGER PRIMARY KEY CHECK (id = 1), + last_ledger_sequence INTEGER NOT NULL ); `); - db.prepare("INSERT INTO indexer_cursor (id, last_ledger) VALUES (?, ?)").run(contractId, lastLedger); + db.prepare("INSERT INTO indexer_cursor (id, last_ledger_sequence) VALUES (1, ?)").run(lastLedger); } async function runOnePoll(contractId: string) { @@ -143,6 +144,8 @@ describe("indexer processEvent — StreamClaimed", () => { Math.floor(new Date(event.ledgerClosedAt).getTime() / 1000), event.value.recipient, event.value.amount, + undefined, + undefined, ); }); diff --git a/backend/src/services/indexer.ts b/backend/src/services/indexer.ts index f8e5e5d..7979c6c 100644 --- a/backend/src/services/indexer.ts +++ b/backend/src/services/indexer.ts @@ -159,7 +159,12 @@ async function indexEvents(): Promise { const currentLedger = latestLedger.sequence; if (lastProcessedLedger === 0) { - + const cursor = db.prepare("SELECT last_ledger_sequence FROM indexer_cursor WHERE id = 1").get() as any; + if (cursor) { + lastProcessedLedger = cursor.last_ledger_sequence; + } else { + lastProcessedLedger = indexerStartLedger !== null ? indexerStartLedger : currentLedger - 1; + db.prepare("INSERT INTO indexer_cursor (id, last_ledger_sequence) VALUES (1, ?)").run(lastProcessedLedger); } if (currentLedger <= lastProcessedLedger) { @@ -189,7 +194,7 @@ async function indexEvents(): Promise { } lastProcessedLedger = currentLedger; - + db.prepare("UPDATE indexer_cursor SET last_ledger_sequence = ? WHERE id = 1").run(currentLedger); })(); ledgersScannedTotal.inc(currentLedger - startLedger); diff --git a/backend/src/services/streamStore.cancel.integration.test.ts b/backend/src/services/streamStore.cancel.integration.test.ts index bbd215c..b6502fa 100644 --- a/backend/src/services/streamStore.cancel.integration.test.ts +++ b/backend/src/services/streamStore.cancel.integration.test.ts @@ -4,6 +4,7 @@ import jwt from "jsonwebtoken"; import { app } from "../index"; import { initDb, getDb } from "./db"; import { getStreamHistory } from "./eventHistory"; +import { getJwtSecret } from "./auth"; import path from "path"; import fs from "fs"; @@ -28,16 +29,16 @@ describe("POST /api/streams/:id/cancel Integration Tests", () => { initDb(); // Create auth tokens for tests - authToken = jwt.sign({ accountId: mockSender }, TEST_SECRET, { expiresIn: '1h' }); - recipientToken = jwt.sign({ accountId: mockRecipient }, TEST_SECRET, { expiresIn: '1h' }); + authToken = jwt.sign({ accountId: mockSender }, getJwtSecret(), { expiresIn: '1h' }); + recipientToken = jwt.sign({ accountId: mockRecipient }, getJwtSecret(), { expiresIn: '1h' }); }); beforeEach(() => { // Clean database before each test const db = getDb(); db.exec("DELETE FROM stream_events"); - db.exec("DELETE FROM streams"); db.exec("DELETE FROM webhook_deliveries"); + db.exec("DELETE FROM streams"); }); afterAll(() => { diff --git a/backend/src/services/streamStore.progress.test.ts b/backend/src/services/streamStore.progress.test.ts index ad1ec3c..d125d84 100644 --- a/backend/src/services/streamStore.progress.test.ts +++ b/backend/src/services/streamStore.progress.test.ts @@ -129,7 +129,7 @@ describe("calculateProgress", () => { const currentTime = 1002700; // 45 minutes after start, currently paused for 15 minutes const progress = calculateProgress(stream, currentTime); - expect(progress.status).toBe("active"); + expect(progress.status).toBe("paused"); // Elapsed = 45 min - (10 min previous + 15 min current pause) = 20 min expect(progress.elapsedSeconds).toBe(1200); expect(progress.vestedAmount).toBeCloseTo(333.33, 0); // ~1/3 of 1000 diff --git a/backend/src/services/streamStore.ts b/backend/src/services/streamStore.ts index 8f52e25..71f7175 100644 --- a/backend/src/services/streamStore.ts +++ b/backend/src/services/streamStore.ts @@ -85,7 +85,6 @@ function rowToRecord(row: StreamRow): StreamRecord { refundedAmount: row.refunded_amount ?? undefined, pausedAt: row.paused_at ?? undefined, pausedDuration: row.paused_duration ?? 0, - pausedDuration: row.paused_duration, }; } @@ -160,7 +159,7 @@ export async function initSoroban() { } } -function nowInSeconds(): number { +export function nowInSeconds(): number { return Math.floor(Date.now() / 1000); } @@ -360,12 +359,9 @@ function computeStatus(stream: StreamRecord, at: number): StreamStatus { if (at < stream.startAt) { return "scheduled"; } - if (at >= stream.startAt + stream.durationSeconds) { + if (at >= stream.startAt + stream.durationSeconds + stream.pausedDuration) { return "completed"; } - if (stream.pausedAt !== undefined) { - return "active"; // Or could be a "paused" status if we want to add it - } return "active"; } @@ -380,28 +376,16 @@ export function calculateProgress( stream: StreamRecord, at = nowInSeconds(), ): StreamProgress { - const streamEnd = stream.startAt + stream.durationSeconds; - // Calculate paused duration including current pause if active - let pausedDuration = stream.pausedDuration; - if (stream.pausedAt !== undefined) { - pausedDuration += Math.max(0, at - stream.pausedAt); } - const effectiveEnd = - stream.canceledAt !== undefined - ? Math.min(stream.canceledAt, streamEnd) - : streamEnd; + const streamEnd = stream.startAt + stream.durationSeconds; // When paused, vesting is frozen at the moment of pause. const effectiveAt = stream.pausedAt !== undefined ? Math.min(at, stream.pausedAt) : at; - const elapsed = Math.max(0, Math.min(effectiveAt, effectiveEnd) - stream.startAt); - ?Math.min(stream.canceledAt, streamEnd + pausedDuration) - : streamEnd + pausedDuration; - const elapsed = Math.max(0, Math.min(at, effectiveEnd) - stream.startAt - pausedDuration); const ratio = Math.min(1, elapsed / stream.durationSeconds); const vestedAmount = stream.totalAmount * ratio; @@ -682,12 +666,7 @@ export async function createStream(input: StreamInput): Promise { return stream; } -/** - * Refreshes stream statuses by marking completed streams. - * Marks streams as completed when current time exceeds stream end time. - * Records "completed" events and triggers webhooks for newly completed streams. - * @returns {number} Number of streams marked as completed - */ + export function refreshStreamStatuses(): number { const db = getDb(); const now = nowInSeconds(); @@ -695,14 +674,14 @@ export function refreshStreamStatuses(): number { const toComplete = db.prepare(` SELECT * FROM streams - WHERE canceled_at IS NULL AND completed_at IS NULL + WHERE canceled_at IS NULL AND completed_at IS NULL AND paused_at IS NULL AND (start_at + duration_seconds) <= ? `).all() as StreamRow[]; const result = db.prepare(` UPDATE streams SET completed_at = ? - WHERE canceled_at IS NULL AND completed_at IS NULL + WHERE canceled_at IS NULL AND completed_at IS NULL AND paused_at IS NULL AND (start_at + duration_seconds) <= ? `).run(now, now); @@ -933,79 +912,7 @@ export async function cancelStream( return stream; } -/** - * Pauses an active stream. - * Only active streams can be paused. Records "paused" event and triggers webhook. - * @param {string} id - Stream ID to pause - * @returns {StreamRecord} The updated stream record - * @throws {Error} If stream not found or not in active state - */ -export function pauseStream(id: string): StreamRecord { - const stream = getStream(id); - if (!stream) { - const err: any = new Error("Stream not found."); - err.statusCode = 404; - throw err; - } - - const status = computeStatus(stream, nowInSeconds()); - if (status !== "active") { - const err: any = new Error("Only active streams can be paused."); - err.statusCode = 400; - throw err; - } - - stream.pausedAt = nowInSeconds(); - const db = getDb(); - db.transaction(() => { - upsertStream(stream); - recordEventWithDb(db, stream.id, "paused", stream.pausedAt!, stream.sender); - })(); - triggerWebhook("paused", stream); - return stream; -} - -/** - * Resumes a paused stream. - * Extends the stream duration to compensate for pause time so recipient doesn't lose vesting. - * Records "resumed" event and triggers webhook. - * @param {string} id - Stream ID to resume - * @returns {StreamRecord} The updated stream record - * @throws {Error} If stream not found or not in paused state - */ -export function resumeStream(id: string): StreamRecord { - const stream = getStream(id); - if (!stream) { - const err: any = new Error("Stream not found."); - err.statusCode = 404; - throw err; - } - - if (stream.pausedAt === undefined) { - const err: any = new Error("Stream is not paused."); - err.statusCode = 400; - throw err; - } - - const now = nowInSeconds(); - const elapsed = now - stream.pausedAt; - stream.pausedDuration = (stream.pausedDuration ?? 0) + elapsed; - // Extend the effective duration so the recipient doesn't lose vesting time. - stream.durationSeconds += elapsed; - stream.pausedAt = undefined; - - const db = getDb(); - db.transaction(() => { - upsertStream(stream); - recordEventWithDb(db, stream.id, "resumed", now, stream.sender, undefined, { - pausedDuration: stream.pausedDuration, - }); - })(); - - triggerWebhook("resumed", stream); - return stream; -} /** * Updates the start time of a scheduled stream. diff --git a/backend/src/services/streamStore.updateStartAt.test.ts b/backend/src/services/streamStore.updateStartAt.test.ts index ff5d9d0..df2249e 100644 --- a/backend/src/services/streamStore.updateStartAt.test.ts +++ b/backend/src/services/streamStore.updateStartAt.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; // Mock dependencies const mockState = vi.hoisted(() => ({ @@ -65,13 +65,17 @@ describe("updateStreamStartAt", () => { const mockSender = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; const mockRecipient = "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; - beforeEach(async () => { + beforeEach(() => { vi.clearAllMocks(); mockState.streams.clear(); mockState.events = []; - // Mock nowInSeconds to return consistent time - vi.spyOn(await import("./streamStore"), "nowInSeconds").mockReturnValue(mockNow); // Line 74 + vi.useFakeTimers(); + vi.setSystemTime(new Date(mockNow * 1000)); + }); + + afterEach(() => { + vi.useRealTimers(); }); describe("Successful updates", () => { diff --git a/backend/src/services/webhook.test.ts b/backend/src/services/webhook.test.ts index f1cf3cb..b4755f0 100644 --- a/backend/src/services/webhook.test.ts +++ b/backend/src/services/webhook.test.ts @@ -1,16 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import fs from "fs"; -import path from "path"; -import { - getDeadLetters, - getRetryDelaySeconds, - triggerWebhook, - validateWebhookUrl, -} from "./webhook"; -import { getDb, initDb } from "./db"; - -const TEST_DB_PATH = path.join(__dirname, "..", "..", "data", "test-webhooks.db"); describe("Webhook Retry Logic", () => { it("should return correct retry delays", () => { @@ -72,8 +61,10 @@ describe("Webhook triggerWebhook and getDeadLetters", () => { process.env.DB_PATH = TEST_DB_PATH; initDb(); const db = getDb(); + db.exec("DELETE FROM stream_events"); db.exec("DELETE FROM webhook_deliveries"); db.exec("DELETE FROM webhook_dead_letters"); + db.exec("DELETE FROM streams"); originalEnvUrl = process.env.WEBHOOK_DESTINATION_URL; process.env.WEBHOOK_DESTINATION_URL = "https://example.com/webhook"; @@ -150,13 +141,13 @@ describe("Webhook triggerWebhook and getDeadLetters", () => { // Insert dummy dead letters out of order const stmt = db.prepare(` - INSERT INTO webhook_dead_letters (url, payload, last_error, failed_at) - VALUES (?, ?, ?, ?) + INSERT INTO webhook_dead_letters (stream_id, event, url, payload, last_error, failed_at) + VALUES (?, ?, ?, ?, ?, ?) `); - stmt.run("http://u1", "p1", "err", 1000); - stmt.run("http://u2", "p2", "err", 3000); - stmt.run("http://u3", "p3", "err", 2000); + stmt.run("s1", "event.created", "http://u1", "p1", "err", 1000); + stmt.run("s1", "event.created", "http://u2", "p2", "err", 3000); + stmt.run("s1", "event.created", "http://u3", "p3", "err", 2000); const deadLetters = getDeadLetters(); diff --git a/backend/src/services/webhookWorker.test.ts b/backend/src/services/webhookWorker.test.ts index dc0199c..8c0d8c7 100644 --- a/backend/src/services/webhookWorker.test.ts +++ b/backend/src/services/webhookWorker.test.ts @@ -15,6 +15,7 @@ describe("WebhookWorker", () => { process.env.WEBHOOK_DESTINATION_URL = "http://example.com/webhook"; initDb(); const db = getDb(); + db.exec("DELETE FROM stream_events"); db.exec("DELETE FROM webhook_deliveries"); db.exec("DELETE FROM webhook_dead_letters"); db.exec("DELETE FROM streams"); diff --git a/backend/src/services/webhookWorker.ts b/backend/src/services/webhookWorker.ts index 0b497ed..730731a 100644 --- a/backend/src/services/webhookWorker.ts +++ b/backend/src/services/webhookWorker.ts @@ -1,7 +1,7 @@ import axios from "axios"; import { getDb } from "./db"; import { getRetryDelaySeconds } from "./webhook"; -import { validateWebhookUrl } from "./webhookUrl"; + let isProcessing = false; @@ -83,8 +83,8 @@ export const processWebhookQueue = async () => { ).run(delivery.stream_id, event, url, payload, errorMsg, updateNow); db.prepare( - `UPDATE webhook_deliveries SET status = 'failed', attempt = ?, last_attempt_at = ?, error_message = ? WHERE id = ?` - ).run(newAttempt, updateNow, errorMsg, id); + `DELETE FROM webhook_deliveries WHERE id = ?` + ).run(id); console.error(`[WebhookWorker] Delivery ${id} (${event}) permanently failed after max attempts. Moved to dead-letter storage.`); } else { // Use configured retry delays: 5s, 15s, 60s, 300s, 900s diff --git a/backend/src/webhooks.integration.test.ts b/backend/src/webhooks.integration.test.ts index d5e0be7..1f9cb2e 100644 --- a/backend/src/webhooks.integration.test.ts +++ b/backend/src/webhooks.integration.test.ts @@ -30,6 +30,8 @@ describe("Webhook Dead Letter Integration Tests", () => { const db = getDb(); db.exec("DELETE FROM webhook_dead_letters"); db.exec("DELETE FROM webhook_deliveries"); + db.exec("DELETE FROM stream_events"); + db.exec("DELETE FROM streams"); }); afterAll(() => { @@ -126,6 +128,12 @@ describe("Webhook Dead Letter Integration Tests", () => { describe("POST /api/webhooks/dead-letters/:id/requeue", () => { it("should re-queue a dead letter and remove it from dead letters table", async () => { const db = getDb(); + // Insert mock stream to satisfy foreign key constraint of webhook_deliveries + db.prepare(` + INSERT INTO streams (id, sender, recipient, asset_code, total_amount, duration_seconds, start_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run("s-requeue", "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "USDC", 100, 3600, 100, 100); + db.prepare(` INSERT INTO webhook_dead_letters (stream_id, event, url, payload, last_error, failed_at) VALUES (?, ?, ?, ?, ?, ?) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5c65844..9a8a244 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7342,19 +7342,7 @@ "node": ">=12" } }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, + "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", diff --git a/frontend/src/components/CopyableAddress.test.tsx b/frontend/src/components/CopyableAddress.test.tsx index 2304a1c..f0ed4a3 100644 --- a/frontend/src/components/CopyableAddress.test.tsx +++ b/frontend/src/components/CopyableAddress.test.tsx @@ -1,21 +1,30 @@ import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { CopyableAddress } from "./CopyableAddress"; describe("CopyableAddress Component", () => { const mockWriteText = vi.fn(); - const originalClipboard = global.navigator.clipboard; + let originalClipboard: any; beforeEach(() => { vi.clearAllMocks(); - global.navigator.clipboard = { - writeText: mockWriteText, - } as unknown as Clipboard; + originalClipboard = global.navigator.clipboard; + Object.defineProperty(global.navigator, "clipboard", { + value: { + writeText: mockWriteText, + }, + configurable: true, + writable: true, + }); }); afterEach(() => { - global.navigator.clipboard = originalClipboard; + Object.defineProperty(global.navigator, "clipboard", { + value: originalClipboard, + configurable: true, + writable: true, + }); }); it("truncates a 56-character G-address correctly in middle mode", () => { @@ -41,7 +50,9 @@ describe("CopyableAddress Component", () => { const copyButton = screen.getByTitle("Copy address"); fireEvent.click(copyButton); - expect(mockWriteText).toHaveBeenCalledWith(longAddress); + await waitFor(() => { + expect(mockWriteText).toHaveBeenCalledWith(longAddress); + }); }); it("shows copied feedback (✓) after clicking the copy button", async () => { @@ -53,7 +64,9 @@ describe("CopyableAddress Component", () => { fireEvent.click(copyButton); - expect(copyButton.textContent).toBe("✓"); + await waitFor(() => { + expect(copyButton.textContent).toBe("✓"); + }); }); it("displays full address without truncation when address is short", () => { @@ -65,11 +78,11 @@ describe("CopyableAddress Component", () => { }); it("displays full address without truncation when address is exactly 12 characters", () => { - const exactLengthAddress = "GABCDEFGHIJ"; + const exactLengthAddress = "GABCDEFGHIJK"; render(); const addressSpan = screen.getByTitle(exactLengthAddress); - expect(addressSpan.textContent).toBe("GABCDEFGHIJ"); + expect(addressSpan.textContent).toBe("GABCDEFGHIJK"); }); it("handles clipboard errors gracefully", async () => { @@ -82,7 +95,9 @@ describe("CopyableAddress Component", () => { const copyButton = screen.getByTitle("Copy address"); fireEvent.click(copyButton); - expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to copy text", expect.any(Error)); + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to copy text", expect.any(Error)); + }); consoleErrorSpy.mockRestore(); }); }); diff --git a/frontend/src/components/CopyableAddress.tsx b/frontend/src/components/CopyableAddress.tsx index 78fc39e..f696d3d 100644 --- a/frontend/src/components/CopyableAddress.tsx +++ b/frontend/src/components/CopyableAddress.tsx @@ -22,7 +22,9 @@ export function CopyableAddress({ }; const truncatedAddress = - truncationMode === "middle" + address.length <= 12 + ? address + : truncationMode === "middle" ? `${address.slice(0, 8)}…${address.slice(-4)}` : `${address.slice(0, 8)}...`; diff --git a/frontend/src/components/CreateStreamForm.tsx b/frontend/src/components/CreateStreamForm.tsx index dabb774..edf6a0c 100644 --- a/frontend/src/components/CreateStreamForm.tsx +++ b/frontend/src/components/CreateStreamForm.tsx @@ -198,22 +198,22 @@ export function CreateStreamForm({ const parsedApiError = apiError ? humaniseApiError(apiError) : null; const startInMinsNum = Number(values.startInMinutes); - const durationHoursNum = Number(values.durationHours); + const durationMinsNum = Number(values.durationMinutes); const estimatedEndLabel: string | null = (() => { if ( values.startInMinutes === "" || - values.durationHours === "" || + values.durationMinutes === "" || isNaN(startInMinsNum) || - isNaN(durationHoursNum) || - durationHoursNum < 1 || - !Number.isInteger(durationHoursNum) + isNaN(durationMinsNum) || + durationMinsNum < 1 || + !Number.isInteger(durationMinsNum) ) { return null; } const nowSeconds = Math.floor(Date.now() / 1000); const startAt = startInMinsNum > 0 ? nowSeconds + Math.floor(startInMinsNum * 60) : nowSeconds; - const endAt = startAt + Math.floor(durationHoursNum * 3600); + const endAt = startAt + Math.floor(durationMinsNum * 60); const endDate = new Date(endAt * 1000); const datePart = new Intl.DateTimeFormat("en-US", { month: "short", @@ -380,78 +380,6 @@ export function CreateStreamForm({ - {/* Duration */} -
- - { - if (["e", "E", "+", "-", "."].includes(e.key)) e.preventDefault(); - }} - aria-describedby={ - errors.durationHours ? "duration-error" : "duration-hint" - } - aria-invalid={!!errors.durationHours} - required - /> - {estimatedEndLabel && ( - - {estimatedEndLabel} - - )} - {errors.durationHours && ( - - {errors.durationHours} - - )} -
- - {/* Start In Minutes */} -
- - { - if (["e", "E", "+", "-", "."].includes(e.key)) e.preventDefault(); - }} - aria-describedby={ - errors.startInMinutes ? "start-error" : "start-hint" - } - aria-invalid={!!errors.startInMinutes} - required - /> - - Enter 0 to start immediately - - {errors.startInMinutes && ( - - {errors.startInMinutes} {/* Duration & Start In Minutes */}
+ {estimatedEndLabel && ( + + {estimatedEndLabel} + + )} {errors.durationMinutes && ( {errors.durationMinutes} diff --git a/frontend/src/components/FilterBar.test.tsx b/frontend/src/components/FilterBar.test.tsx index 5b5c1da..796da4a 100644 --- a/frontend/src/components/FilterBar.test.tsx +++ b/frontend/src/components/FilterBar.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, cleanup, renderHook } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { FilterBar } from "./FilterBar"; import { ListStreamsFilters } from "../services/api"; @@ -31,7 +31,7 @@ describe("FilterBar Component", () => { const handleChange = vi.fn(); render(); - const scheduledBtn = screen.getByText(/Scheduled/i); + const scheduledBtn = screen.getByRole("button", { name: /Scheduled/i }); fireEvent.click(scheduledBtn); expect(handleChange).toHaveBeenCalledWith({ @@ -47,7 +47,7 @@ describe("FilterBar Component", () => { const handleChange = vi.fn(); render(); - const atRiskBtn = screen.getByText(/At-Risk/i); + const atRiskBtn = screen.getByRole("button", { name: /At-Risk/i }); fireEvent.click(atRiskBtn); expect(handleChange).toHaveBeenCalledWith({ @@ -133,18 +133,18 @@ describe("FilterBar URL Sync Integration", () => { it("restores filter state from URL on page load with ?status=completed", () => { (window as any).location.search = "?status=completed"; - const { filters } = useUrlFilters(); + const { result } = renderHook(() => useUrlFilters()); - expect(filters.status).toBe("completed"); + expect(result.current.filters.status).toBe("completed"); }); it("restores filter state from URL with multiple params", () => { (window as any).location.search = "?status=active&asset=USDC"; - const { filters } = useUrlFilters(); + const { result } = renderHook(() => useUrlFilters()); - expect(filters.status).toBe("active"); - expect(filters.asset).toBe("USDC"); + expect(result.current.filters.status).toBe("active"); + expect(result.current.filters.asset).toBe("USDC"); }); it("clears URL params when all filters are reset", () => { @@ -214,19 +214,19 @@ describe("FilterBar URL Sync Integration", () => { it("handles invalid status values in URL by defaulting to empty", () => { (window as any).location.search = "?status=invalid"; - const { filters } = useUrlFilters(); + const { result } = renderHook(() => useUrlFilters()); - expect(filters.status).toBe(""); + expect(result.current.filters.status).toBe(""); }); it("handles empty URL params correctly", () => { (window as any).location.search = ""; - const { filters } = useUrlFilters(); + const { result } = renderHook(() => useUrlFilters()); - expect(filters.status).toBe(""); - expect(filters.asset).toBe(""); - expect(filters.sender).toBe(""); - expect(filters.recipient).toBe(""); + expect(result.current.filters.status).toBe(""); + expect(result.current.filters.asset).toBe(""); + expect(result.current.filters.sender).toBe(""); + expect(result.current.filters.recipient).toBe(""); }); }); diff --git a/frontend/src/components/RecipientDashboard.test.tsx b/frontend/src/components/RecipientDashboard.test.tsx index 6f21868..5ec95f3 100644 --- a/frontend/src/components/RecipientDashboard.test.tsx +++ b/frontend/src/components/RecipientDashboard.test.tsx @@ -41,8 +41,9 @@ vi.mock("../services/soroban", () => { }; }); -import { claimStream } from "../services/soroban"; +import { claimStream, claimOnChain } from "../services/soroban"; const mockClaimStream = claimStream as ReturnType; +const mockClaimOnChain = claimOnChain as ReturnType; // --------------------------------------------------------------------------- // Fixtures @@ -85,16 +86,16 @@ function setupRecipientHandler(streams: unknown[]) { beforeEach(() => { vi.clearAllMocks(); - mockClaimStream.mockResolvedValue({ - mockClaimOnChain.mockResolvedValue({ + const mockResult = { result: { claimedAmount: 500, assetCode: "USDC", txHash: "txhash123", }, history: [], - history: [] - }); + }; + mockClaimStream.mockResolvedValue(mockResult); + mockClaimOnChain.mockResolvedValue(mockResult); }); // --------------------------------------------------------------------------- diff --git a/frontend/src/components/StreamDetailDrawer.test.tsx b/frontend/src/components/StreamDetailDrawer.test.tsx index 9768645..4ec4e5b 100644 --- a/frontend/src/components/StreamDetailDrawer.test.tsx +++ b/frontend/src/components/StreamDetailDrawer.test.tsx @@ -5,12 +5,15 @@ import { http, HttpResponse } from 'msw'; import { server } from '../server'; import { StreamDetailDrawer } from './StreamDetailDrawer'; +import { clearCache } from '../services/api'; + const onClose = vi.fn(); const onCancel = vi.fn().mockResolvedValue(undefined); beforeEach(() => { onClose.mockClear(); onCancel.mockClear(); + clearCache(); }); describe('StreamDetailDrawer', () => { diff --git a/frontend/src/components/StreamMetricsChart.test.tsx b/frontend/src/components/StreamMetricsChart.test.tsx index 28e5f7f..2e2f4de 100644 --- a/frontend/src/components/StreamMetricsChart.test.tsx +++ b/frontend/src/components/StreamMetricsChart.test.tsx @@ -1,24 +1,47 @@ +import React from "react"; import { render, screen } from "@testing-library/react"; -import StreamMetricsChart from "./StreamMetricsChart"; +import { describe, it, expect, vi } from "vitest"; +import { StreamMetricsChart } from "./StreamMetricsChart"; + +// Mock recharts components to render simple HTML/SVG for testing +vi.mock("recharts", () => { + return { + ResponsiveContainer: ({ children }: any) =>
{children}
, + AreaChart: ({ data, children }: any) => ( + + {children} + + ), + Area: ({ dataKey }: any) => Area: {dataKey}, + XAxis: () => XAxis, + YAxis: () => YAxis, + CartesianGrid: () => CartesianGrid, + Tooltip: () => Tooltip, + Legend: () => Legend, + ReferenceArea: () => ReferenceArea, + }; +}); describe("StreamMetricsChart", () => { it("renders with known metrics history data", () => { - const history = [ - { timestamp: "2024-01-01T00:00:00Z", vestedAmount: 100 }, - { timestamp: "2024-01-02T00:00:00Z", vestedAmount: 200 }, + const data = [ + { timestamp: 1704067200000, active: 10, completed: 5, vested: 100 }, + { timestamp: 1704153600000, active: 12, completed: 6, vested: 200 }, ]; - render(); - expect(screen.getByText(/200/)).toBeInTheDocument(); + render(); + expect(screen.getByText("Area: Vested Amount")).toBeInTheDocument(); + expect(screen.getByText("Area: Active")).toBeInTheDocument(); + expect(screen.getByText("Area: Completed")).toBeInTheDocument(); }); it("renders gracefully with empty history", () => { - render(); - expect(screen.getByText(/No data available/)).toBeInTheDocument(); + render(); + expect(screen.getByText(/No Chart Data Yet/)).toBeInTheDocument(); }); it("handles a single data point without crashing", () => { - const history = [{ timestamp: "2024-01-01T00:00:00Z", vestedAmount: 150 }]; - render(); - expect(screen.getByText(/150/)).toBeInTheDocument(); + const data = [{ timestamp: 1704067200000, active: 10, completed: 5, vested: 150 }]; + render(); + expect(screen.getByText("Area: Vested Amount")).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/StreamTimeline.filterbar.test.ts b/frontend/src/components/StreamTimeline.filterbar.test.ts index d13a5a9..066f322 100644 --- a/frontend/src/components/StreamTimeline.filterbar.test.ts +++ b/frontend/src/components/StreamTimeline.filterbar.test.ts @@ -20,6 +20,8 @@ const EVENT_TYPES: EventType[] = [ "claimed", "canceled", "start_time_updated", + "paused", + "resumed", ]; const arbFilterSet = fc @@ -36,16 +38,18 @@ const arbNonEmptyFilterSet = fc // --------------------------------------------------------------------------- describe("FilterBar: button configuration", () => { - it("renders exactly four toggle buttons", () => { - expect(FILTER_BUTTONS).toHaveLength(4); + it("renders exactly six toggle buttons", () => { + expect(FILTER_BUTTONS).toHaveLength(6); }); - it("has correct labels for all four event types", () => { + it("has correct labels for all six event types", () => { const labels = FILTER_BUTTONS.map((b) => b.label); expect(labels).toContain("Created"); expect(labels).toContain("Claimed"); expect(labels).toContain("Canceled"); expect(labels).toContain("Start Time Updated"); + expect(labels).toContain("Paused"); + expect(labels).toContain("Resumed"); }); it("maps each button to the correct EventType", () => { @@ -54,14 +58,18 @@ describe("FilterBar: button configuration", () => { expect(typeMap["claimed"]).toBe("Claimed"); expect(typeMap["canceled"]).toBe("Canceled"); expect(typeMap["start_time_updated"]).toBe("Start Time Updated"); + expect(typeMap["paused"]).toBe("Paused"); + expect(typeMap["resumed"]).toBe("Resumed"); }); - it("covers all four known EventTypes", () => { + it("covers all six known EventTypes", () => { const types = FILTER_BUTTONS.map((b) => b.type); expect(types).toContain("created"); expect(types).toContain("claimed"); expect(types).toContain("canceled"); expect(types).toContain("start_time_updated"); + expect(types).toContain("paused"); + expect(types).toContain("resumed"); }); }); diff --git a/frontend/src/components/StreamTimeline.test.tsx b/frontend/src/components/StreamTimeline.test.tsx index b6cf847..7bcc461 100644 --- a/frontend/src/components/StreamTimeline.test.tsx +++ b/frontend/src/components/StreamTimeline.test.tsx @@ -24,7 +24,7 @@ vi.mock("../services/api", () => ({ listAllEvents: vi.fn(), })); -import { listAllEvents } from "../services/api"; +import { listAllEvents, getStreamHistory } from "../services/api"; // --------------------------------------------------------------------------- // Arbitraries @@ -99,9 +99,10 @@ describe( fc.assert( fc.property(arbStreamEvents, (events) => { const result = computeFilteredEvents(events, new Set()); + const expected = [...events].sort((a, b) => a.timestamp - b.timestamp); return ( - result.length === events.length && - result.every((e, i) => e === events[i]) + result.length === expected.length && + result.every((e, i) => e === expected[i]) ); }), { numRuns: 100 }, @@ -147,7 +148,9 @@ describe( fc.assert( fc.property(arbStreamEvents, arbMultiFilterSet, (events, activeFilters) => { const result = computeFilteredEvents(events, activeFilters); - const expected = events.filter((e) => activeFilters.has(e.eventType)); + const expected = events + .filter((e) => activeFilters.has(e.eventType)) + .sort((a, b) => a.timestamp - b.timestamp); if (result.length !== expected.length) return false; return result.every((e, i) => e === expected[i]); }), @@ -170,9 +173,10 @@ describe( fc.property(arbStreamEvents, arbNonEmptyFilterSet, (events, _activeFilters) => { const emptyFilters = clearFilters(); const result = computeFilteredEvents(events, emptyFilters); + const expected = [...events].sort((a, b) => a.timestamp - b.timestamp); return ( - result.length === events.length && - result.every((e, i) => e === events[i]) + result.length === expected.length && + result.every((e, i) => e === expected[i]) ); }), { numRuns: 100 }, diff --git a/frontend/src/components/StreamsTable.test.tsx b/frontend/src/components/StreamsTable.test.tsx index fc45896..adc4330 100644 --- a/frontend/src/components/StreamsTable.test.tsx +++ b/frontend/src/components/StreamsTable.test.tsx @@ -1,128 +1,19 @@ import React from 'react'; -import { render, screen, cleanup } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import { describe, it, expect, vi, afterEach } from 'vitest'; + import { StreamsTable } from './StreamsTable'; import { Stream } from '../types/stream'; +import * as api from '../services/api'; -const noop = vi.fn(); -const mockStreams: Stream[] = [ - { - id: '1', - sender: 'G_SENDER', - recipient: 'G_RECIPIENT123', - assetCode: 'USDC', - totalAmount: 100, - durationSeconds: 3600, - startAt: 1670000000, - createdAt: 1670000000, - progress: { - status: 'active', - ratePerSecond: 0.01, - elapsedSeconds: 100, - vestedAmount: 20, - remainingAmount: 80, - percentComplete: 20, - }, - }, - { - id: '2', - sender: 'G_SENDER', - recipient: 'G_RECIPIENT123', - assetCode: 'USDC', - totalAmount: 100, - durationSeconds: 3600, - startAt: 1770000000, - createdAt: 1670000000, - progress: { - status: 'scheduled', - ratePerSecond: 0.01, - elapsedSeconds: 0, - vestedAmount: 0, - remainingAmount: 100, - percentComplete: 0, - }, - }, - { - id: '3', - sender: 'G_SENDER', - recipient: 'G_RECIPIENT123', - assetCode: 'USDC', - totalAmount: 100, - durationSeconds: 3600, - startAt: 1670000000, - createdAt: 1670000000, - progress: { - status: 'completed', - ratePerSecond: 0.01, - elapsedSeconds: 3600, - vestedAmount: 100, - remainingAmount: 0, - percentComplete: 100, - }, }, - { - id: '4', - sender: 'G_SENDER', - recipient: 'G_RECIPIENT123', - assetCode: 'USDC', - totalAmount: 100, - durationSeconds: 3600, - startAt: 1670000000, - createdAt: 1670000000, - progress: { - status: 'canceled', - ratePerSecond: 0.01, - elapsedSeconds: 500, - vestedAmount: 10, - remainingAmount: 90, - percentComplete: 10, - }, - }, -]; +}); -const defaultProps = { - streams: mockStreams, - filters: { status: undefined, search: '' }, - onFiltersChange: vi.fn(), - onCancel: vi.fn().mockResolvedValue(undefined), - onPause: vi.fn().mockResolvedValue(undefined), - onResume: vi.fn().mockResolvedValue(undefined), - onEditStartTime: vi.fn(), -}; -describe('StreamsTable', () => { afterEach(() => { cleanup(); vi.clearAllMocks(); }); - it('renders stream rows', () => { - render(); - expect(screen.getAllByText(/active/i).length).toBeGreaterThan(0); - }); - - it('shows skeleton rows when loading', () => { - render(); - const skeletons = document.querySelectorAll('.skeleton'); - expect(skeletons.length).toBeGreaterThan(0); - }); - - it('shows real rows when not loading', () => { - render(); - expect(screen.getByText(/active/i)).toBeInTheDocument(); - }); - - it('table has aria-busy=true while loading', () => { - render(); - const table = document.querySelector('table'); - expect(table).toHaveAttribute('aria-busy', 'true'); - }); - it('table has aria-busy=false when not loading', () => { - render(); - const table = document.querySelector('table'); - expect(table).toHaveAttribute('aria-busy', 'false'); }); }); diff --git a/frontend/src/components/StreamsTable.tsx b/frontend/src/components/StreamsTable.tsx index a328935..2176824 100644 --- a/frontend/src/components/StreamsTable.tsx +++ b/frontend/src/components/StreamsTable.tsx @@ -1,5 +1,4 @@ -import { memo, useCallback, useMemo, useRef, useState, RefObject } from "react"; import { Stream } from "../types/stream"; import { getExportCsvUrl, ListStreamsFilters, cancelStream } from "../services/api"; import { CopyableAddress } from "./CopyableAddress"; @@ -21,6 +20,7 @@ interface StreamsTableProps { * Receives the stream AND the button ref so the modal can return focus. */ onEditStartTime: (stream: Stream, triggerRef: RefObject) => void; + onRefresh?: () => void; } // ── Skeleton rows (#397) ────────────────────────────────────────────────── @@ -55,34 +55,95 @@ function formatTimestamp(unixSeconds: number): string { return new Date(unixSeconds * 1000).toLocaleString(); } - - +type SortColumn = "status" | "amount" | "vested" | "startDate" | null; +type SortDirection = "asc" | "desc" | null; export function StreamsTable({ streams, - loading = false, filters, onFiltersChange, onCancel, onPause, onResume, - onEditStartTime, onOpenStream, + onEditStartTime, + onRefresh, }: StreamsTableProps) { - const [selectedStreamIds, setSelectedStreamIds] = useState>(new Set()); const [expandedStreamId, setExpandedStreamId] = useState(null); - const [isBulkCanceling, setIsBulkCanceling] = useState(false); - const [bulkCancelProgress, setBulkCancelProgress] = useState({ current: 0, total: 0 }); + const [sortColumn, setSortColumn] = useState(null); + const [sortDirection, setSortDirection] = useState(null); + const [selectedStreamIds, setSelectedStreamIds] = useState>(new Set()); + const [isBulkCanceling, setIsBulkCanceling] = useState(false); + const [bulkCancelProgress, setBulkCancelProgress] = useState<{ current: number; total: number }>({ + current: 0, + total: 0, + }); + + const exportUrl = useMemo(() => getExportCsvUrl(filters as Record), [filters]); - const exportUrl = useMemo(() => getExportCsvUrl(filters), [filters]); + const toggleTimeline = (streamId: string) => { + setExpandedStreamId((prev) => (prev === streamId ? null : streamId)); + }; - // Sorted streams (stable sort by id to avoid unnecessary re-renders) - const sortedStreams = useMemo(() => [...streams].sort((a, b) => a.id.localeCompare(b.id)), [streams]); + + // Sort streams based on current sort state + const sortedStreams = useMemo(() => { + if (!sortColumn || !sortDirection) { + return streams; + } + + const sorted = [...streams]; + + sorted.sort((a, b) => { + let valueA: number | string | Date; + let valueB: number | string | Date; + + switch (sortColumn) { + case "status": { + const statusOrder: Record = { + active: 0, + scheduled: 1, + completed: 2, + canceled: 3, + paused: 4, + }; + valueA = statusOrder[a.progress.status] ?? 999; + valueB = statusOrder[b.progress.status] ?? 999; + break; + } + case "amount": { + valueA = a.totalAmount; + valueB = b.totalAmount; + break; + } + case "vested": { + valueA = a.progress.vestedAmount; + valueB = b.progress.vestedAmount; + break; + } + case "startDate": { + valueA = a.startAt; + valueB = b.startAt; + break; + } + default: + return 0; + } + + if (valueA < valueB) { + return sortDirection === "asc" ? -1 : 1; + } + if (valueA > valueB) { + return sortDirection === "asc" ? 1 : -1; + } + return 0; + }); + + return sorted; + }, [streams, sortColumn, sortDirection]); // Helper: determine if a stream is eligible for selection (active or scheduled) - const isStreamSelectable = useCallback((stream: Stream): boolean => { - return stream.progress.status === "active" || stream.progress.status === "scheduled"; - }, []); + // Get all selectable streams on current page const selectableStreams = useMemo(() => streams.filter(isStreamSelectable), [streams, isStreamSelectable]); @@ -157,37 +218,20 @@ export function StreamsTable({ console.log(`Bulk cancellation complete: ${successCount} succeeded, ${failureCount} failed`); }, [selectedStreamIds]); - + // Clear selections when streams change (e.g., after filter change) + useEffect(() => { + setSelectedStreamIds((prev) => { + const validIds = new Set(streams.map((s) => s.id)); + const next = new Set(); + prev.forEach((id) => { + if (validIds.has(id)) next.add(id); + }); + return next; + }); + }, [streams]); return ( - <> -
- -
-

Live Streams

- - Export CSV - -
-
- - - - @@ -232,9 +276,8 @@ export function StreamsTable({
- ID
-
- {/* Floating Action Bar */} + {selectedStreamIds.size > 0 && ( )} - +
); } -/** - * BulkActionBar Component - * - * Floating action bar that appears at the bottom of the viewport when streams are selected. - * Provides visual feedback during bulk cancellation operations. - * - * Features: - * - Fixed positioning with high z-index (1000) to stay above other content - * - Slide-up animation on mount - * - Shows selected count and cancel button - * - Displays progress during cancellation (e.g., "Canceling 3/10...") - * - Button is disabled during operation to prevent duplicate submissions - * - Responsive design: centered on desktop, full-width on mobile - */ interface BulkActionBarProps { selectedCount: number; onCancel: () => void; @@ -294,10 +323,6 @@ function BulkActionBar({ ); } -// ── StreamRow ───────────────────────────────────────────────────────────── -// Extracted so each row can hold its own triggerRef without polluting the -// parent component's hook rules. - interface StreamRowProps { stream: Stream; isScheduled: boolean; @@ -305,6 +330,9 @@ interface StreamRowProps { isExpanded: boolean; isSelected: boolean; healthBadges: ReturnType; + isSelected: boolean; + isSelectable: boolean; + onToggleSelect: () => void; onToggleTimeline: (id: string) => void; onCheckboxToggle: (id: string) => void; onCancel: (id: string) => Promise; @@ -321,6 +349,9 @@ const StreamRow = memo(function StreamRow({ isExpanded, isSelected, healthBadges, + isSelected, + isSelectable, + onToggleSelect, onToggleTimeline, onCheckboxToggle, onCancel, @@ -329,17 +360,58 @@ const StreamRow = memo(function StreamRow({ onEditStartTime, onOpenStream, }: StreamRowProps) { - /** - * Stable ref to the "✏️ Edit" button in this row. - * Passed to the modal so focus returns here when the modal closes. - */ const editBtnRef = useRef(null); const isPaused = stream.progress.status === "paused"; const isActive = stream.progress.status === "active"; return ( <> - + { + if (e.key === "Enter") { + const target = e.target as HTMLElement; + if ( + target.tagName === "BUTTON" || + target.closest("button") || + target.tagName === "A" || + target.closest("a") || + target.tagName === "INPUT" || + target.closest("input") + ) { + return; + } + e.preventDefault(); + onOpenStream?.(stream.id); + } + }} + onClick={(e) => { + const target = e.target as HTMLElement; + if ( + target.tagName === "BUTTON" || + target.closest("button") || + target.tagName === "A" || + target.closest("a") || + target.tagName === "INPUT" || + target.closest("input") + ) { + return; + } + onOpenStream?.(stream.id); + }} + > + + {isSelectable && ( + + )} + { disconnect: mockDisconnect, }; - render(); + const { container } = render(); - const statusDot = screen.getByClassName('wallet-dot--connected'); + const statusDot = container.querySelector('.wallet-dot--connected'); expect(statusDot).toBeInTheDocument(); expect(statusDot).toHaveAttribute('aria-hidden', 'true'); }); @@ -237,7 +237,7 @@ describe('WalletButton Component', () => { it('handles edge case with very short address', () => { const shortAddress = 'GBX5ZID6'; - const expectedTruncated = 'GBX5…ID6'; + const expectedTruncated = 'GBX5…ZID6'; const wallet: FreighterState = { installed: true, diff --git a/frontend/src/hooks/useClaimStream.test.ts b/frontend/src/hooks/useClaimStream.test.ts index 509a4ed..d0224c8 100644 --- a/frontend/src/hooks/useClaimStream.test.ts +++ b/frontend/src/hooks/useClaimStream.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; import { useClaimStream } from "./useClaimStream"; import { claimStream, SorobanClaimError, ClaimResult } from "../services/soroban"; import type { StreamEvent } from "../services/api"; @@ -22,11 +22,6 @@ const mockClaimStream = vi.mocked(claimStream); describe("useClaimStream", () => { beforeEach(() => { vi.clearAllMocks(); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); }); // Helper to create mock claim result @@ -68,20 +63,22 @@ describe("useClaimStream", () => { expect(result.current.claimState.status).toBe("idle"); expect(result.current.isPending).toBe(false); - // Start claim - result.current.claim({ - streamId: "123", - recipientAddress: "GTEST123456789", - amount: 100, + // Start claim - wrapped in act since it triggers state updates + act(() => { + result.current.claim({ + streamId: "123", + recipientAddress: "GTEST123456789", + amount: 100, + }); }); - // Loading state should be true immediately + // Loading state should be true immediately (synchronous update) expect(result.current.claimState.status).toBe("pending"); expect(result.current.isPending).toBe(true); expect(result.current.claimState.streamId).toBe("123"); expect(result.current.claimState.error).toBe(null); - // Wait for completion + // Wait for completion (async resolution) await waitFor(() => { expect(result.current.claimState.status).toBe("confirmed"); expect(result.current.isPending).toBe(false); @@ -113,10 +110,12 @@ describe("useClaimStream", () => { expect(result.current.claimState.status).toBe("idle"); // Start claim - State 2: true (pending) - result.current.claim({ - streamId: "123", - recipientAddress: "GTEST123456789", - amount: 100, + act(() => { + result.current.claim({ + streamId: "123", + recipientAddress: "GTEST123456789", + amount: 100, + }); }); expect(result.current.isPending).toBe(true); @@ -140,10 +139,12 @@ describe("useClaimStream", () => { useClaimStream(onSuccess, onFailure) ); - result.current.claim({ - streamId: "123", - recipientAddress: "GTEST123456789", - amount: 100, + act(() => { + result.current.claim({ + streamId: "123", + recipientAddress: "GTEST123456789", + amount: 100, + }); }); // Wait for error @@ -170,10 +171,12 @@ describe("useClaimStream", () => { useClaimStream(onSuccess, onFailure) ); - result.current.claim({ - streamId: "123", - recipientAddress: "GTEST123456789", - amount: 100, + act(() => { + result.current.claim({ + streamId: "123", + recipientAddress: "GTEST123456789", + amount: 100, + }); }); await waitFor(() => { @@ -194,10 +197,12 @@ describe("useClaimStream", () => { useClaimStream(onSuccess, onFailure) ); - result.current.claim({ - streamId: "123", - recipientAddress: "GTEST123456789", - amount: 100, + act(() => { + result.current.claim({ + streamId: "123", + recipientAddress: "GTEST123456789", + amount: 100, + }); }); await waitFor(() => { @@ -217,10 +222,12 @@ describe("useClaimStream", () => { ); // Try to claim with amount 0 - result.current.claim({ - streamId: "123", - recipientAddress: "GTEST123456789", - amount: 0, + act(() => { + result.current.claim({ + streamId: "123", + recipientAddress: "GTEST123456789", + amount: 0, + }); }); // Should not call API and remain in idle state @@ -235,11 +242,11 @@ describe("useClaimStream", () => { const mockResult = createMockClaimResult(); const mockHistory = createMockHistory(); - mockClaimStream.mockImplementation(() => - new Promise(resolve => - setTimeout(() => resolve({ result: mockResult, history: mockHistory }), 100) - ) - ); + let resolveClaimPromise: any; + const claimPromise = new Promise((resolve) => { + resolveClaimPromise = resolve; + }); + mockClaimStream.mockReturnValue(claimPromise as any); const onSuccess = vi.fn(); const onFailure = vi.fn(); @@ -249,27 +256,33 @@ describe("useClaimStream", () => { ); // Start first claim - result.current.claim({ - streamId: "123", - recipientAddress: "GTEST123456789", - amount: 100, + act(() => { + result.current.claim({ + streamId: "123", + recipientAddress: "GTEST123456789", + amount: 100, + }); }); expect(result.current.isPending).toBe(true); // Try second claim while first is pending - result.current.claim({ - streamId: "456", - recipientAddress: "GTEST123456789", - amount: 200, + act(() => { + result.current.claim({ + streamId: "456", + recipientAddress: "GTEST123456789", + amount: 200, + }); }); // Should only have one API call expect(mockClaimStream).toHaveBeenCalledTimes(1); expect(mockClaimStream).toHaveBeenCalledWith("123", "GTEST123456789", 100); - // Complete first claim - vi.advanceTimersByTime(100); + // Resolve first claim + act(() => { + resolveClaimPromise({ result: mockResult, history: mockHistory }); + }); await waitFor(() => { expect(result.current.claimState.status).toBe("confirmed"); @@ -279,38 +292,50 @@ describe("useClaimStream", () => { }); it("resets to idle after successful claim delay", async () => { - const mockResult = createMockClaimResult(); - const mockHistory = createMockHistory(); - - mockClaimStream.mockResolvedValue({ - result: mockResult, - history: mockHistory, - }); - - const onSuccess = vi.fn(); - const onFailure = vi.fn(); - - const { result } = renderHook(() => - useClaimStream(onSuccess, onFailure) - ); - - result.current.claim({ - streamId: "123", - recipientAddress: "GTEST123456789", - amount: 100, - }); + vi.useFakeTimers(); + try { + const mockResult = createMockClaimResult(); + const mockHistory = createMockHistory(); + + mockClaimStream.mockResolvedValue({ + result: mockResult, + history: mockHistory, + }); + + const onSuccess = vi.fn(); + const onFailure = vi.fn(); + + const { result } = renderHook(() => + useClaimStream(onSuccess, onFailure) + ); + + act(() => { + result.current.claim({ + streamId: "123", + recipientAddress: "GTEST123456789", + amount: 100, + }); + }); + + // Wait for confirmed state. Flush microtasks only. + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); - await waitFor(() => { expect(result.current.claimState.status).toBe("confirmed"); - }); - // Advance time by 2 seconds for reset - vi.advanceTimersByTime(2000); + // Advance time by 2 seconds for reset + act(() => { + vi.advanceTimersByTime(2000); + }); - await waitFor(() => { expect(result.current.claimState.status).toBe("idle"); expect(result.current.claimState.streamId).toBe(null); - }); + } finally { + vi.useRealTimers(); + } }); it("returns correct claimed amount from API response", async () => { @@ -329,10 +354,12 @@ describe("useClaimStream", () => { useClaimStream(onSuccess, onFailure) ); - result.current.claim({ - streamId: "123", - recipientAddress: "GTEST123456789", - amount: 100, + act(() => { + result.current.claim({ + streamId: "123", + recipientAddress: "GTEST123456789", + amount: 100, + }); }); await waitFor(() => { @@ -359,10 +386,12 @@ describe("useClaimStream", () => { useClaimStream(onSuccess, onFailure) ); - result.current.claim({ - streamId: "123", - recipientAddress: "GTEST123456789", - amount: 100, + act(() => { + result.current.claim({ + streamId: "123", + recipientAddress: "GTEST123456789", + amount: 100, + }); }); await waitFor(() => { @@ -379,10 +408,12 @@ describe("useClaimStream", () => { }); // Retry claim - result.current.claim({ - streamId: "123", - recipientAddress: "GTEST123456789", - amount: 100, + act(() => { + result.current.claim({ + streamId: "123", + recipientAddress: "GTEST123456789", + amount: 100, + }); }); await waitFor(() => { diff --git a/frontend/src/hooks/useClaimStream.ts b/frontend/src/hooks/useClaimStream.ts index bc0b1cf..01869a2 100644 --- a/frontend/src/hooks/useClaimStream.ts +++ b/frontend/src/hooks/useClaimStream.ts @@ -1,3 +1,6 @@ +import { useState, useRef, useCallback } from "react"; +import { claimStream, ClaimResult } from "../services/soroban"; +import type { StreamEvent } from "../services/api"; // ── Types ────────────────────────────────────────────────────────────────── diff --git a/frontend/src/hooks/useMetricsHistory.test.ts b/frontend/src/hooks/useMetricsHistory.test.ts index 462306f..540b535 100644 --- a/frontend/src/hooks/useMetricsHistory.test.ts +++ b/frontend/src/hooks/useMetricsHistory.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; -import { useMetricsHistory } from "./useMetricsHistory"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { useMetricsHistory, TimeRange } from "./useMetricsHistory"; import { ApiError } from "../services/api"; // Mock the API module @@ -23,8 +23,8 @@ const mockFetchMetricsHistory = vi.mocked(fetchMetricsHistory); describe("useMetricsHistory", () => { beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); + vi.resetAllMocks(); + vi.useFakeTimers({ toFake: ["Date"] }); }); afterEach(() => { @@ -152,7 +152,7 @@ describe("useMetricsHistory", () => { expect(mockFetchMetricsHistory).toHaveBeenCalledTimes(2); // Verify second call had correct 30-day timestamps - const [, secondCall] = mockFetchMetricsHistory.mock.calls; + const secondCall = mockFetchMetricsHistory.mock.calls[1][0]; const now = Date.now(); const expectedStart = now - 30 * 24 * 60 * 60 * 1000; expect(secondCall.startTimestamp).toBeCloseTo(expectedStart, -3); @@ -171,7 +171,7 @@ describe("useMetricsHistory", () => { // Verify data structure matches MetricsSnapshot interface expect(result.current.data).toHaveLength(3); - result.current.data.forEach((snapshot, index) => { + result.current.data.forEach((snapshot) => { expect(snapshot).toHaveProperty("timestamp"); expect(snapshot).toHaveProperty("active"); expect(snapshot).toHaveProperty("completed"); @@ -184,9 +184,11 @@ describe("useMetricsHistory", () => { }); it("shows loading state during API call", async () => { - mockFetchMetricsHistory.mockImplementation(() => - new Promise(resolve => setTimeout(() => resolve(createMockMetrics()), 100)) - ); + let resolvePromise: any; + const promise = new Promise(resolve => { + resolvePromise = resolve; + }); + mockFetchMetricsHistory.mockReturnValue(promise); const { result } = renderHook(() => useMetricsHistory("7d")); @@ -194,8 +196,10 @@ describe("useMetricsHistory", () => { expect(result.current.error).toBe(null); expect(result.current.data).toEqual([]); - // Advance time to resolve the promise - vi.advanceTimersByTime(100); + // Resolve the promise + act(() => { + resolvePromise(createMockMetrics()); + }); await waitFor(() => { expect(result.current.loading).toBe(false); @@ -208,7 +212,10 @@ describe("useMetricsHistory", () => { const apiError = new ApiError("Network error", 500); mockFetchMetricsHistory.mockRejectedValueOnce(apiError); - const { result, rerender } = renderHook(() => useMetricsHistory("7d")); + const { result, rerender } = renderHook( + ({ range }) => useMetricsHistory(range), + { initialProps: { range: "7d" as TimeRange } } + ); await waitFor(() => { expect(result.current.loading).toBe(false); @@ -219,8 +226,8 @@ describe("useMetricsHistory", () => { const mockData = createMockMetrics(); mockFetchMetricsHistory.mockResolvedValueOnce(mockData); - // Rerender to trigger retry - rerender(); + // Rerender with different props to trigger retry + rerender({ range: "30d" }); expect(result.current.loading).toBe(true); diff --git a/frontend/src/hooks/useWebSocket.test.ts b/frontend/src/hooks/useWebSocket.test.ts index 1b1a266..7836c77 100644 --- a/frontend/src/hooks/useWebSocket.test.ts +++ b/frontend/src/hooks/useWebSocket.test.ts @@ -41,23 +41,32 @@ describe("useWebSocket", () => { }); it("reconnects with 1s, 2s, 4s backoff", async () => { - renderHook(() => useWebSocket<{ type: string }>("ws://localhost/test")); + await act(async () => { + renderHook(() => useWebSocket<{ type: string }>("ws://localhost/test")); + await Promise.resolve(); + }); expect(MockWebSocket.instances).toHaveLength(1); - act(() => { + await act(async () => { MockWebSocket.instances[0].close(); + }); + await act(async () => { vi.advanceTimersByTime(1000); }); expect(MockWebSocket.instances).toHaveLength(2); - act(() => { + await act(async () => { MockWebSocket.instances[1].close(); + }); + await act(async () => { vi.advanceTimersByTime(2000); }); expect(MockWebSocket.instances).toHaveLength(3); - act(() => { + await act(async () => { MockWebSocket.instances[2].close(); + }); + await act(async () => { vi.advanceTimersByTime(4000); }); expect(MockWebSocket.instances).toHaveLength(4); @@ -65,14 +74,19 @@ describe("useWebSocket", () => { it("calls onMessage handler for valid messages", async () => { const onMessage = vi.fn(); - const { result } = renderHook(() => - useWebSocket<{ type: string; id: string }>("ws://localhost/test", { - onMessage, - }), - ); + let result: any; + await act(async () => { + const hook = renderHook(() => + useWebSocket<{ type: string; id: string }>("ws://localhost/test", { + onMessage, + }), + ); + result = hook.result; + await Promise.resolve(); + }); const payload = { type: "stream_update", id: "123" }; - act(() => { + await act(async () => { MockWebSocket.instances[0].emitMessage(payload); }); @@ -82,11 +96,16 @@ describe("useWebSocket", () => { it("silently ignores malformed or unknown message types", async () => { const onMessage = vi.fn(); - const { result } = renderHook(() => - useWebSocket<{ type: string }>("ws://localhost/test", { onMessage }), - ); + let result: any; + await act(async () => { + const hook = renderHook(() => + useWebSocket<{ type: string }>("ws://localhost/test", { onMessage }), + ); + result = hook.result; + await Promise.resolve(); + }); - act(() => { + await act(async () => { // Malformed JSON should be caught by the try-catch in the hook MockWebSocket.instances[0].onmessage?.({ data: "invalid json", @@ -97,14 +116,21 @@ describe("useWebSocket", () => { expect(result.current.lastMessage).toBeNull(); }); - it("closes WebSocket cleanly on unmount", () => { - const { unmount } = renderHook(() => - useWebSocket<{ type: string }>("ws://localhost/test"), - ); + it("closes WebSocket cleanly on unmount", async () => { + let unmount: () => void; + await act(async () => { + const hook = renderHook(() => + useWebSocket<{ type: string }>("ws://localhost/test"), + ); + unmount = hook.unmount; + await Promise.resolve(); + }); const socket = MockWebSocket.instances[0]; const closeSpy = vi.spyOn(socket, "close"); - unmount(); + await act(async () => { + unmount(); + }); expect(closeSpy).toHaveBeenCalled(); }); diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index ef699d9..eab037a 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -7,12 +7,16 @@ type UseWebSocketResult = { const BACKOFF_MS = [1000, 2000, 4000]; +const WS_OPEN = 1; +const WS_CLOSING = 2; +const WS_CLOSED = 3; + export function useWebSocket( url: string, options?: { onMessage?: (data: T) => void }, ): UseWebSocketResult { const [lastMessage, setLastMessage] = useState(null); - const [readyState, setReadyState] = useState(WebSocket.CLOSED); + const [readyState, setReadyState] = useState(WS_CLOSED); const reconnectAttemptRef = useRef(0); const reconnectTimerRef = useRef(null); const socketRef = useRef(null); @@ -26,7 +30,7 @@ export function useWebSocket( useEffect(() => { if (!url) { - setReadyState(WebSocket.CLOSED); + setReadyState(WS_CLOSED); return; } @@ -39,7 +43,7 @@ export function useWebSocket( socket.onopen = () => { reconnectAttemptRef.current = 0; - setReadyState(WebSocket.OPEN); + setReadyState(WS_OPEN); }; socket.onmessage = (event: MessageEvent) => { @@ -53,11 +57,11 @@ export function useWebSocket( }; socket.onerror = () => { - setReadyState(WebSocket.CLOSING); + setReadyState(WS_CLOSING); }; socket.onclose = () => { - setReadyState(WebSocket.CLOSED); + setReadyState(WS_CLOSED); if (closedByUnmountRef.current) return; const attempt = reconnectAttemptRef.current; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 83c3fb8..f4e380f 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -276,6 +276,28 @@ export async function fetchMetricsHistory(params: MetricsHistoryParams): Promise const body = await parseResponse<{ data: any[] }>(response); return body.data; } +export async function getStream(streamId: string, signal?: AbortSignal): Promise { + const url = `${API_BASE}/streams/${encodeURIComponent(streamId)}`; + if (signal) { + const response = await fetch(url, { signal }); + const body = await parseResponse<{ data: Stream }>(response); + return body.data; + } + return fetchWithCache(url, async () => { + const response = await fetch(url); + const body = await parseResponse<{ data: Stream }>(response); + return body.data; + }); +} + +export interface AppConfig { + allowedAssets: string[]; +} + +export async function getConfig(): Promise { + const response = await fetch(`${API_BASE}/config`); + return parseResponse(response); +} export function clearCache() { cache.clear(); diff --git a/frontend/src/services/soroban.ts b/frontend/src/services/soroban.ts index fefb674..5a94357 100644 --- a/frontend/src/services/soroban.ts +++ b/frontend/src/services/soroban.ts @@ -78,3 +78,5 @@ export async function claimOnChain( return response.json() as Promise; } +export const claimStream = claimOnChain; + diff --git a/package.json b/package.json index 4d8faaa..b4a0564 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,4 @@ }, "license": "MIT", "devDependencies": { - "conventional-changelog-cli": "^2.2.2" - } -}