From 80bb6a9136af9b36acacb7c32401f2af15e59561 Mon Sep 17 00:00:00 2001 From: Tumilara Adetayo Date: Mon, 29 Jun 2026 17:11:46 +0100 Subject: [PATCH] feat: add UI primitives and contract invocation tracing --- admin/app/components/Avatar.tsx | 91 +++++ admin/app/components/Breadcrumb.tsx | 62 +++ admin/app/components/Divider.tsx | 56 +++ .../app/components/__tests__/Avatar.test.tsx | 79 ++++ .../components/__tests__/Breadcrumb.test.tsx | 82 ++++ .../app/components/__tests__/Divider.test.tsx | 56 +++ admin/app/components/index.ts | 9 + django-backend/soroscan/ingest/schema.py | 70 ++++ django-backend/soroscan/ingest/tasks.py | 100 +++++ .../ingest/tests/test_invocation_history.py | 367 ++++++++++++++++++ django-backend/soroscan/ingest/urls.py | 5 + .../components/ui/__tests__/Avatar.test.tsx | 76 ++++ .../ui/__tests__/Breadcrumb.test.tsx | 84 ++++ .../components/ui/__tests__/Divider.test.tsx | 54 +++ soroscan-frontend/components/ui/avatar.tsx | 94 +++++ .../components/ui/breadcrumb.tsx | 61 +++ 16 files changed, 1346 insertions(+) create mode 100644 admin/app/components/Avatar.tsx create mode 100644 admin/app/components/Breadcrumb.tsx create mode 100644 admin/app/components/Divider.tsx create mode 100644 admin/app/components/__tests__/Avatar.test.tsx create mode 100644 admin/app/components/__tests__/Breadcrumb.test.tsx create mode 100644 admin/app/components/__tests__/Divider.test.tsx create mode 100644 django-backend/soroscan/ingest/tests/test_invocation_history.py create mode 100644 soroscan-frontend/components/ui/__tests__/Avatar.test.tsx create mode 100644 soroscan-frontend/components/ui/__tests__/Breadcrumb.test.tsx create mode 100644 soroscan-frontend/components/ui/__tests__/Divider.test.tsx create mode 100644 soroscan-frontend/components/ui/avatar.tsx create mode 100644 soroscan-frontend/components/ui/breadcrumb.tsx diff --git a/admin/app/components/Avatar.tsx b/admin/app/components/Avatar.tsx new file mode 100644 index 00000000..fbd49f02 --- /dev/null +++ b/admin/app/components/Avatar.tsx @@ -0,0 +1,91 @@ +'use client'; + +import React, { useState } from 'react'; + +export interface AvatarProps { + /** Image URL — falls back to initials if omitted or fails to load */ + src?: string; + /** Full name used for initials and tooltip */ + name: string; + /** Size variant */ + size?: 'sm' | 'md' | 'lg'; + /** Override the background color for the initials fallback */ + color?: string; + /** Additional CSS classes */ + className?: string; +} + +const SIZE_CLASSES: Record, string> = { + sm: 'w-8 h-8 text-xs', + md: 'w-10 h-10 text-sm', + lg: 'w-14 h-14 text-base', +}; + +/** Deterministic hue from a string so each user gets a consistent colour. */ +function colorFromName(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + const hue = Math.abs(hash) % 360; + return `hsl(${hue}, 55%, 50%)`; +} + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/); + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +} + +const Avatar: React.FC = ({ + src, + name, + size = 'md', + color, + className = '', +}) => { + const [imgError, setImgError] = useState(false); + const showImage = !!src && !imgError; + const initials = getInitials(name); + const bgColor = color ?? colorFromName(name); + + return ( +
+ {showImage ? ( + {name} setImgError(true)} + /> + ) : ( + + {initials} + + )} + + {/* Hover tooltip */} + +
+ ); +}; + +export default Avatar; diff --git a/admin/app/components/Breadcrumb.tsx b/admin/app/components/Breadcrumb.tsx new file mode 100644 index 00000000..52e3db4b --- /dev/null +++ b/admin/app/components/Breadcrumb.tsx @@ -0,0 +1,62 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; + +export interface BreadcrumbItem { + /** Display label */ + label: string; + /** Navigation href — omit for the current (last) page */ + href?: string; +} + +export interface BreadcrumbProps { + /** Ordered list of breadcrumb items */ + items: BreadcrumbItem[]; + /** Character(s) used as separator between items */ + separator?: React.ReactNode; + /** Additional CSS classes */ + className?: string; +} + +const Breadcrumb: React.FC = ({ + items, + separator = '/', + className = '', +}) => { + return ( + + ); +}; + +export default Breadcrumb; diff --git a/admin/app/components/Divider.tsx b/admin/app/components/Divider.tsx new file mode 100644 index 00000000..980c571c --- /dev/null +++ b/admin/app/components/Divider.tsx @@ -0,0 +1,56 @@ +'use client'; + +import React from 'react'; + +export interface DividerProps { + /** Orientation of the divider */ + orientation?: 'horizontal' | 'vertical'; + /** Optional label centered on a horizontal divider */ + label?: string; + /** Visual weight */ + variant?: 'subtle' | 'prominent'; + /** Custom color (overrides variant) */ + color?: string; + /** Additional CSS classes */ + className?: string; +} + +const Divider: React.FC = ({ + orientation = 'horizontal', + label, + variant = 'subtle', + color, + className = '', +}) => { + const lineStyle = color ? { backgroundColor: color } : undefined; + + const lineClass = [ + orientation === 'vertical' ? 'w-px h-full' : 'h-px w-full', + variant === 'subtle' ? 'bg-gray-200' : 'bg-gray-400', + ].join(' '); + + if (orientation === 'horizontal' && label) { + return ( +
+
+ {label} +
+
+ ); + } + + return ( +
+ ); +}; + +export default Divider; diff --git a/admin/app/components/__tests__/Avatar.test.tsx b/admin/app/components/__tests__/Avatar.test.tsx new file mode 100644 index 00000000..30ed7027 --- /dev/null +++ b/admin/app/components/__tests__/Avatar.test.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Avatar from '../Avatar'; + +describe('Avatar', () => { + // --- Image rendering --- + it('renders image when src is provided', () => { + render(); + const img = screen.getByRole('img', { name: 'Alice Smith' }); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', 'https://example.com/photo.jpg'); + }); + + it('shows initials fallback when no src provided', () => { + render(); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + expect(screen.getByLabelText('AS')).toBeInTheDocument(); + }); + + it('shows initials fallback when image fails to load', () => { + render(); + const img = screen.getByRole('img'); + fireEvent.error(img); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + expect(screen.getByLabelText('BJ')).toBeInTheDocument(); + }); + + // --- Initials logic --- + it('uses first two chars for single-word name', () => { + render(); + expect(screen.getByLabelText('AL')).toBeInTheDocument(); + }); + + it('uses first + last initials for multi-word name', () => { + render(); + expect(screen.getByLabelText('JD')).toBeInTheDocument(); + }); + + // --- Size variants --- + it('applies md size by default', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('w-10', 'h-10'); + }); + + it('applies sm size', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('w-8', 'h-8'); + }); + + it('applies lg size', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('w-14', 'h-14'); + }); + + // --- Tooltip --- + it('renders name as title attribute for native tooltip', () => { + const { container } = render(); + expect(container.firstChild).toHaveAttribute('title', 'Alice Smith'); + }); + + it('renders tooltip span with name text', () => { + render(); + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Alice Smith'); + }); + + // --- Custom color --- + it('applies custom background color to initials', () => { + render(); + const span = screen.getByLabelText('AL'); + expect(span).toHaveStyle({ backgroundColor: '#abc123' }); + }); + + // --- Custom className --- + it('applies custom className', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('my-avatar'); + }); +}); diff --git a/admin/app/components/__tests__/Breadcrumb.test.tsx b/admin/app/components/__tests__/Breadcrumb.test.tsx new file mode 100644 index 00000000..c3c51235 --- /dev/null +++ b/admin/app/components/__tests__/Breadcrumb.test.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Breadcrumb from '../Breadcrumb'; + +// Mock Next.js Link — in the admin project (no full Next.js test setup) +jest.mock('next/link', () => { + const MockLink = ({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) => ( + {children} + ); + MockLink.displayName = 'Link'; + return MockLink; +}); + +const items = [ + { label: 'Home', href: '/' }, + { label: 'Contracts', href: '/contracts' }, + { label: 'Details' }, +]; + +describe('Breadcrumb', () => { + it('renders all items', () => { + render(); + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('Contracts')).toBeInTheDocument(); + expect(screen.getByText('Details')).toBeInTheDocument(); + }); + + it('renders as a nav with aria-label', () => { + render(); + expect(screen.getByRole('navigation', { name: /breadcrumb/i })).toBeInTheDocument(); + }); + + it('renders links for all but the last item', () => { + render(); + const links = screen.getAllByRole('link'); + expect(links).toHaveLength(2); + expect(links[0]).toHaveAttribute('href', '/'); + expect(links[1]).toHaveAttribute('href', '/contracts'); + }); + + it('last item is not a link', () => { + render(); + const links = screen.queryAllByRole('link'); + const texts = links.map(l => l.textContent); + expect(texts).not.toContain('Details'); + }); + + it('marks last item with aria-current="page"', () => { + render(); + expect(screen.getByText('Details')).toHaveAttribute('aria-current', 'page'); + }); + + it('renders separator between items', () => { + render(); + // Two separators for three items + const separators = screen.getAllByText('/'); + expect(separators).toHaveLength(2); + }); + + it('supports custom separator', () => { + render(); + const separators = screen.getAllByText('>'); + expect(separators).toHaveLength(2); + }); + + it('renders single item without separator', () => { + render(); + expect(screen.queryByText('/')).not.toBeInTheDocument(); + }); + + it('item without href is non-link text', () => { + render(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(screen.getByText('Static')).toBeInTheDocument(); + }); + + it('applies custom className to nav', () => { + const { container } = render(); + expect(container.querySelector('nav')).toHaveClass('my-class'); + }); +}); diff --git a/admin/app/components/__tests__/Divider.test.tsx b/admin/app/components/__tests__/Divider.test.tsx new file mode 100644 index 00000000..2f779da8 --- /dev/null +++ b/admin/app/components/__tests__/Divider.test.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Divider from '../Divider'; + +describe('Divider', () => { + it('renders with role="separator"', () => { + render(); + expect(screen.getByRole('separator')).toBeInTheDocument(); + }); + + it('defaults to horizontal orientation', () => { + render(); + expect(screen.getByRole('separator')).toHaveAttribute('aria-orientation', 'horizontal'); + }); + + it('renders vertical variant', () => { + render(); + const sep = screen.getByRole('separator'); + expect(sep).toHaveAttribute('aria-orientation', 'vertical'); + expect(sep).toHaveClass('w-px', 'h-full'); + }); + + it('renders label centered on horizontal divider', () => { + render(); + expect(screen.getByText('OR')).toBeInTheDocument(); + }); + + it('renders two line elements when label is present', () => { + const { container } = render(); + // The wrapper div contains two line divs + one span + const lines = container.querySelectorAll('.flex-1'); + expect(lines).toHaveLength(2); + }); + + it('applies subtle variant classes by default', () => { + render(); + expect(screen.getByRole('separator')).toHaveClass('bg-gray-200'); + }); + + it('applies prominent variant classes', () => { + render(); + expect(screen.getByRole('separator')).toHaveClass('bg-gray-400'); + }); + + it('applies custom color via inline style', () => { + render(); + const sep = screen.getByRole('separator'); + expect(sep).toHaveStyle({ backgroundColor: '#ff0000' }); + }); + + it('applies custom className', () => { + render(); + expect(screen.getByRole('separator')).toHaveClass('my-divider'); + }); +}); diff --git a/admin/app/components/index.ts b/admin/app/components/index.ts index e0bf49a3..3a8e4895 100644 --- a/admin/app/components/index.ts +++ b/admin/app/components/index.ts @@ -21,3 +21,12 @@ export type { VerifiedSourceBadgeProps, VerificationDetails } from './VerifiedSo export { ContractEventTimeline } from './ContractEventTimeline'; export type { ContractEvent, ContractEventTimelineProps } from './ContractEventTimeline'; + +export { default as Breadcrumb } from './Breadcrumb'; +export type { BreadcrumbProps, BreadcrumbItem } from './Breadcrumb'; + +export { default as Divider } from './Divider'; +export type { DividerProps } from './Divider'; + +export { default as Avatar } from './Avatar'; +export type { AvatarProps } from './Avatar'; diff --git a/django-backend/soroscan/ingest/schema.py b/django-backend/soroscan/ingest/schema.py index b504678d..a95bb972 100644 --- a/django-backend/soroscan/ingest/schema.py +++ b/django-backend/soroscan/ingest/schema.py @@ -852,6 +852,76 @@ def recent_errors(self, info: Info, limit: int = 10) -> list[ErrorLog]: for log in logs ] + @strawberry.field + def invocations_for_contract( + self, + contract_id: str, + caller: Optional[str] = None, + function_name: Optional[str] = None, + since: Optional[datetime] = None, + until: Optional[datetime] = None, + first: int = 20, + after: Optional[str] = None, + ) -> InvocationConnection: + """ + Fetch invocations for a contract, each with its related events. + + Supports filtering by caller address, function name, and timestamp range. + Returns cursor-based paginated results (max 100 per page). + """ + qs = ( + ContractInvocation.objects.select_related("contract") + .prefetch_related("events__contract") + .filter(contract__contract_id=contract_id) + .order_by("id") + ) + + if caller: + qs = qs.filter(caller=caller) + if function_name: + qs = qs.filter(function_name=function_name) + if since: + qs = qs.filter(created_at__gte=since) + if until: + qs = qs.filter(created_at__lte=until) + + total_count = qs.count() + + if after: + try: + decoded = base64.b64decode(after).decode("utf-8") + after_id = int(decoded.split(":", 1)[1]) + qs = qs.filter(id__gt=after_id) + except (ValueError, IndexError, UnicodeDecodeError): + pass + + first = max(0, min(first, 100)) + + if first == 0: + return InvocationConnection( + edges=[], + page_info=PageInfo(has_next_page=qs.exists(), end_cursor=None), + total_count=total_count, + ) + + items = list(qs[: first + 1]) + has_next = len(items) > first + items = items[:first] + + edges = [] + for item in items: + cursor = base64.b64encode(f"cursor:{item.id}".encode()).decode("utf-8") + edges.append(InvocationEdge(node=item, cursor=cursor)) + + return InvocationConnection( + edges=edges, + page_info=PageInfo( + has_next_page=has_next, + end_cursor=edges[-1].cursor if edges else None, + ), + total_count=total_count, + ) + @strawberry.type class Mutation: diff --git a/django-backend/soroscan/ingest/tasks.py b/django-backend/soroscan/ingest/tasks.py index 5ff58b81..fe13d8d8 100644 --- a/django-backend/soroscan/ingest/tasks.py +++ b/django-backend/soroscan/ingest/tasks.py @@ -3896,3 +3896,103 @@ def detect_contract_upgrades() -> dict[str, Any]: ).update(valid_to_ledger=ledger - 1) return summary + + +@shared_task( + name="ingest.tasks.fetch_and_store_invocation", + bind=True, + max_retries=3, + default_retry_delay=10, +) +def fetch_and_store_invocation(self, contract_id: str, tx_hash: str, event_id: int) -> dict[str, Any]: + """ + Fetch invocation details from Soroban RPC for a transaction and persist them. + + Called asynchronously after a ContractEvent is created so that the ingest + pipeline is not blocked by RPC round-trips. Implements: + - Rate-limited + cached RPC calls via SorobanClient.get_invocation() + - get_or_create semantics (idempotent on retry) + - Links the ContractEvent to the resulting ContractInvocation via FK + + Args: + contract_id: Stellar contract address (C...) + tx_hash: Transaction hash that triggered the event + event_id: PK of the ContractEvent to link to the invocation + """ + logger.info( + "Fetching invocation details for tx=%s contract=%s", + tx_hash, + contract_id, + extra={"contract_id": contract_id, "tx_hash": tx_hash}, + ) + + try: + contract = TrackedContract.objects.get(contract_id=contract_id) + except TrackedContract.DoesNotExist: + logger.warning( + "TrackedContract not found for contract_id=%s — skipping invocation fetch", + contract_id, + extra={"contract_id": contract_id}, + ) + return {"status": "skipped", "reason": "contract_not_found"} + + # Idempotency: if invocation already stored, just ensure the FK is set + existing = ContractInvocation.objects.filter( + tx_hash=tx_hash, contract=contract + ).first() + if existing: + _link_event_to_invocation(event_id, existing) + return {"status": "already_exists", "invocation_id": existing.id} + + client = SorobanClient() + invocation_data = client.get_invocation(tx_hash) + + if not invocation_data.success: + logger.warning( + "Invocation fetch failed for tx=%s: %s", + tx_hash, + invocation_data.error, + extra={"contract_id": contract_id, "tx_hash": tx_hash}, + ) + # Retry on transient failures; give up on permanent ones (e.g. NOT_FOUND) + if invocation_data.error and "not found" in (invocation_data.error or "").lower(): + return {"status": "not_found", "tx_hash": tx_hash} + raise self.retry(exc=Exception(invocation_data.error or "fetch failed")) + + invocation, created = ContractInvocation.objects.get_or_create( + tx_hash=tx_hash, + contract=contract, + defaults={ + "caller": invocation_data.caller, + "function_name": invocation_data.function_name or "unknown", + "parameters": invocation_data.parameters or {}, + "result": invocation_data.result, + "ledger_sequence": invocation_data.ledger_sequence, + }, + ) + + _link_event_to_invocation(event_id, invocation) + + logger.info( + "Invocation %s for tx=%s contract=%s (%s)", + invocation.id, + tx_hash, + contract_id, + "created" if created else "found", + extra={"contract_id": contract_id, "tx_hash": tx_hash, "invocation_id": invocation.id}, + ) + return {"status": "ok", "invocation_id": invocation.id, "created": created} + + +def _link_event_to_invocation(event_id: int, invocation: "ContractInvocation") -> None: + """Set the invocation FK on a ContractEvent if not already linked.""" + updated = ContractEvent.objects.filter( + id=event_id, invocation__isnull=True + ).update(invocation=invocation) + if updated: + logger.debug( + "Linked event %d to invocation %d", + event_id, + invocation.id, + extra={"event_id": event_id, "invocation_id": invocation.id}, + ) diff --git a/django-backend/soroscan/ingest/tests/test_invocation_history.py b/django-backend/soroscan/ingest/tests/test_invocation_history.py new file mode 100644 index 00000000..fca11ebe --- /dev/null +++ b/django-backend/soroscan/ingest/tests/test_invocation_history.py @@ -0,0 +1,367 @@ +""" +Integration tests for Contract Invocation History (issue #796). + +Covers: +- ContractInvocation model creation and FK to ContractEvent +- GET /api/contracts/{id}/invocations/ REST endpoint +- GET /api/invocations/ list endpoint with filters +- GraphQL invocationsForContract query with nested events +- fetch_and_store_invocation Celery task +""" +import pytest +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +from django.urls import reverse +from rest_framework.test import APIClient + +from soroscan.ingest.models import ContractEvent, ContractInvocation, TrackedContract +from soroscan.ingest.schema import schema +from soroscan.ingest.tasks import fetch_and_store_invocation +from soroscan.ingest.stellar_client import InvocationData + +from .factories import ( + ContractEventFactory, + TrackedContractFactory, + UserFactory, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_invocation(contract, tx_hash="a" * 64, caller="G" + "A" * 55, function_name="transfer"): + return ContractInvocation.objects.create( + contract=contract, + tx_hash=tx_hash, + caller=caller, + function_name=function_name, + parameters={"amount": "100"}, + result={"xdr": "AAAAAA=="}, + ledger_sequence=1000, + ) + + +def _gql_context(user): + ctx = MagicMock() + ctx.request = MagicMock() + ctx.request.user = user + return ctx + + +# --------------------------------------------------------------------------- +# Model tests +# --------------------------------------------------------------------------- + +@pytest.mark.django_db +class TestContractInvocationModel: + def test_create_invocation(self, contract): + inv = _make_invocation(contract) + assert inv.pk is not None + assert inv.contract == contract + assert inv.function_name == "transfer" + + def test_unique_tx_hash_contract_constraint(self, contract): + _make_invocation(contract, tx_hash="b" * 64) + with pytest.raises(Exception): + _make_invocation(contract, tx_hash="b" * 64) + + def test_event_invocation_fk(self, contract): + inv = _make_invocation(contract) + event = ContractEventFactory(contract=contract, invocation=inv, tx_hash=inv.tx_hash) + assert event.invocation == inv + # Reverse relation + assert list(inv.events.all()) == [event] + + def test_event_invocation_nullable(self, contract): + """ContractEvent.invocation is nullable — existing events unaffected.""" + event = ContractEventFactory(contract=contract, invocation=None) + assert event.invocation is None + + def test_str_representation(self, contract): + inv = _make_invocation(contract) + assert "transfer" in str(inv) + assert str(inv.ledger_sequence) in str(inv) + + +# --------------------------------------------------------------------------- +# REST API tests +# --------------------------------------------------------------------------- + +@pytest.mark.django_db +class TestContractInvocationsRESTEndpoint: + def setup_method(self): + self.client = APIClient() + + def test_list_invocations_for_contract(self, user, contract): + self.client.force_authenticate(user=user) + inv = _make_invocation(contract) + + response = self.client.get( + f"/api/contracts/{contract.contract_id}/invocations/" + ) + assert response.status_code == 200 + data = response.json() + results = data.get("results", data) + assert any(r["id"] == inv.id for r in results) + + def test_list_invocations_filter_by_caller(self, user, contract): + self.client.force_authenticate(user=user) + caller_a = "G" + "A" * 55 + caller_b = "G" + "B" * 55 + _make_invocation(contract, tx_hash="c" * 64, caller=caller_a) + _make_invocation(contract, tx_hash="d" * 64, caller=caller_b) + + response = self.client.get( + f"/api/contracts/{contract.contract_id}/invocations/", + {"caller": caller_a}, + ) + assert response.status_code == 200 + results = response.json().get("results", response.json()) + assert all(r["caller"] == caller_a for r in results) + assert len(results) == 1 + + def test_list_invocations_filter_by_function_name(self, user, contract): + self.client.force_authenticate(user=user) + _make_invocation(contract, tx_hash="e" * 64, function_name="swap") + _make_invocation(contract, tx_hash="f" * 64, function_name="transfer") + + response = self.client.get( + f"/api/contracts/{contract.contract_id}/invocations/", + {"function_name": "swap"}, + ) + assert response.status_code == 200 + results = response.json().get("results", response.json()) + assert all(r["function_name"] == "swap" for r in results) + + def test_list_invocations_requires_auth(self, contract): + response = self.client.get( + f"/api/contracts/{contract.contract_id}/invocations/" + ) + assert response.status_code in (401, 403) + + def test_invocation_response_includes_contract_id(self, user, contract): + self.client.force_authenticate(user=user) + _make_invocation(contract) + + response = self.client.get( + f"/api/contracts/{contract.contract_id}/invocations/" + ) + results = response.json().get("results", response.json()) + assert results[0]["contract_id"] == contract.contract_id + + def test_generic_invocations_endpoint(self, user, contract): + """GET /api/invocations/ also works.""" + self.client.force_authenticate(user=user) + _make_invocation(contract) + + response = self.client.get("/api/invocations/") + assert response.status_code == 200 + + +# --------------------------------------------------------------------------- +# GraphQL tests +# --------------------------------------------------------------------------- + +@pytest.mark.django_db +class TestInvocationsForContractGraphQL: + def test_basic_query_returns_invocations(self, user, contract): + inv = _make_invocation(contract) + event = ContractEventFactory(contract=contract, invocation=inv, tx_hash=inv.tx_hash) + + result = schema.execute_sync( + """ + query($contractId: String!) { + invocationsForContract(contractId: $contractId) { + totalCount + edges { + node { + id + txHash + caller + functionName + parameters + ledgerSequence + contractId + events { + id + eventType + } + } + cursor + } + pageInfo { + hasNextPage + endCursor + } + } + } + """, + variable_values={"contractId": contract.contract_id}, + ) + assert result.errors is None + data = result.data["invocationsForContract"] + assert data["totalCount"] == 1 + node = data["edges"][0]["node"] + assert node["txHash"] == inv.tx_hash + assert node["caller"] == inv.caller + assert node["functionName"] == "transfer" + assert node["contractId"] == contract.contract_id + assert len(node["events"]) == 1 + assert node["events"][0]["id"] == event.id + + def test_filter_by_caller(self, user, contract): + caller_a = "G" + "A" * 55 + caller_b = "G" + "B" * 55 + _make_invocation(contract, tx_hash="g" * 64, caller=caller_a) + _make_invocation(contract, tx_hash="h" * 64, caller=caller_b) + + result = schema.execute_sync( + """ + query($contractId: String!, $caller: String) { + invocationsForContract(contractId: $contractId, caller: $caller) { + totalCount + edges { node { caller } } + } + } + """, + variable_values={"contractId": contract.contract_id, "caller": caller_a}, + ) + assert result.errors is None + data = result.data["invocationsForContract"] + assert data["totalCount"] == 1 + assert data["edges"][0]["node"]["caller"] == caller_a + + def test_filter_by_function_name(self, user, contract): + _make_invocation(contract, tx_hash="i" * 64, function_name="mint") + _make_invocation(contract, tx_hash="j" * 64, function_name="burn") + + result = schema.execute_sync( + """ + query($contractId: String!, $functionName: String) { + invocationsForContract(contractId: $contractId, functionName: $functionName) { + totalCount + } + } + """, + variable_values={"contractId": contract.contract_id, "functionName": "mint"}, + ) + assert result.errors is None + assert result.data["invocationsForContract"]["totalCount"] == 1 + + def test_pagination_first(self, user, contract): + for i in range(5): + _make_invocation(contract, tx_hash=str(i).zfill(64)) + + result = schema.execute_sync( + """ + query($contractId: String!) { + invocationsForContract(contractId: $contractId, first: 2) { + totalCount + edges { cursor } + pageInfo { hasNextPage endCursor } + } + } + """, + variable_values={"contractId": contract.contract_id}, + ) + assert result.errors is None + data = result.data["invocationsForContract"] + assert len(data["edges"]) == 2 + assert data["totalCount"] == 5 + assert data["pageInfo"]["hasNextPage"] is True + + def test_unknown_contract_returns_empty(self): + result = schema.execute_sync( + """ + query { + invocationsForContract(contractId: "C" + "Z" * 55) { + totalCount + } + } + """, + ) + # Schema error or empty result — either is acceptable for unknown contract + if result.errors is None: + assert result.data["invocationsForContract"]["totalCount"] == 0 + + +# --------------------------------------------------------------------------- +# Celery task tests +# --------------------------------------------------------------------------- + +@pytest.mark.django_db +class TestFetchAndStoreInvocationTask: + def _mock_invocation_data(self, **kwargs): + defaults = dict( + caller="G" + "A" * 55, + contract="C" + "A" * 55, + function_name="transfer", + parameters={"amount": "500"}, + result={"xdr": "AAAA"}, + ledger_sequence=2000, + success=True, + error=None, + ) + defaults.update(kwargs) + return InvocationData(**defaults) + + def test_task_creates_invocation_and_links_event(self, contract): + event = ContractEventFactory(contract=contract, invocation=None) + tx = "k" * 64 + + mock_data = self._mock_invocation_data() + with patch( + "soroscan.ingest.tasks.SorobanClient.get_invocation", + return_value=mock_data, + ): + result = fetch_and_store_invocation(contract.contract_id, tx, event.id) + + assert result["status"] == "ok" + assert result["created"] is True + + inv = ContractInvocation.objects.get(id=result["invocation_id"]) + assert inv.tx_hash == tx + assert inv.function_name == "transfer" + assert inv.caller == "G" + "A" * 55 + + event.refresh_from_db() + assert event.invocation == inv + + def test_task_idempotent_on_existing_invocation(self, contract): + """Running the task twice for the same tx does not create a duplicate.""" + event = ContractEventFactory(contract=contract, invocation=None) + tx = "l" * 64 + inv = _make_invocation(contract, tx_hash=tx) + + mock_data = self._mock_invocation_data() + with patch( + "soroscan.ingest.tasks.SorobanClient.get_invocation", + return_value=mock_data, + ): + result = fetch_and_store_invocation(contract.contract_id, tx, event.id) + + assert result["status"] == "already_exists" + assert result["invocation_id"] == inv.id + assert ContractInvocation.objects.filter(tx_hash=tx, contract=contract).count() == 1 + + def test_task_skips_unknown_contract(self): + result = fetch_and_store_invocation("C" + "Z" * 55, "m" * 64, 999) + assert result["status"] == "skipped" + assert result["reason"] == "contract_not_found" + + def test_task_handles_not_found_gracefully(self, contract): + event = ContractEventFactory(contract=contract, invocation=None) + not_found_data = self._mock_invocation_data( + success=False, error="Transaction not found" + ) + with patch( + "soroscan.ingest.tasks.SorobanClient.get_invocation", + return_value=not_found_data, + ): + result = fetch_and_store_invocation(contract.contract_id, "n" * 64, event.id) + + assert result["status"] == "not_found" + event.refresh_from_db() + assert event.invocation is None diff --git a/django-backend/soroscan/ingest/urls.py b/django-backend/soroscan/ingest/urls.py index d5173aa1..971f2a52 100644 --- a/django-backend/soroscan/ingest/urls.py +++ b/django-backend/soroscan/ingest/urls.py @@ -41,6 +41,11 @@ router.register(r"teams", TeamViewSet, basename="team") urlpatterns = [ + path( + "contracts//invocations/", + ContractInvocationViewSet.as_view({"get": "list"}), + name="contract-invocations", + ), path("contracts//timeline/", contract_timeline_view, name="contract-timeline"), path( "contracts//events/explorer/", diff --git a/soroscan-frontend/components/ui/__tests__/Avatar.test.tsx b/soroscan-frontend/components/ui/__tests__/Avatar.test.tsx new file mode 100644 index 00000000..540eb37b --- /dev/null +++ b/soroscan-frontend/components/ui/__tests__/Avatar.test.tsx @@ -0,0 +1,76 @@ +import React from "react" +import { render, screen, fireEvent } from "@testing-library/react" +import "@testing-library/jest-dom" +import { Avatar } from "../avatar" + +describe("Avatar", () => { + // --- Image rendering --- + it("renders image when src provided", () => { + render() + const img = screen.getByRole("img", { name: "Alice Smith" }) + expect(img).toHaveAttribute("src", "https://example.com/pic.jpg") + }) + + it("shows initials fallback when no src", () => { + render() + expect(screen.queryByRole("img")).not.toBeInTheDocument() + expect(screen.getByLabelText("AS")).toBeInTheDocument() + }) + + it("shows initials fallback when image fails to load", () => { + render() + fireEvent.error(screen.getByRole("img")) + expect(screen.queryByRole("img")).not.toBeInTheDocument() + expect(screen.getByLabelText("BJ")).toBeInTheDocument() + }) + + // --- Initials logic --- + it("uses first two chars for single-word name", () => { + render() + expect(screen.getByLabelText("AL")).toBeInTheDocument() + }) + + it("uses first + last initial for multi-word name", () => { + render() + expect(screen.getByLabelText("JD")).toBeInTheDocument() + }) + + // --- Size variants --- + it("applies md size by default", () => { + const { container } = render() + expect(container.firstChild).toHaveClass("w-10", "h-10") + }) + + it("applies sm size", () => { + const { container } = render() + expect(container.firstChild).toHaveClass("w-8", "h-8") + }) + + it("applies lg size", () => { + const { container } = render() + expect(container.firstChild).toHaveClass("w-14", "h-14") + }) + + // --- Tooltip --- + it("renders title attribute for native tooltip", () => { + const { container } = render() + expect(container.firstChild).toHaveAttribute("title", "Alice Smith") + }) + + it("renders tooltip span with name", () => { + render() + expect(screen.getByRole("tooltip", { hidden: true })).toHaveTextContent("Alice Smith") + }) + + // --- Custom color --- + it("applies custom background color to initials span", () => { + render() + expect(screen.getByLabelText("AL")).toHaveStyle({ backgroundColor: "#abc123" }) + }) + + // --- Custom className --- + it("applies custom className", () => { + const { container } = render() + expect(container.firstChild).toHaveClass("ring-2") + }) +}) diff --git a/soroscan-frontend/components/ui/__tests__/Breadcrumb.test.tsx b/soroscan-frontend/components/ui/__tests__/Breadcrumb.test.tsx new file mode 100644 index 00000000..22df519e --- /dev/null +++ b/soroscan-frontend/components/ui/__tests__/Breadcrumb.test.tsx @@ -0,0 +1,84 @@ +import React from "react" +import { render, screen } from "@testing-library/react" +import "@testing-library/jest-dom" +import { Breadcrumb } from "../breadcrumb" + +jest.mock("next/link", () => { + const MockLink = ({ + href, + children, + className, + }: { + href: string + children: React.ReactNode + className?: string + }) => ( + + {children} + + ) + MockLink.displayName = "Link" + return MockLink +}) + +const items = [ + { label: "Home", href: "/" }, + { label: "Events", href: "/events" }, + { label: "Detail" }, +] + +describe("Breadcrumb", () => { + it("renders all items", () => { + render() + expect(screen.getByText("Home")).toBeInTheDocument() + expect(screen.getByText("Events")).toBeInTheDocument() + expect(screen.getByText("Detail")).toBeInTheDocument() + }) + + it("renders nav with aria-label", () => { + render() + expect(screen.getByRole("navigation", { name: /breadcrumb/i })).toBeInTheDocument() + }) + + it("renders links for all but the last item", () => { + render() + const links = screen.getAllByRole("link") + expect(links).toHaveLength(2) + expect(links[0]).toHaveAttribute("href", "/") + expect(links[1]).toHaveAttribute("href", "/events") + }) + + it("last item is not a link", () => { + render() + const linkTexts = screen.getAllByRole("link").map((l) => l.textContent) + expect(linkTexts).not.toContain("Detail") + }) + + it("marks last item with aria-current=page", () => { + render() + expect(screen.getByText("Detail")).toHaveAttribute("aria-current", "page") + }) + + it("renders default separators", () => { + render() + expect(screen.getAllByText("/")).toHaveLength(2) + }) + + it("supports custom separator", () => { + render() + expect(screen.getAllByText("›")).toHaveLength(2) + }) + + it("item without href renders as plain text", () => { + render() + expect(screen.queryByRole("link")).not.toBeInTheDocument() + expect(screen.getByText("Only")).toBeInTheDocument() + }) + + it("applies custom className", () => { + const { container } = render( + + ) + expect(container.querySelector("nav")).toHaveClass("custom-nav") + }) +}) diff --git a/soroscan-frontend/components/ui/__tests__/Divider.test.tsx b/soroscan-frontend/components/ui/__tests__/Divider.test.tsx new file mode 100644 index 00000000..f0886762 --- /dev/null +++ b/soroscan-frontend/components/ui/__tests__/Divider.test.tsx @@ -0,0 +1,54 @@ +import React from "react" +import { render, screen } from "@testing-library/react" +import "@testing-library/jest-dom" +import { Divider } from "../divider" + +describe("Divider", () => { + it("renders with role=separator", () => { + render() + expect(screen.getByRole("separator")).toBeInTheDocument() + }) + + it("defaults to horizontal orientation", () => { + render() + expect(screen.getByRole("separator")).toHaveAttribute("aria-orientation", "horizontal") + }) + + it("renders vertical variant", () => { + render() + const sep = screen.getByRole("separator") + expect(sep).toHaveAttribute("aria-orientation", "vertical") + expect(sep).toHaveClass("w-px", "h-full") + }) + + it("renders label text on horizontal divider", () => { + render() + expect(screen.getByText("OR")).toBeInTheDocument() + }) + + it("renders two line elements flanking the label", () => { + const { container } = render() + const lines = container.querySelectorAll(".flex-1") + expect(lines).toHaveLength(2) + }) + + it("applies subtle variant by default", () => { + render() + expect(screen.getByRole("separator")).toHaveClass("opacity-40") + }) + + it("applies prominent variant", () => { + render() + expect(screen.getByRole("separator")).toHaveClass("opacity-100") + }) + + it("applies custom color via inline style", () => { + render() + expect(screen.getByRole("separator")).toHaveStyle({ backgroundColor: "#ff0000" }) + }) + + it("applies custom className", () => { + render() + expect(screen.getByRole("separator")).toHaveClass("my-divider") + }) +}) diff --git a/soroscan-frontend/components/ui/avatar.tsx b/soroscan-frontend/components/ui/avatar.tsx new file mode 100644 index 00000000..500c3243 --- /dev/null +++ b/soroscan-frontend/components/ui/avatar.tsx @@ -0,0 +1,94 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" + +const avatarVariants = cva( + "relative inline-flex items-center justify-center rounded-full overflow-hidden shrink-0 group", + { + variants: { + size: { + sm: "w-8 h-8 text-xs", + md: "w-10 h-10 text-sm", + lg: "w-14 h-14 text-base", + }, + }, + defaultVariants: { + size: "md", + }, + } +) + +/** Deterministic hue from a string so each user gets a consistent colour. */ +function colorFromName(name: string): string { + let hash = 0 + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash) + } + const hue = Math.abs(hash) % 360 + return `hsl(${hue}, 55%, 50%)` +} + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/) + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase() + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() +} + +export interface AvatarProps + extends React.HTMLAttributes, + VariantProps { + /** Image URL — falls back to initials if omitted or load fails */ + src?: string + /** Full name used for initials and tooltip */ + name: string + /** Override background color for the initials fallback */ + color?: string +} + +function Avatar({ src, name, size, color, className, ...props }: AvatarProps) { + const [imgError, setImgError] = React.useState(false) + const showImage = !!src && !imgError + const initials = getInitials(name) + const bgColor = color ?? colorFromName(name) + + return ( +
+ {showImage ? ( + {name} setImgError(true)} + /> + ) : ( + + {initials} + + )} + + {/* Hover tooltip */} + +
+ ) +} + +export { Avatar, avatarVariants } diff --git a/soroscan-frontend/components/ui/breadcrumb.tsx b/soroscan-frontend/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..e7f144a9 --- /dev/null +++ b/soroscan-frontend/components/ui/breadcrumb.tsx @@ -0,0 +1,61 @@ +import * as React from "react" +import Link from "next/link" +import { cn } from "@/lib/utils" + +export interface BreadcrumbItem { + /** Display label */ + label: string + /** Navigation href — omit for the current (last) page */ + href?: string +} + +export interface BreadcrumbProps extends React.ComponentProps<"nav"> { + items: BreadcrumbItem[] + /** Separator node rendered between items (default: "/") */ + separator?: React.ReactNode +} + +function Breadcrumb({ + items, + separator = "/", + className, + ...props +}: BreadcrumbProps) { + return ( + + ) +} + +export { Breadcrumb }