Skip to content
Merged
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
94 changes: 94 additions & 0 deletions __tests__/components/ui/tabs.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Tabs defaultValue="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="account">Account panel</TabsContent>
<TabsContent value="password">Password panel</TabsContent>
<TabsContent value="settings">Settings panel</TabsContent>
</Tabs>,
);
}

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();
});
});
159 changes: 159 additions & 0 deletions __tests__/lib/stellar.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { explorerUrl, isValidStellarAddress } from '@/lib/stellar'
import { describe, it, expect, beforeEach } from 'vitest'
import {
explorerUrl,
Expand Down Expand Up @@ -177,6 +179,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)
})
describe('custom token storage', () => {
it('returns an empty array when storage is empty', () => {
expect(getCustomTokens('testnet')).toEqual([])
Expand Down
109 changes: 108 additions & 1 deletion __tests__/lib/stream-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi, afterEach } from 'vitest'
import {
getUnlockedAmount,
getWithdrawableAmount,
Expand All @@ -12,6 +12,7 @@ import {
shortenAddress,
formatTimeRemaining,
formatDateTime,
formatTimeAgo,
} from '@/lib/stream-utils'
import type { StreamData } from '@/types/stream'

Expand Down Expand Up @@ -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')
})
})
Loading