From 48059447a090cd47880e307cfde6c66c3f097aa2 Mon Sep 17 00:00:00 2001 From: topsonDev <275107762+topsonDev@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:15:23 +0000 Subject: [PATCH] simple: batch fix 4 issues Co-authored-by: Simple Agent --- __tests__/components/ui/tabs.test.tsx | 94 +++++++++++++++ __tests__/lib/stellar.test.ts | 162 +++++++++++++++++++++++++- __tests__/lib/stream-utils.test.ts | 109 ++++++++++++++++- contracts/streaming/src/test.rs | 33 ++++++ 4 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 __tests__/components/ui/tabs.test.tsx diff --git a/__tests__/components/ui/tabs.test.tsx b/__tests__/components/ui/tabs.test.tsx new file mode 100644 index 0000000..c37e458 --- /dev/null +++ b/__tests__/components/ui/tabs.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; + +function renderTabs() { + return render( + + + Account + Password + Settings + + Account panel + Password panel + Settings panel + , + ); +} + +describe("Tabs", () => { + it("renders all triggers and the active panel", () => { + renderTabs(); + + expect( + screen.getByRole("tab", { name: "Account" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("tab", { name: "Password" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("tab", { name: "Settings" }), + ).toBeInTheDocument(); + + expect(screen.getByText("Account panel")).toBeInTheDocument(); + expect(screen.queryByText("Password panel")).not.toBeInTheDocument(); + expect(screen.queryByText("Settings panel")).not.toBeInTheDocument(); + }); + + it("switches panels when a trigger is clicked", async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByRole("tab", { name: "Password" })); + + expect(screen.getByText("Password panel")).toBeInTheDocument(); + expect(screen.queryByText("Account panel")).not.toBeInTheDocument(); + expect(screen.queryByText("Settings panel")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: "Settings" })); + + expect(screen.getByText("Settings panel")).toBeInTheDocument(); + expect(screen.queryByText("Password panel")).not.toBeInTheDocument(); + expect(screen.queryByText("Account panel")).not.toBeInTheDocument(); + }); + + it("navigates between triggers with arrow keys", async () => { + const user = userEvent.setup(); + renderTabs(); + + const accountTab = screen.getByRole("tab", { name: "Account" }); + const passwordTab = screen.getByRole("tab", { name: "Password" }); + const settingsTab = screen.getByRole("tab", { name: "Settings" }); + + accountTab.focus(); + expect(accountTab).toHaveFocus(); + + await user.keyboard("{ArrowRight}"); + expect(passwordTab).toHaveFocus(); + expect(screen.getByText("Password panel")).toBeInTheDocument(); + expect(screen.queryByText("Account panel")).not.toBeInTheDocument(); + + await user.keyboard("{ArrowRight}"); + expect(settingsTab).toHaveFocus(); + expect(screen.getByText("Settings panel")).toBeInTheDocument(); + expect(screen.queryByText("Password panel")).not.toBeInTheDocument(); + + await user.keyboard("{ArrowLeft}"); + expect(passwordTab).toHaveFocus(); + expect(screen.getByText("Password panel")).toBeInTheDocument(); + expect(screen.queryByText("Settings panel")).not.toBeInTheDocument(); + + await user.keyboard("{ArrowLeft}"); + expect(accountTab).toHaveFocus(); + expect(screen.getByText("Account panel")).toBeInTheDocument(); + expect(screen.queryByText("Password panel")).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/__tests__/lib/stellar.test.ts b/__tests__/lib/stellar.test.ts index 1b699e8..911922a 100644 --- a/__tests__/lib/stellar.test.ts +++ b/__tests__/lib/stellar.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { explorerUrl, isValidStellarAddress } from '@/lib/stellar' describe('isValidStellarAddress', () => { @@ -164,3 +164,163 @@ describe('explorerUrl', () => { }) }) }) + +// A known built-in token address used across the verified-token tests. +const KNOWN_TOKEN_ADDRESS = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC' +const UNKNOWN_TOKEN_ADDRESS = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4' + +const VERIFIED_TOKENS_FIXTURE = [ + { + address: KNOWN_TOKEN_ADDRESS, + symbol: 'USDC', + name: 'USD Coin', + decimals: 7, + }, +] + +/** + * The verified-token list is cached at module scope, so each test must load a + * fresh copy of the module (after configuring mocks) to avoid cache leakage. + */ +async function importStellar() { + vi.resetModules() + return import('@/lib/stellar') +} + +function mockFetchJson(payload: unknown) { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(payload), + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +describe('verified token helpers', () => { + beforeEach(() => { + vi.resetModules() + vi.unstubAllGlobals() + localStorage.clear() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + localStorage.clear() + }) + + describe('loadVerifiedTokens', () => { + it('loads verified tokens from the remote list', async () => { + const fetchMock = mockFetchJson({ tokens: VERIFIED_TOKENS_FIXTURE }) + const { loadVerifiedTokens } = await importStellar() + + const tokens = await loadVerifiedTokens() + + expect(fetchMock).toHaveBeenCalled() + expect(tokens).toHaveLength(VERIFIED_TOKENS_FIXTURE.length) + expect(tokens[0]).toMatchObject({ + address: KNOWN_TOKEN_ADDRESS, + symbol: 'USDC', + }) + }) + + it('falls back gracefully when the request rejects', async () => { + const captureError = vi.fn() + vi.doMock('@/lib/sentry', () => ({ captureError })) + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down'))) + const { loadVerifiedTokens } = await importStellar() + + await expect(loadVerifiedTokens()).resolves.toEqual([]) + expect(captureError).toHaveBeenCalled() + }) + + it('falls back gracefully when the response is not ok', async () => { + const captureError = vi.fn() + vi.doMock('@/lib/sentry', () => ({ captureError })) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: () => Promise.resolve({}), + }), + ) + const { loadVerifiedTokens } = await importStellar() + + await expect(loadVerifiedTokens()).resolves.toEqual([]) + expect(captureError).toHaveBeenCalled() + }) + }) + + describe('isVerifiedToken', () => { + it('returns true for a known verified token address', async () => { + mockFetchJson({ tokens: VERIFIED_TOKENS_FIXTURE }) + const { loadVerifiedTokens, isVerifiedToken } = await importStellar() + + await loadVerifiedTokens() + + expect(isVerifiedToken(KNOWN_TOKEN_ADDRESS)).toBe(true) + }) + + it('returns false for an unknown token address', async () => { + mockFetchJson({ tokens: VERIFIED_TOKENS_FIXTURE }) + const { loadVerifiedTokens, isVerifiedToken } = await importStellar() + + await loadVerifiedTokens() + + expect(isVerifiedToken(UNKNOWN_TOKEN_ADDRESS)).toBe(false) + }) + }) + + describe('getAllTokens', () => { + it('merges built-in, verified and custom tokens without duplicates', async () => { + mockFetchJson({ tokens: VERIFIED_TOKENS_FIXTURE }) + const { loadVerifiedTokens, getAllTokens } = await importStellar() + + await loadVerifiedTokens() + + const customTokens = [ + { + address: 'CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + symbol: 'CSTM', + name: 'Custom Token', + decimals: 7, + }, + // Overlaps with the verified token; must not be duplicated. + { + address: KNOWN_TOKEN_ADDRESS, + symbol: 'USDC', + name: 'USD Coin', + decimals: 7, + }, + ] + localStorage.setItem('customTokens', JSON.stringify(customTokens)) + + const tokens = getAllTokens('testnet') + const addresses = tokens.map((t) => t.address) + + // No duplicate addresses. + expect(new Set(addresses).size).toBe(addresses.length) + + // Verified token is present exactly once. + expect(addresses.filter((a) => a === KNOWN_TOKEN_ADDRESS)).toHaveLength(1) + + // Custom-only token is included. + expect(addresses).toContain(customTokens[0].address) + }) + + it('returns the network tokens when there are no custom tokens', async () => { + mockFetchJson({ tokens: VERIFIED_TOKENS_FIXTURE }) + const { loadVerifiedTokens, getAllTokens } = await importStellar() + + await loadVerifiedTokens() + + const tokens = getAllTokens('testnet') + const addresses = tokens.map((t) => t.address) + + expect(addresses).toContain(KNOWN_TOKEN_ADDRESS) + expect(new Set(addresses).size).toBe(addresses.length) + }) + }) +}) diff --git a/__tests__/lib/stream-utils.test.ts b/__tests__/lib/stream-utils.test.ts index 7728d2d..4e6373f 100644 --- a/__tests__/lib/stream-utils.test.ts +++ b/__tests__/lib/stream-utils.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi, afterEach } from 'vitest' import { getUnlockedAmount, getWithdrawableAmount, @@ -12,6 +12,7 @@ import { shortenAddress, formatTimeRemaining, formatDateTime, + formatTimeAgo, } from '@/lib/stream-utils' import type { StreamData } from '@/types/stream' @@ -360,3 +361,109 @@ describe('formatDateTime', () => { expect(formatDateTime(ts)).toBe(formatDateTime(ts)) }) }) + +// --------------------------------------------------------------------------- +// formatTimeAgo +// --------------------------------------------------------------------------- + +/** + * formatTimeAgo compares a millisecond timestamp against Date.now(). + * We pin the system clock with fake timers so every assertion is deterministic. + */ +describe('formatTimeAgo', () => { + const NOW = 1_700_000_000_000 // fixed reference point in ms + + afterEach(() => { + vi.useRealTimers() + }) + + function pinNow() { + vi.useFakeTimers() + vi.setSystemTime(NOW) + } + + it('returns "just now" for a timestamp equal to now', () => { + pinNow() + expect(formatTimeAgo(NOW)).toBe('just now') + }) + + it('returns "just now" for a few seconds ago', () => { + pinNow() + expect(formatTimeAgo(NOW - 5_000)).toBe('just now') + }) + + it('boundary: 59s ago is still "just now"', () => { + pinNow() + expect(formatTimeAgo(NOW - 59_000)).toBe('just now') + }) + + it('boundary: 60s ago is "1m ago"', () => { + pinNow() + expect(formatTimeAgo(NOW - 60_000)).toBe('1m ago') + }) + + it('formats minutes', () => { + pinNow() + expect(formatTimeAgo(NOW - 5 * 60_000)).toBe('5m ago') + }) + + it('formats 59 minutes as minutes', () => { + pinNow() + expect(formatTimeAgo(NOW - 59 * 60_000)).toBe('59m ago') + }) + + it('boundary: 1h ago is "1h ago"', () => { + pinNow() + expect(formatTimeAgo(NOW - 3_600_000)).toBe('1h ago') + }) + + it('formats hours', () => { + pinNow() + expect(formatTimeAgo(NOW - 5 * 3_600_000)).toBe('5h ago') + }) + + it('formats 23 hours as hours', () => { + pinNow() + expect(formatTimeAgo(NOW - 23 * 3_600_000)).toBe('23h ago') + }) + + it('boundary: 24h ago is "1d ago"', () => { + pinNow() + expect(formatTimeAgo(NOW - 86_400_000)).toBe('1d ago') + }) + + it('formats days', () => { + pinNow() + expect(formatTimeAgo(NOW - 3 * 86_400_000)).toBe('3d ago') + }) + + it('formats 6 days as days', () => { + pinNow() + expect(formatTimeAgo(NOW - 6 * 86_400_000)).toBe('6d ago') + }) + + it('boundary: 7 days ago falls through to a locale date string', () => { + pinNow() + const result = formatTimeAgo(NOW - 7 * 86_400_000) + expect(result).not.toMatch(/ago/) + expect(result).not.toBe('just now') + // Should be a date string for the timestamp 7 days before NOW + expect(result).toBe(new Date(NOW - 7 * 86_400_000).toLocaleDateString()) + }) + + it('returns a locale date string for timestamps older than 7 days', () => { + pinNow() + const ts = NOW - 30 * 86_400_000 + expect(formatTimeAgo(ts)).toBe(new Date(ts).toLocaleDateString()) + }) + + it('returns "just now" for future timestamps', () => { + pinNow() + expect(formatTimeAgo(NOW + 60_000)).toBe('just now') + }) + + it('returns "just now" for far-future timestamps', () => { + pinNow() + expect(formatTimeAgo(NOW + 86_400_000)).toBe('just now') + }) +}) diff --git a/contracts/streaming/src/test.rs b/contracts/streaming/src/test.rs index 87550d1..7d440c6 100644 --- a/contracts/streaming/src/test.rs +++ b/contracts/streaming/src/test.rs @@ -1625,6 +1625,39 @@ fn test_remove_delegate() { assert_eq!(client.get_delegate(&stream_id), None); } +#[test] +fn test_remove_delegate_rejected_while_paused() { + let t = TestEnv::setup(); + let now = 1_000_000u64; + t.set_time(now); + let client = t.client(); + let params = t.default_params(now); + let total = params.total_amount; + + t.token().approve( + &t.sender, + &t.contract_id, + &total, + &(t.env.ledger().sequence() + 500), + ); + let stream_id = client.create_stream(&t.sender, ¶ms); + + // Recipient sets a delegate. + let delegate = Address::generate(&t.env); + client.set_delegate(&stream_id, &delegate); + assert_eq!(client.get_delegate(&stream_id), Some(delegate.clone())); + + // Admin pauses the contract. + client.pause(); + + // remove_delegate must be rejected with the paused error. + let result = client.try_remove_delegate(&stream_id); + assert_eq!(result, Err(Ok(StreamError::ContractPaused))); + + // The delegate must be unchanged after the failed call. + assert_eq!(client.get_delegate(&stream_id), Some(delegate)); +} + #[test] fn test_transfer_clears_delegate() { let t = TestEnv::setup();