Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import React from "react";

// ─── Mocks ──────────────────────────────────────────────────────────────

const mockSession = {
publicKey: "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7",
network: "TESTNET",
walletName: "Freighter",
};

vi.mock("@/context/wallet-context", () => ({
useWallet: () => ({
session: mockSession,
isHydrated: true,
}),
}));

vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));

vi.mock("react-hot-toast", () => ({
default: { success: vi.fn(), error: vi.fn() },
}));

vi.mock("@/lib/api/_shared", () => ({
getApiBaseUrl: () => "http://localhost:4000",
}));

vi.mock("@/lib/logger", () => ({
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));

vi.mock("@/hooks/useStreamEvents", () => ({
useStreamEvents: () => ({ events: [] }),
}));

vi.mock("@/lib/soroban", () => ({
withdrawFromStream: vi.fn(),
cancelStream: vi.fn(),
topUpStream: vi.fn(),
pauseStream: vi.fn(),
resumeStream: vi.fn(),
toBaseUnits: vi.fn((v: string) => BigInt(v)),
toSorobanErrorMessage: vi.fn((e) => String(e)),
}));

vi.mock("@/components/stream-creation/CancelConfirmModal", () => ({
CancelConfirmModal: () => <div data-testid="cancel-modal">Cancel Modal</div>,
}));

vi.mock("@/components/ui/Button", () => ({
Button: ({
children,
onClick,
disabled,
...rest
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { glow?: boolean; variant?: string; children?: React.ReactNode }) => (
<button onClick={onClick} disabled={disabled} {...rest}>
{children}
</button>
),
}));

import StreamDetailsContent from "../stream-details-content";

const STREAM_ID = "42";

function createMockStream() {
return {
id: "stream-42",
streamId: 42,
sender: "GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD",
recipient: "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7",
tokenAddress: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCN",
depositedAmount: "1000000000",
withdrawnAmount: "250000000",
ratePerSecond: "100",
startTime: Math.floor(Date.now() / 1000) - 86400,
endTime: Math.floor(Date.now() / 1000) + 86400,
lastUpdateTime: Math.floor(Date.now() / 1000),
isActive: true,
status: "Active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}

describe("StreamDetailsContent loading skeleton", () => {
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
});

it("renders a distinct loading skeleton with shimmer placeholders while fetch is in-flight", () => {
// Keep fetch pending indefinitely
vi.mocked(global.fetch).mockImplementation(
() => new Promise(() => {}) // never resolves
);

render(<StreamDetailsContent streamId={STREAM_ID} />);

// Should show skeleton elements, not a simple spinner
const skeletonRegion = screen.getByRole("status");
expect(skeletonRegion).toBeInTheDocument();
expect(skeletonRegion).toHaveAttribute("aria-label", "Loading stream details");

// Should NOT show the not-found error state
expect(screen.queryByText(/stream not found/i)).not.toBeInTheDocument();
expect(screen.queryByTestId("cancel-modal")).not.toBeInTheDocument();
});

it("transitions from skeleton to stream content when data loads successfully", async () => {
const mockStream = createMockStream();

vi.mocked(global.fetch)
.mockResolvedValueOnce({
ok: true,
json: async () => mockStream,
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ events: [], total: 0 }),
} as Response);

render(<StreamDetailsContent streamId={STREAM_ID} />);

// Initially shows skeleton
expect(screen.getByRole("status")).toBeInTheDocument();

// After data loads, the skeleton should be replaced by stream details
await waitFor(() => {
expect(screen.getByText(/stream details/i)).toBeInTheDocument();
});

// Skeleton should be gone
expect(screen.queryByRole("status")).not.toBeInTheDocument();

// Stream-specific content should be visible
expect(screen.getByText(/stream #42/i)).toBeInTheDocument();
});

it("transitions from skeleton to not-found state when stream is confirmed missing", async () => {
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({ error: "Stream not found" }),
} as Response);

render(<StreamDetailsContent streamId={STREAM_ID} />);

// Initially shows skeleton
expect(screen.getByRole("status")).toBeInTheDocument();

// After fetch resolves with error, should show the not-found/error state
await waitFor(() => {
expect(screen.getByText(/stream not found/i)).toBeInTheDocument();
});

// Skeleton should be gone
expect(screen.queryByRole("status")).not.toBeInTheDocument();

// The not-found state should have a back-to-dashboard link
expect(screen.getByText(/← back to dashboard/i)).toBeInTheDocument();
});

it("shows 'stream not found' when API returns non-ok response", async () => {
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({ error: "Stream not found" }),
} as Response);

render(<StreamDetailsContent streamId={STREAM_ID} />);

await waitFor(() => {
expect(screen.getByText(/stream not found/i)).toBeInTheDocument();
});

// The not-found/error UI should have a back link
expect(screen.getByText(/← back to dashboard/i)).toBeInTheDocument();
});
});
89 changes: 85 additions & 4 deletions frontend/src/app/streams/[id]/stream-details-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,10 +290,65 @@ export default function StreamDetailsContent({ streamId }: { streamId: string })
if (loading) {
return (
<main className="min-h-screen p-4 md:p-8 bg-gradient-to-br from-zinc-950 via-zinc-900 to-black">
<div className="max-w-4xl mx-auto">
<div className="glass-card p-8 text-center">
<div className="animate-spin h-8 w-8 border-2 border-accent border-t-transparent rounded-full mx-auto mb-4" />
<p className="text-slate-400">Loading stream details...</p>
<div className="max-w-4xl mx-auto space-y-6" aria-label="Loading stream details" role="status">
{/* Skeleton: Header */}
<div className="flex items-center gap-4">
<div className="h-9 w-9 rounded-lg bg-slate-800/60 animate-pulse" />
<div className="space-y-2">
<div className="h-3 w-24 rounded-full bg-slate-800/60 animate-pulse" />
<div className="h-6 w-40 rounded-md bg-slate-800/60 animate-pulse" />
</div>
<div className="ml-auto h-7 w-20 rounded-full bg-slate-800/60 animate-pulse" />
</div>

{/* Skeleton: Stream Overview */}
<div className="glass-card p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<SkeletonLine />
<SkeletonLine />
<SkeletonLine />
</div>
<div className="space-y-4">
<SkeletonLine />
<SkeletonLine />
<SkeletonLine />
</div>
</div>
</div>

{/* Skeleton: Financial Overview */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<SkeletonCard className="h-24" />
<SkeletonCard className="h-24" />
<SkeletonCard className="h-24" />
</div>

{/* Skeleton: Progress */}
<div className="glass-card p-6">
<div className="h-5 w-36 rounded-md bg-slate-800/60 animate-pulse mb-4" />
<div className="h-3 rounded-full bg-slate-800/60 animate-pulse mb-3" />
<div className="h-4 w-48 rounded-md bg-slate-800/60 animate-pulse" />
</div>

{/* Skeleton: Actions */}
<div className="glass-card p-6">
<div className="h-5 w-20 rounded-md bg-slate-800/60 animate-pulse mb-4" />
<div className="flex gap-3">
<div className="h-10 w-28 rounded-xl bg-slate-800/60 animate-pulse" />
<div className="h-10 w-24 rounded-xl bg-slate-800/60 animate-pulse" />
<div className="h-10 w-24 rounded-xl bg-slate-800/60 animate-pulse" />
</div>
</div>

{/* Skeleton: Event History */}
<div className="glass-card p-6">
<div className="h-5 w-32 rounded-md bg-slate-800/60 animate-pulse mb-4" />
<div className="space-y-3">
<SkeletonEventRow />
<SkeletonEventRow />
<SkeletonEventRow />
</div>
</div>
</div>
</main>
Expand Down Expand Up @@ -546,6 +601,32 @@ export default function StreamDetailsContent({ streamId }: { streamId: string })
);
}

function SkeletonLine() {
return <div className="h-4 w-full rounded-md bg-slate-800/60 animate-pulse" />;
}

function SkeletonCard({ className = "" }: { className?: string }) {
return (
<div className={`glass-card p-4 ${className}`}>
<div className="h-3 w-20 rounded-full bg-slate-800/60 animate-pulse mb-2" />
<div className="h-6 w-32 rounded-md bg-slate-800/60 animate-pulse" />
</div>
);
}

function SkeletonEventRow() {
return (
<div className="flex items-center gap-4 py-3">
<div className="h-8 w-8 rounded-full bg-slate-800/60 animate-pulse flex-shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-4 w-24 rounded-md bg-slate-800/60 animate-pulse" />
<div className="h-3 w-36 rounded-full bg-slate-800/60 animate-pulse" />
</div>
<div className="h-4 w-20 rounded-md bg-slate-800/60 animate-pulse" />
</div>
);
}

function StatusBadge({ status, isPaused }: { status: string; isPaused?: boolean }) {
const getStyles = () => {
if (isPaused) return "bg-yellow-500/20 text-yellow-400 border-yellow-500/30";
Expand Down
Loading