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
235 changes: 235 additions & 0 deletions __tests__/e2e/global-search-debounce.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import React from 'react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { GlobalSearch } from '@/components/search/GlobalSearch';
import { GLOBAL_SEARCH_DEBOUNCE_MS } from '@/hooks/useGlobalSearch';
import api from '@/lib/api';

/**
* E2E: Global Unified Search and Debounce Logic
*
* Exercises the full search stack (component → hook → service) with the
* HTTP client, wallet, and WebSocket layers mocked so the suite stays
* deterministic.
*/

jest.mock('@/lib/api', () => ({
__esModule: true,
default: { get: jest.fn() },
}));

jest.mock('@/hooks/useWallet', () => ({
useWallet: () => ({
connect: jest.fn().mockResolvedValue(true),
address: 'GABCD...MOCK_WALLET_ADDRESS',
isConnected: true,
signTransaction: jest.fn().mockResolvedValue('mock_signed_xdr'),
}),
}));

jest.mock('socket.io-client', () => ({
io: jest.fn(() => ({
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
disconnect: jest.fn(),
})),
}));

const mockGet = api.get as jest.Mock;

const MIXED_RESULTS = [
{ id: 't1', category: 'transactions', title: '0xabc123', subtitle: '250 XLM' },
{ id: 'd1', category: 'deliveries', title: 'TRK001', subtitle: 'In transit' },
{ id: 'v1', category: 'drivers', title: 'Ada Obi', subtitle: 'Lagos' },
{ id: 'd2', category: 'deliveries', title: 'TRK002', subtitle: 'Pending' },
];

function delay(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

function typeIntoSearch(value: string) {
fireEvent.change(screen.getByRole('searchbox', { name: 'Global search' }), {
target: { value },
});
}

describe('E2E: Global Unified Search and Debounce Logic', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGet.mockResolvedValue({ data: { results: [] } });
});

it('renders the global search bar without querying the API on idle', () => {
render(<GlobalSearch />);

expect(screen.getByRole('searchbox', { name: 'Global search' })).toBeInTheDocument();
expect(mockGet).not.toHaveBeenCalled();
expect(screen.queryByText('Searching...')).not.toBeInTheDocument();
});

it('does not fire the API while the user is still typing', () => {
render(<GlobalSearch />);

typeIntoSearch('lagos');

expect(mockGet).not.toHaveBeenCalled();
expect(screen.getByText('Searching...')).toBeInTheDocument();
});

it('fires a single API call with the final query after the debounce window', async () => {
mockGet.mockResolvedValue({ data: { results: MIXED_RESULTS } });
render(<GlobalSearch />);

typeIntoSearch('lagos');
expect(mockGet).not.toHaveBeenCalled();

await waitFor(
() => {
expect(mockGet).toHaveBeenCalledTimes(1);
},
{ timeout: GLOBAL_SEARCH_DEBOUNCE_MS + 500 },
);
expect(mockGet).toHaveBeenCalledWith('/search', {
params: { q: 'lagos' },
signal: expect.any(AbortSignal),
});
});

it('resets the debounce timer on each keystroke so intermediate queries never hit the API', async () => {
mockGet.mockResolvedValue({ data: { results: MIXED_RESULTS } });
render(<GlobalSearch />);

typeIntoSearch('l');
await act(async () => {
await delay(80);
});
typeIntoSearch('la');
await act(async () => {
await delay(80);
});
typeIntoSearch('lag');

expect(mockGet).not.toHaveBeenCalled();

await waitFor(
() => {
expect(mockGet).toHaveBeenCalledTimes(1);
},
{ timeout: GLOBAL_SEARCH_DEBOUNCE_MS + 500 },
);
expect(mockGet).toHaveBeenCalledWith('/search', {
params: { q: 'lag' },
signal: expect.any(AbortSignal),
});
});

