-
Notifications
You must be signed in to change notification settings - Fork 20
feat: implement StreamCreated handler #43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
d3vobed
wants to merge
2
commits into
Fundable-Protocol:dev
Choose a base branch
from
d3vobed:feat/stream-created-handler
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+295
−0
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| import { describe, expect, test } from "vitest"; | ||
|
|
||
| import { | ||
| STREAM_CREATED_TOPIC, | ||
| getEventIdentity, | ||
| handleStreamCreated, | ||
| mapStreamCreatedToRecord, | ||
| parseStreamCreatedPayload, | ||
| } from "./streamCreated.js"; | ||
| import type { StreamCreatedEvent } from "./types.js"; | ||
|
|
||
| function createMockEvent(overrides?: Partial<StreamCreatedEvent>): StreamCreatedEvent { | ||
| return { | ||
| contractId: "0x123", | ||
| ledger: 12345, | ||
| txHash: "0xabc", | ||
| eventIndex: 0, | ||
| topics: [STREAM_CREATED_TOPIC], | ||
| data: JSON.stringify({ | ||
| streamId: "stream-1", | ||
| sender: "0xsender", | ||
| recipient: "0xrecipient", | ||
| amount: "1000000000", | ||
| startTime: "1000000", | ||
| endTime: "2000000", | ||
| }), | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| const mockPayload = { | ||
| streamId: "stream-1", | ||
| sender: "0xsender", | ||
| recipient: "0xrecipient", | ||
| amount: "1000000000", | ||
| startTime: "1000000", | ||
| endTime: "2000000", | ||
| }; | ||
|
|
||
| describe("parseStreamCreatedPayload", () => { | ||
| test("parses a valid StreamCreated event", () => { | ||
| const event = createMockEvent(); | ||
| const payload = parseStreamCreatedPayload(event); | ||
|
|
||
| expect(payload).toEqual(mockPayload); | ||
| }); | ||
|
|
||
| test("throws when event topic does not match", () => { | ||
| const event = createMockEvent({ topics: ["WrongTopic"] }); | ||
|
|
||
| expect(() => parseStreamCreatedPayload(event)).toThrow( | ||
| "Expected StreamCreated event topic, got WrongTopic", | ||
| ); | ||
| }); | ||
|
|
||
| test("throws when topics array is empty", () => { | ||
| const event = createMockEvent({ topics: [] }); | ||
|
|
||
| expect(() => parseStreamCreatedPayload(event)).toThrow( | ||
| "Expected StreamCreated event topic, got undefined", | ||
| ); | ||
| }); | ||
|
|
||
| test("throws on invalid JSON data", () => { | ||
| const event = createMockEvent({ data: "not-json" }); | ||
|
|
||
| expect(() => parseStreamCreatedPayload(event)).toThrow( | ||
| "Failed to parse event data: invalid JSON", | ||
| ); | ||
| }); | ||
|
|
||
| test("throws on missing streamId field", () => { | ||
| const { streamId: _, ...partial } = mockPayload; | ||
| const event = createMockEvent({ data: JSON.stringify(partial) }); | ||
|
|
||
| expect(() => parseStreamCreatedPayload(event)).toThrow( | ||
| 'Invalid payload: "streamId" must be a non-empty string', | ||
| ); | ||
| }); | ||
|
|
||
| test("throws on non-string amount field", () => { | ||
| const event = createMockEvent({ | ||
| data: JSON.stringify({ ...mockPayload, amount: 12345 }), | ||
| }); | ||
|
|
||
| expect(() => parseStreamCreatedPayload(event)).toThrow( | ||
| 'Invalid payload: "amount" must be a non-empty string', | ||
| ); | ||
| }); | ||
|
|
||
| test("throws on empty string recipient", () => { | ||
| const event = createMockEvent({ | ||
| data: JSON.stringify({ ...mockPayload, recipient: "" }), | ||
| }); | ||
|
|
||
| expect(() => parseStreamCreatedPayload(event)).toThrow( | ||
| 'Invalid payload: "recipient" must be a non-empty string', | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe("getEventIdentity", () => { | ||
| test("produces a deterministic identity string", () => { | ||
| const event = createMockEvent(); | ||
| const identity = getEventIdentity(event); | ||
|
|
||
| expect(identity).toBe("0x123:12345:0xabc:0"); | ||
| }); | ||
|
|
||
| test("changes when any identity field changes", () => { | ||
| const base = createMockEvent(); | ||
| const differentContract = createMockEvent({ contractId: "0x456" }); | ||
| const differentLedger = createMockEvent({ ledger: 99999 }); | ||
| const differentTxHash = createMockEvent({ txHash: "0xdef" }); | ||
| const differentIndex = createMockEvent({ eventIndex: 1 }); | ||
|
|
||
| const baseId = getEventIdentity(base); | ||
| expect(getEventIdentity(differentContract)).not.toBe(baseId); | ||
| expect(getEventIdentity(differentLedger)).not.toBe(baseId); | ||
| expect(getEventIdentity(differentTxHash)).not.toBe(baseId); | ||
| expect(getEventIdentity(differentIndex)).not.toBe(baseId); | ||
| }); | ||
| }); | ||
|
|
||
| describe("mapStreamCreatedToRecord", () => { | ||
| test("maps payload and event to a Stream record", () => { | ||
| const event = createMockEvent(); | ||
| const record = mapStreamCreatedToRecord(mockPayload, event); | ||
|
|
||
| expect(record).toEqual({ | ||
| id: "stream-1", | ||
| sender: "0xsender", | ||
| recipient: "0xrecipient", | ||
| amount: "1000000000", | ||
| startTime: "1000000", | ||
| endTime: "2000000", | ||
| contractId: "0x123", | ||
| ledger: 12345, | ||
| txHash: "0xabc", | ||
| eventIndex: 0, | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe("handleStreamCreated", () => { | ||
| test("returns stream record and identity", () => { | ||
| const event = createMockEvent(); | ||
| const result = handleStreamCreated(event); | ||
|
|
||
| expect(result.stream.id).toBe("stream-1"); | ||
| expect(result.stream.contractId).toBe("0x123"); | ||
| expect(result.stream.ledger).toBe(12345); | ||
| expect(result.identity).toBe("0x123:12345:0xabc:0"); | ||
| }); | ||
|
|
||
| test("is idempotent (same input produces same output)", () => { | ||
| const event = createMockEvent(); | ||
| const result1 = handleStreamCreated(event); | ||
| const result2 = handleStreamCreated(event); | ||
|
|
||
| expect(result1).toEqual(result2); | ||
| }); | ||
|
|
||
| test("handles different stream IDs correctly", () => { | ||
| const event1 = createMockEvent({ | ||
| data: JSON.stringify({ ...mockPayload, streamId: "stream-1" }), | ||
| }); | ||
| const event2 = createMockEvent({ | ||
| data: JSON.stringify({ ...mockPayload, streamId: "stream-2" }), | ||
| }); | ||
|
|
||
| const result1 = handleStreamCreated(event1); | ||
| const result2 = handleStreamCreated(event2); | ||
|
|
||
| expect(result1.stream.id).toBe("stream-1"); | ||
| expect(result2.stream.id).toBe("stream-2"); | ||
| expect(result1.stream.id).not.toBe(result2.stream.id); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import type { StreamCreatedEvent, StreamRecord } from "./types.js"; | ||
|
|
||
| export const STREAM_CREATED_TOPIC = "StreamCreated"; | ||
|
|
||
| export interface StreamCreatedPayload { | ||
| streamId: string; | ||
| sender: string; | ||
| recipient: string; | ||
| amount: string; | ||
| startTime: string; | ||
| endTime: string; | ||
| } | ||
|
|
||
| export function parseStreamCreatedPayload(event: StreamCreatedEvent): StreamCreatedPayload { | ||
| const eventName = event.topics[0]; | ||
| if (!eventName || eventName !== STREAM_CREATED_TOPIC) { | ||
| throw new Error( | ||
| `Expected ${STREAM_CREATED_TOPIC} event topic, got ${eventName ?? "undefined"}`, | ||
| ); | ||
| } | ||
|
|
||
| let parsed: Record<string, unknown>; | ||
| try { | ||
| parsed = JSON.parse(event.data) as Record<string, unknown>; | ||
| } catch { | ||
| throw new Error("Failed to parse event data: invalid JSON"); | ||
| } | ||
|
|
||
| const requiredFields = [ | ||
| "streamId", | ||
| "sender", | ||
| "recipient", | ||
| "amount", | ||
| "startTime", | ||
| "endTime", | ||
| ] as const; | ||
|
|
||
| for (const field of requiredFields) { | ||
| const value = parsed[field]; | ||
| if (typeof value !== "string" || value.length === 0) { | ||
| throw new Error( | ||
| `Invalid payload: "${field}" must be a non-empty string, got ${typeof value === "string" ? "empty string" : typeof value}`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| streamId: parsed.streamId as string, | ||
| sender: parsed.sender as string, | ||
| recipient: parsed.recipient as string, | ||
| amount: parsed.amount as string, | ||
| startTime: parsed.startTime as string, | ||
| endTime: parsed.endTime as string, | ||
| }; | ||
| } | ||
|
|
||
| export function getEventIdentity(event: StreamCreatedEvent): string { | ||
| return `${event.contractId}:${event.ledger}:${event.txHash}:${event.eventIndex}`; | ||
| } | ||
|
|
||
| export function mapStreamCreatedToRecord( | ||
| payload: StreamCreatedPayload, | ||
| event: StreamCreatedEvent, | ||
| ): StreamRecord { | ||
| return { | ||
| id: payload.streamId, | ||
| sender: payload.sender, | ||
| recipient: payload.recipient, | ||
| amount: payload.amount, | ||
| startTime: payload.startTime, | ||
| endTime: payload.endTime, | ||
| contractId: event.contractId, | ||
| ledger: event.ledger, | ||
| txHash: event.txHash, | ||
| eventIndex: event.eventIndex, | ||
| }; | ||
| } | ||
|
|
||
| export function handleStreamCreated(event: StreamCreatedEvent): { | ||
| stream: StreamRecord; | ||
| identity: string; | ||
| } { | ||
| const payload = parseStreamCreatedPayload(event); | ||
| const stream = mapStreamCreatedToRecord(payload, event); | ||
| const identity = getEventIdentity(event); | ||
|
|
||
| return { stream, identity }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| export interface StreamCreatedEvent { | ||
| contractId: string; | ||
| ledger: number; | ||
| txHash: string; | ||
| eventIndex: number; | ||
| topics: string[]; | ||
| data: string; | ||
| } | ||
|
|
||
| export interface StreamRecord { | ||
| id: string; | ||
| sender: string; | ||
| recipient: string; | ||
| amount: string; | ||
| startTime: string; | ||
| endTime: string; | ||
| contractId: string; | ||
| ledger: number; | ||
| txHash: string; | ||
| eventIndex: number; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Add a non-object JSON payload case.
These negative tests still miss valid JSON with the wrong top-level shape, e.g.
data: "null". Inindexer/streams/src/handlers/streamCreated.ts:22-39,JSON.parsewould succeed and the subsequentparsed[field]access would throw a rawTypeErrorinstead of the intended validation error. Please add a case fornull(and ideally another non-object shape) and harden the parser accordingly.🤖 Prompt for AI Agents