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
2 changes: 1 addition & 1 deletion dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export function App() {
className="app-tabs__list"
onKeyDown={handleTabKeyDown}
>
{NAV_ITEMS.map((item, index) => (
{NAV_ITEMS.map((item) => (
<button
key={item.id}
type="button"
Expand Down
1 change: 0 additions & 1 deletion dashboard/src/__tests__/accessibility.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
* Accessibility audit tests (#394)
* Uses jest-axe to detect WCAG 2.1 AA violations in key components.
*/
import React from 'react';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import { axe, toHaveNoViolations } from 'jest-axe';
Expand Down
1 change: 0 additions & 1 deletion dashboard/src/components/MobileNavDrawer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/**
* Tests for MobileNavDrawer (#396)
*/
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MobileNavDrawer, NAV_ITEMS } from '../components/MobileNavDrawer';
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/components/NotificationTimelineView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ describe('NotificationTimelineView', () => {
fireEvent.submit(screen.getByRole('search'));

await waitFor(() =>
expect(screen.getByText(/no history entries found/i)).toBeInTheDocument()
expect(screen.getByText(/no history entries/i)).toBeInTheDocument()
);
});

Expand Down
9 changes: 7 additions & 2 deletions dashboard/src/context/ToastContext.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
/**
* Tests for ToastContext (#397)
*/
import React from 'react';
import { render, screen, act, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ToastProvider, useToast } from '../context/ToastContext';

interface ToastTriggerProps {
message?: string;
variant?: 'success' | 'error' | 'warning' | 'info';
duration?: number;
}

// Helper component that exposes toast actions
function ToastTrigger({ message = 'Test message', variant = 'success' as const, duration = 0 }) {
function ToastTrigger({ message = 'Test message', variant = 'success' as const, duration = 0 }: ToastTriggerProps) {
const { addToast, removeToast, showSuccess, showError, showInfo, showWarning, toasts } =
useToast();
return (
Expand Down
3 changes: 1 addition & 2 deletions dashboard/src/pages/EventExplorerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ export function EventExplorerPage() {
setSearch('');
setContractFilter('');
setEventTypeFilter('');
setStatusFilter('');
setStatusFilter('all');
setDateFrom('');
setDateTo('');
setPage(1);
Expand Down Expand Up @@ -297,7 +297,6 @@ export function EventExplorerPage() {
description="Update the search, event type, or contract filter to uncover matching Soroban contract events."
action={{ label: 'Clear filters', onClick: handleClearFilters }}
/>
/>
)}

<PaginationControls
Expand Down
52 changes: 24 additions & 28 deletions dashboard/src/pages/NotificationSearchPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import '@testing-library/jest-dom';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { NotificationSearchPage } from './NotificationSearchPage';
import * as eventsApi from '../services/eventsApi';
import { searchNotifications } from '../services/eventsApi';
import type { NotificationSearchResponse } from '../services/eventsApi';

jest.mock('../services/eventsApi', () => {
const actual = jest.requireActual('../services/eventsApi') as typeof import('../services/eventsApi');
Expand All @@ -10,11 +12,9 @@ jest.mock('../services/eventsApi', () => {
};
});

const searchNotifications = eventsApi.searchNotifications as jest.MockedFunction<
typeof eventsApi.searchNotifications
>;
const mockedSearch = searchNotifications as jest.MockedFunction<typeof searchNotifications>;

function emptyResponse(): eventsApi.NotificationSearchResponse {
function emptyResponse(): NotificationSearchResponse {
return {
results: [],
total: 0,
Expand All @@ -25,23 +25,6 @@ function emptyResponse(): eventsApi.NotificationSearchResponse {
};
}

describe('NotificationSearchPage filters', () => {
beforeEach(() => {
jest.useFakeTimers();
searchNotifications.mockReset();
searchNotifications.mockResolvedValue(emptyResponse());
import '@testing-library/jest-dom';
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import { NotificationSearchPage } from './NotificationSearchPage';
import { searchNotifications } from '../services/eventsApi';
import type { NotificationSearchResponse } from '../services/eventsApi';

jest.mock('../services/eventsApi', () => ({
searchNotifications: jest.fn(),
}));

const mockedSearch = searchNotifications as jest.MockedFunction<typeof searchNotifications>;

const mockResult: NotificationSearchResponse = {
results: [
{
Expand All @@ -64,10 +47,11 @@ const mockResult: NotificationSearchResponse = {
totalPages: 1,
};

describe('NotificationSearchPage loading skeletons', () => {
describe('NotificationSearchPage filters', () => {
beforeEach(() => {
mockedSearch.mockReset();
jest.useFakeTimers();
mockedSearch.mockReset();
mockedSearch.mockResolvedValue(emptyResponse());
});

afterEach(() => {
Expand Down Expand Up @@ -108,10 +92,10 @@ describe('NotificationSearchPage loading skeletons', () => {
});

await waitFor(() => {
expect(searchNotifications).toHaveBeenCalled();
expect(mockedSearch).toHaveBeenCalled();
});

const lastCall = searchNotifications.mock.calls[searchNotifications.mock.calls.length - 1];
const lastCall = mockedSearch.mock.calls[mockedSearch.mock.calls.length - 1];
expect(lastCall?.[1]).toMatchObject({
type: 'discord',
status: 'FAILED',
Expand All @@ -121,7 +105,7 @@ describe('NotificationSearchPage loading skeletons', () => {
});

it('updates results when filters change', async () => {
searchNotifications.mockResolvedValue({
mockedSearch.mockResolvedValue({
results: [
{
id: 1,
Expand Down Expand Up @@ -199,7 +183,6 @@ describe('searchNotifications query params', () => {
});

it('appends type, status, startDate, and endDate to the URL', async () => {
// Use the real implementation (not the page mock)
const { searchNotifications: realSearch } = jest.requireActual(
'../services/eventsApi'
) as typeof import('../services/eventsApi');
Expand All @@ -218,6 +201,19 @@ describe('searchNotifications query params', () => {
expect(calledUrl).toContain('status=COMPLETED');
expect(calledUrl).toContain('startDate=2026-01-01');
expect(calledUrl).toContain('endDate=2026-01-31');
});
});

describe('NotificationSearchPage loading skeletons', () => {
beforeEach(() => {
mockedSearch.mockReset();
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it('shows result-card skeletons while searching and hides Searching text', async () => {
mockedSearch.mockReturnValue(new Promise(() => {}));

Expand Down
5 changes: 2 additions & 3 deletions dashboard/src/pages/NotificationSearchPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { NotificationSearchSkeleton } from '../components/NotificationSearchSkeleton';
import { formatTimestamp } from '../utils/formatTime';
import { getEventsApiBaseUrl } from '../config/eventsApiUrl';
import { useDebounce } from '../hooks/useDebounce';
import { EmptyState } from '../components/EmptyState';
Expand Down Expand Up @@ -462,7 +463,6 @@ function NotificationResultCard({ result }: { result: NotificationSearchResult }
{result.eventId && (
<>
<dt>Event ID</dt>
<dd>{result.eventId}</dd>
<dd>
<code>{result.eventId}</code>
<CopyButton value={result.eventId} label="event ID" size="xs" />
Expand All @@ -472,7 +472,6 @@ function NotificationResultCard({ result }: { result: NotificationSearchResult }
{result.txHash && (
<>
<dt>Tx Hash</dt>
<dd>{result.txHash}</dd>
<dd>
<code>{result.txHash}</code>
<CopyButton value={result.txHash} label="tx hash" size="xs" />
Expand All @@ -498,7 +497,7 @@ function NotificationResultCard({ result }: { result: NotificationSearchResult }
</>
)}
<dt>Created</dt>
<dd>{new Date(result.createdAt).toLocaleString()}</dd>
<dd>{formatTimestamp(result.createdAt)}</dd>
</dl>
</article>
);
Expand Down
113 changes: 95 additions & 18 deletions dashboard/src/utils/formatTime.test.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,142 @@
import { formatTimestamp, formatTimestampShort } from './formatTime';
import { formatTimestamp, formatTimestampShort, parseToDate } from './formatTime';

// 2024-01-15 14:30:45 UTC
const FIXED_TIMESTAMP = 1705329045000;
const FIXED_TIMESTAMP_MS = 1705329045000;
const FIXED_TIMESTAMP_SEC = 1705329045;

describe('parseToDate helper', () => {
it('parses valid Date objects correctly', () => {
const d = new Date();
expect(parseToDate(d)).toBe(d);
});

it('handles invalid Date objects by returning null', () => {
expect(parseToDate(new Date('invalid-date'))).toBeNull();
});

it('parses Unix millisecond numbers correctly', () => {
const d = parseToDate(FIXED_TIMESTAMP_MS);
expect(d).not.toBeNull();
expect(d!.getTime()).toBe(FIXED_TIMESTAMP_MS);
});

it('parses Unix second numbers correctly by auto-converting to milliseconds', () => {
const d = parseToDate(FIXED_TIMESTAMP_SEC);
expect(d).not.toBeNull();
expect(d!.getTime()).toBe(FIXED_TIMESTAMP_MS);
});

it('parses valid ISO string dates correctly', () => {
const d = parseToDate('2024-01-15T14:30:45.000Z');
expect(d).not.toBeNull();
expect(d!.getTime()).toBe(FIXED_TIMESTAMP_MS);
});

it('parses numeric string dates in seconds correctly', () => {
const d = parseToDate('1705329045');
expect(d).not.toBeNull();
expect(d!.getTime()).toBe(FIXED_TIMESTAMP_MS);
});

it('parses numeric string dates in milliseconds correctly', () => {
const d = parseToDate('1705329045000');
expect(d).not.toBeNull();
expect(d!.getTime()).toBe(FIXED_TIMESTAMP_MS);
});

it('returns null for invalid strings', () => {
expect(parseToDate('not-a-date')).toBeNull();
expect(parseToDate(' ')).toBeNull();
});

it('returns null for null and undefined', () => {
expect(parseToDate(null)).toBeNull();
expect(parseToDate(undefined)).toBeNull();
});

it('returns null for NaN and infinity', () => {
expect(parseToDate(NaN)).toBeNull();
expect(parseToDate(Infinity)).toBeNull();
});
});

describe('formatTimestamp', () => {
it('returns a non-empty string for a valid timestamp', () => {
expect(typeof formatTimestamp(FIXED_TIMESTAMP)).toBe('string');
expect(formatTimestamp(FIXED_TIMESTAMP).length).toBeGreaterThan(0);
expect(typeof formatTimestamp(FIXED_TIMESTAMP_MS)).toBe('string');
expect(formatTimestamp(FIXED_TIMESTAMP_MS).length).toBeGreaterThan(0);
});

it('includes a timezone abbreviation in the output', () => {
const result = formatTimestamp(FIXED_TIMESTAMP);
const result = formatTimestamp(FIXED_TIMESTAMP_MS);
// Intl.DateTimeFormat with timeZoneName:'short' always appends a timezone token
// (e.g. "UTC", "EST", "GMT+5") — check a broad pattern
expect(result).toMatch(/[A-Z]{2,5}[+-]?\d*|UTC/);
expect(result).toMatch(/[A-Z]{2,5}[+-]?\d*|UTC|GMT/);
});

it('includes the year in the output', () => {
expect(formatTimestamp(FIXED_TIMESTAMP)).toContain('2024');
expect(formatTimestamp(FIXED_TIMESTAMP_MS)).toContain('2024');
});

it('produces different output for different timestamps', () => {
const earlier = formatTimestamp(FIXED_TIMESTAMP - 60000);
const later = formatTimestamp(FIXED_TIMESTAMP);
const earlier = formatTimestamp(FIXED_TIMESTAMP_MS - 60000);
const later = formatTimestamp(FIXED_TIMESTAMP_MS);
expect(earlier).not.toBe(later);
});

it('handles the Unix epoch without throwing', () => {
expect(() => formatTimestamp(0)).not.toThrow();
expect(formatTimestamp(0)).not.toBe('Unknown time');
});

it('handles large future timestamps without throwing', () => {
it('handles large future timestamps and old past timestamps without throwing', () => {
const farFuture = Date.now() + 365 * 24 * 60 * 60 * 1000;
expect(() => formatTimestamp(farFuture)).not.toThrow();
expect(formatTimestamp(farFuture)).not.toBe('Unknown time');

const farPast = -100 * 365 * 24 * 60 * 60 * 1000; // ~100 years ago
expect(() => formatTimestamp(farPast)).not.toThrow();
expect(formatTimestamp(farPast)).not.toBe('Unknown time');
});

it('returns fallback string "Unknown time" on invalid/null/undefined inputs', () => {
expect(formatTimestamp(null)).toBe('Unknown time');
expect(formatTimestamp(undefined)).toBe('Unknown time');
expect(formatTimestamp('malformed string')).toBe('Unknown time');
expect(formatTimestamp(NaN)).toBe('Unknown time');
});
});

describe('formatTimestampShort', () => {
it('returns a non-empty string for a valid timestamp', () => {
expect(typeof formatTimestampShort(FIXED_TIMESTAMP)).toBe('string');
expect(formatTimestampShort(FIXED_TIMESTAMP).length).toBeGreaterThan(0);
expect(typeof formatTimestampShort(FIXED_TIMESTAMP_MS)).toBe('string');
expect(formatTimestampShort(FIXED_TIMESTAMP_MS).length).toBeGreaterThan(0);
});

it('includes a timezone abbreviation in the output', () => {
const result = formatTimestampShort(FIXED_TIMESTAMP);
expect(result).toMatch(/[A-Z]{2,5}[+-]?\d*|UTC/);
const result = formatTimestampShort(FIXED_TIMESTAMP_MS);
expect(result).toMatch(/[A-Z]{2,5}[+-]?\d*|UTC|GMT/);
});

it('is shorter than or equal to the full timestamp for the same input', () => {
const full = formatTimestamp(FIXED_TIMESTAMP);
const short = formatTimestampShort(FIXED_TIMESTAMP);
const full = formatTimestamp(FIXED_TIMESTAMP_MS);
const short = formatTimestampShort(FIXED_TIMESTAMP_MS);
expect(short.length).toBeLessThanOrEqual(full.length);
});

it('produces different output for different timestamps', () => {
const earlier = formatTimestampShort(FIXED_TIMESTAMP - 3600000);
const later = formatTimestampShort(FIXED_TIMESTAMP);
const earlier = formatTimestampShort(FIXED_TIMESTAMP_MS - 3600000);
const later = formatTimestampShort(FIXED_TIMESTAMP_MS);
expect(earlier).not.toBe(later);
});

it('handles the Unix epoch without throwing', () => {
expect(() => formatTimestampShort(0)).not.toThrow();
expect(formatTimestampShort(0)).not.toBe('Unknown time');
});

it('returns fallback string "Unknown time" on invalid/null/undefined inputs', () => {
expect(formatTimestampShort(null)).toBe('Unknown time');
expect(formatTimestampShort(undefined)).toBe('Unknown time');
expect(formatTimestampShort('invalid date string')).toBe('Unknown time');
});
});
Loading
Loading