it('shows grouped results after a successful debounced search', async () => {
mockGet.mockResolvedValue({ data: { results: MIXED_RESULTS } });
render(<GlobalSearch />);

typeIntoSearch('a');

expect(await screen.findByRole('heading', { name: 'Deliveries' })).toBeInTheDocument();
expect(screen.getAllByRole('heading').map((heading) => heading.textContent)).toEqual([
'Deliveries',
'Drivers',
'Transactions',
]);

const deliveries = screen.getByRole('region', { name: 'Deliveries' });
expect(within(deliveries).getByText('TRK001')).toBeInTheDocument();
expect(within(deliveries).getByText('TRK002')).toBeInTheDocument();
expect(screen.getByText('Ada Obi')).toBeInTheDocument();
expect(screen.getByText('0xabc123')).toBeInTheDocument();
});

it('shows the empty state when the API returns no matches', async () => {
mockGet.mockResolvedValue({ data: { results: [] } });
render(<GlobalSearch />);

typeIntoSearch('zzz');

expect(await screen.findByText('No results found for "zzz".')).toBeInTheDocument();
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});

it('shows an error when the search API fails after debounce', async () => {
mockGet.mockRejectedValue(new Error('Search service unavailable'));
render(<GlobalSearch />);

typeIntoSearch('trk');

expect(await screen.findByRole('alert')).toHaveTextContent('Search service unavailable');
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});

it('does not query the API for whitespace-only input', async () => {
render(<GlobalSearch />);

typeIntoSearch(' ');
await act(async () => {
await delay(GLOBAL_SEARCH_DEBOUNCE_MS + 50);
});

expect(mockGet).not.toHaveBeenCalled();
expect(screen.queryByText(/No results found/)).not.toBeInTheDocument();
});

it('reports the selected result to the caller', async () => {
const onSelect = jest.fn();
mockGet.mockResolvedValue({ data: { results: MIXED_RESULTS } });
render(<GlobalSearch onSelect={onSelect} />);

typeIntoSearch('ada');
fireEvent.click(await screen.findByRole('button', { name: /Ada Obi/ }));

expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({
id: 'v1',
category: 'drivers',
title: 'Ada Obi',
href: '/fleet/drivers/v1',
}),
);
});

it('clears the query and cancels a pending debounce so the API is never called', async () => {
render(<GlobalSearch />);
const input = screen.getByRole('searchbox', { name: 'Global search' });

typeIntoSearch('lagos');
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }));
await act(async () => {
await delay(GLOBAL_SEARCH_DEBOUNCE_MS + 50);
});

expect(input).toHaveValue('');
expect(mockGet).not.toHaveBeenCalled();
expect(screen.queryByText('Searching...')).not.toBeInTheDocument();
});

it('issues a follow-up request with the latest query after a second debounce', async () => {
mockGet.mockResolvedValueOnce({ data: { results: MIXED_RESULTS } });
mockGet.mockResolvedValueOnce({
data: { results: [MIXED_RESULTS[2]] },
});
render(<GlobalSearch />);

typeIntoSearch('a');
expect(await screen.findByRole('heading', { name: 'Deliveries' })).toBeInTheDocument();
expect(mockGet).toHaveBeenCalledTimes(1);

typeIntoSearch('ada');
await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(2));
expect(mockGet).toHaveBeenLastCalledWith('/search', {
params: { q: 'ada' },
signal: expect.any(AbortSignal),
});
await waitFor(() => expect(screen.queryByText('TRK001')).not.toBeInTheDocument());
expect(screen.getByText('Ada Obi')).toBeInTheDocument();
});
});
2 changes: 1 addition & 1 deletion app/api/federation/resolve/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export async function GET(req: Request) {
const m = toml.match(/FEDERATION_SERVER\s*=\s*"(.*?)"/i);
if (m && m[1]) federationServer = m[1];
}
} catch (e) {
} catch {
// ignore discovery errors and fallback
}

Expand Down
2 changes: 1 addition & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { WorkflowCards } from '@/components/deliveries/WorkflowCards';
import { ValueProps } from '@/components/landing/ValueProps';
import { CallToAction } from '@/components/landing/CallToAction';
import { TrustBar } from '@/components/landing/TrustBar';
import { PricingCards } from '@/components/pricing/PricingCards';
import { KineticExplorer } from '@/components/landing/KineticExplorer';

export default function Home() {
return (
Expand Down
50 changes: 22 additions & 28 deletions components/mobile/MobileFooter.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,45 @@
`use client
'use client';

import React from react
import Link from next/link
import React from 'react';
import Link from 'next/link';

// Navigation links data
const footerLinks = [
{ label: Home, href: / },
{ label: About, href: /about },
{ label: Services, href: /services },
{ label: Support, href: /support },
{ label: Privacy Policy, href: /privacy },
{ label: Terms of Service, href: /terms },
{ label: Contact, href: /contact },
{ label: FAQs, href: /faq },
]
{ label: 'Home', href: '/' },
{ label: 'About', href: '/about' },
{ label: 'Services', href: '/services' },
{ label: 'Support', href: '/support' },
{ label: 'Privacy Policy', href: '/privacy' },
{ label: 'Terms of Service', href: '/terms' },
{ label: 'Contact', href: '/contact' },
{ label: 'FAQs', href: '/faq' },
];

// Social icons data
const socialLinks = [
{ label: Twitter, href: https://twitter.com/swiftchain, icon: 🐦 },
{ label: GitHub, href: https://github.com/swiftchain, icon: 🐙 },
{ label: LinkedIn, href: https://linkedin.com/company/swiftchain, icon: 🔗 },
{ label: Discord, href: https://discord.gg/swiftchain, icon: 💬 },
]
{ label: 'Twitter', href: 'https://twitter.com/swiftchain', icon: '🐦' },
{ label: 'GitHub', href: 'https://github.com/swiftchain', icon: '🐙' },
{ label: 'LinkedIn', href: 'https://linkedin.com/company/swiftchain', icon: '🔗' },
{ label: 'Discord', href: 'https://discord.gg/swiftchain', icon: '💬' },
];

export function MobileFooter() {
const currentYear = new Date().getFullYear()
const currentYear = new Date().getFullYear();

return (
<footer className="border-t border-gray-200 bg-white px-4 py-8 dark:border-gray-800 dark:bg-gray-950">
<div className="mx-auto max-w-md">
{/* Navigation links - 2x2 grid on mobile */}
<nav className="mb-6 grid grid-cols-2 gap-2" aria-label="Footer navigation">
{footerLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="rounded-lg px-3 py-2.5 text-sm text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 active:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100 dark:active:bg-gray-700"
style={{ minHeight: 44px }}
style={{ minHeight: 44 }}
>
{link.label}
</Link>
))}
</nav>

{/* Social icons - centered */}
<div className="mb-4 flex justify-center gap-4">
{socialLinks.map((social) => (
<a
Expand All @@ -53,7 +49,7 @@ export function MobileFooter() {
rel="noopener noreferrer"
className="flex h-11 w-11 items-center justify-center rounded-full text-xl transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-800 dark:hover:text-gray-100"
aria-label={social.label}
style={{ minHeight: 44px, minWidth: 44px }}
style={{ minHeight: 44, minWidth: 44 }}
>
<span className="sr-only">{social.label}</span>
<span role="img" aria-hidden="true">
Expand All @@ -63,14 +59,12 @@ export function MobileFooter() {
))}
</div>

{/* Copyright - centered */}
<p className="text-center text-sm text-gray-500 dark:text-gray-400">
&copy; {currentYear} SwiftChain. All rights reserved.
</p>
</div>
</footer>
)
);
}

export default MobileFooter

export default MobileFooter;
Loading
Loading