Skip to content

Commit 8756d68

Browse files
feat(#929): replace hardcoded mock data in header search with debounced API query
Replace executeSearch in useSearch.tsx with a debounced API call that uses AbortController to cancel in-flight requests on new keystrokes. Keep MOCK_DATA only as a dev fallback (when NEXT_PUBLIC_USE_MOCK_SEARCH=true or API unreachable). Changes:\n- New src/app/api/search/route.ts: Edge-runtime API endpoint with rate limiting, security headers, and scoring/live search\n- Updated src/app/hooks/useSearch.tsx: async executeSearch with AbortController, fetch abort-on-keystroke, mock fallback on failure\n- Updated src/app/hooks/__tests__/useSearch.test.tsx: mock globalThis.fetch instead of local mock data, drain microtasks for async assertions
1 parent c44c6d9 commit 8756d68

3 files changed

Lines changed: 352 additions & 141 deletions

File tree

src/app/api/search/route.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { NextResponse } from 'next/server';
2+
import { withRateLimit } from '@/lib/ratelimit';
3+
import { edgeLog, CDN_CACHE_HEADERS } from '@/../infra/edge-config';
4+
import { withSecurityHeaders, sanitizeObject } from '@/lib/security';
5+
6+
export const runtime = 'edge';
7+
8+
interface SearchResult {
9+
id: string;
10+
title: string;
11+
category: 'course' | 'instructor' | 'topic' | 'investment';
12+
instructor?: string;
13+
rating?: number;
14+
price?: number;
15+
image?: string;
16+
description?: string;
17+
}
18+
19+
interface CategorizedResults {
20+
courses: SearchResult[];
21+
instructors: SearchResult[];
22+
topics: SearchResult[];
23+
investments: SearchResult[];
24+
}
25+
26+
// In-memory catalogue backing the header search endpoint.
27+
// In production this would be replaced by a database query or
28+
// an external search-index service (Algolia, Meilisearch, etc.).
29+
const SEARCH_INDEX: SearchResult[] = [
30+
// --- Courses ---
31+
{ id: 'CS-101', title: 'Advanced UI/UX Masterclass for Digital Products', category: 'course', instructor: 'Dr. Sarah Connor', rating: 4.9, price: 84.99, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuCil99ZvcYdOTX2ikzfmezXpX8PM1gdqvlH9POOIWtf_oLnLUykcTbi_AmwTRyHd91SesbJSqYVeMRowIU8LwxAMfmlo6f_Pz1u8SrLX6MRh78Y4jM36RyPsfr3f6KzeGWxm4kZXTGblOlSET-GxfYpd_Nzdu1P0AVyTV6bpa_jwzSu1ZfKYb7SrSNphUJwOZowmvViRp-dqkbRQYrkWbuITbMKEf_FdUEefC39x97j6p9fuc1eF7A1Z3QFUZVecRUTOyGnzRBp9_Q' },
32+
{ id: 'MK-204', title: 'Data-Driven Growth Strategies for Startups', category: 'course', instructor: 'James Wilson', rating: 4.7, price: 49.99, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAvzbzuSjw86VeI3OOXElSHJGSEvvzouGyeAW34jd3Px-Io4QsMww1pfJW7UOrjmNJJJ_DaLMU81Np88h_XCXTqDiDlcl9mNDYwjXXpxgNJJDTWfwEiAukRqT_aWTm9KNnK-6hjRAZZ1EPYs-Sz8NXTSel_BwuZekfnzd5n0JxMSoI2ke-DWJeJv42Df58VwTZCOadgRyZ6ktv0syTD-xYuIuPk4fe0vYDgPEqVPNAigyQsq3AxWqvfnpntYxcLt3ABxf7aaTSb1cQ' },
33+
{ id: 'GD-009', title: 'Professional Branding: From Concept to Launch', category: 'course', instructor: 'Dr. Sarah Connor', rating: 4.5, price: 119.0, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuDCreAkOdseX9ZVH36oFGC3i0Q7D-DQz4x3zJL5NDtMSwfta8Qw886FBxG-IqaeUipdJkrptvGNhoKABpUsxpWM3mdOIUqxuJd_fIs7rU40m72S62pZ5kpdbKsqlJVJyqsi2WWJEkXEWJbKJwxP8BauVhNPiCvvFftKmHNbg7YM1sd5mq65ce7SyfNYQF70fI6FtiiqDZsVImVhLv0vn7St75HI9mz129PyeaPssxNqC7teU9A8by_hsz-wROUBDqIZLBErccnO0oQ' },
34+
{ id: 'WD-300', title: 'Modern Web Design with Figma and Webflow', category: 'course', instructor: 'Dr. Sarah Connor', rating: 4.6, price: 99.0, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAC6NhUUHDRqyDaSzJlgNygr56BoDO_8fQcX3et2Wnp8VDx35N3nXgqbR-Xt6LuiN8hBJJaqe85edLCUHF5Bfgu7Px9PPIjEpbNqBu_b_BBAFHrqEGmReNkqR478aid53gP2dmZgNyG55_bI7DNrVWMgb85BYuA7qVKC-a25Qn5MboAwsL8FmgvO-VnOtPKgxx-yRNmHY4bAzrF8d9nAusUx1x_WpNRHMsGsuZkOktb-WhkFhppcyDN489FP793xIA_CTxtWMSe0r4' },
35+
{ id: 'SM-881', title: 'Social Media Marketing: Zero to Hero', category: 'course', instructor: 'James Wilson', rating: 4.9, price: 24.99, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAUd6QuR7Rp0azAg6bhUKGrn8eqsVDX9Ry8ezok66U35Rnc39EGF8e_4RTwbxJKMv-73ahqf8f5ZB9b93NmWiNPSK8EnFcIr6DteWxx7yWzlyXRTn5Ti-5vmkE6VCeszMwgLEntCvZqhhTZrqr2Jn9RHsXJsFVCynv4RsUCpiG3B-L_NPzkyO3IFkYBfoMOmsHnLUsFI4SXKQGRME81AAq--3NMDs_chLm5cXIc-AdEeXiMFUIGBH9GllgnintdJWon-xXTl_1BWos' },
36+
{ id: 'CS-102', title: 'Prototyping High Fidelity Interfaces', category: 'course', instructor: 'Dr. Sarah Connor', rating: 4.7, price: 55.0, image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuCil99ZvcYdOTX2ikzfmezXpX8PM1gdqvlH9POOIWtf_oLnLUykcTbi_AmwTRyHd91SesbJSqYVeMRowIU8LwxAMfmlo6f_Pz1u8SrLX6MRh78Y4jM36RyPsfr3f6KzeGWxm4kZXTGblOlSET-GxfYpd_Nzdu1P0AVyTV6bpa_jwzSu1ZfKYb7SrSNphUJwOZowmvViRp-dqkbRQYrkWbuITbMKEf_FdUEefC39x97j6p9fuc1eF7A1Z3QFUZVecRUTOyGnzRBp9_Q' },
37+
{ id: 'FI-101', title: 'Investment Fundamentals for Creators', category: 'course', instructor: 'Dr. Sarah Connor', rating: 4.8, price: 59.99, image: 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?auto=format&fit=crop&w=1200&q=80' },
38+
{ id: 'FI-102', title: 'Strategic Investment Planning for Startups', category: 'course', instructor: 'James Wilson', rating: 4.7, price: 69.99, image: 'https://images.unsplash.com/photo-1542223616-0d4b4e2aa77d?auto=format&fit=crop&w=1200&q=80' },
39+
40+
// --- Instructors ---
41+
{ id: 'INST-001', title: 'Dr. Sarah Connor', category: 'instructor', image: 'https://ui-avatars.com/api/?name=Sarah+Connor', description: 'Expert in UI/UX Design and Web Development' },
42+
{ id: 'INST-002', title: 'James Wilson', category: 'instructor', image: 'https://ui-avatars.com/api/?name=James+Wilson', description: 'Marketing and Growth Strategy Specialist' },
43+
{ id: 'INST-003', title: 'Dr. Emily Chen', category: 'instructor', image: 'https://ui-avatars.com/api/?name=Emily+Chen', description: 'Data Science and Machine Learning Professor' },
44+
{ id: 'INST-004', title: 'Alex Rivera', category: 'instructor', image: 'https://ui-avatars.com/api/?name=Alex+Rivera', description: 'Full-Stack Developer and Cloud Architect' },
45+
46+
// --- Topics ---
47+
{ id: 'TOPIC-001', title: 'UI/UX Design', category: 'topic', description: 'Learn user interface and user experience design' },
48+
{ id: 'TOPIC-002', title: 'Digital Marketing', category: 'topic', description: 'Master digital marketing strategies' },
49+
{ id: 'TOPIC-003', title: 'Web Development', category: 'topic', description: 'Build modern web applications' },
50+
{ id: 'TOPIC-004', title: 'Data Analysis', category: 'topic', description: 'Analyze and visualize data effectively' },
51+
{ id: 'TOPIC-005', title: 'Machine Learning', category: 'topic', description: 'Explore AI and machine learning fundamentals' },
52+
{ id: 'TOPIC-006', title: 'Investment Planning', category: 'topic', description: 'Strategies for capital allocation and growth' },
53+
54+
// --- Investment Features ---
55+
{ id: 'INV-101', title: 'Investment Fundamentals for Creators', category: 'investment', description: 'Understand capital allocation, growth opportunities, and risk management.' },
56+
{ id: 'INV-102', title: 'Investment Planning for Startups', category: 'investment', description: 'Learn how to build investor-ready business plans and pitch decks.' },
57+
{ id: 'INV-103', title: 'Strategic Capital Deployment', category: 'investment', description: 'Align project goals with effective investment and sourcing strategies.' },
58+
{ id: 'INV-104', title: 'Angel Investing 101', category: 'investment', description: 'Navigate early-stage investing, valuation, and portfolio diversification.' },
59+
];
60+
61+
function normalize(text: string): string {
62+
return text.toLowerCase().trim().replace(/[^a-z0-9\s]/g, '');
63+
}
64+
65+
function matchScore(item: SearchResult, queryTerms: string[]): number {
66+
const titleNorm = normalize(item.title);
67+
const descNorm = item.description ? normalize(item.description) : '';
68+
const instructorNorm = item.instructor ? normalize(item.instructor) : '';
69+
70+
let score = 0;
71+
for (const term of queryTerms) {
72+
if (titleNorm.includes(term)) score += 3;
73+
if (instructorNorm.includes(term)) score += 2;
74+
if (descNorm.includes(term)) score += 1;
75+
}
76+
return score;
77+
}
78+
79+
export async function GET(request: Request): Promise<NextResponse> {
80+
edgeLog('info', '/api/search', 'GET request received');
81+
82+
const { addHeaders, rateLimitResponse } = withRateLimit(request, 'READ');
83+
if (rateLimitResponse) {
84+
return withSecurityHeaders(rateLimitResponse) as NextResponse;
85+
}
86+
87+
const { searchParams } = new URL(request.url);
88+
const q = searchParams.get('q')?.trim() ?? '';
89+
90+
if (!q) {
91+
const empty: CategorizedResults = {
92+
courses: [],
93+
instructors: [],
94+
topics: [],
95+
investments: [],
96+
};
97+
const response = withSecurityHeaders(
98+
addHeaders(NextResponse.json(empty)),
99+
);
100+
response.headers.set('Cache-Control', CDN_CACHE_HEADERS.private);
101+
return response;
102+
}
103+
104+
const queryTerms = normalize(q).split(/\s+/).filter(Boolean);
105+
106+
// Score and classify every index item
107+
const scored = SEARCH_INDEX
108+
.map((item) => ({ item, score: matchScore(item, queryTerms) }))
109+
.filter((entry) => entry.score > 0);
110+
111+
// Build categorized results, capped at 3 per category
112+
const categorized: CategorizedResults = {
113+
courses: scored
114+
.filter((e) => e.item.category === 'course')
115+
.sort((a, b) => b.score - a.score)
116+
.slice(0, 3)
117+
.map((e) => e.item),
118+
instructors: scored
119+
.filter((e) => e.item.category === 'instructor')
120+
.sort((a, b) => b.score - a.score)
121+
.slice(0, 3)
122+
.map((e) => e.item),
123+
topics: scored
124+
.filter((e) => e.item.category === 'topic')
125+
.sort((a, b) => b.score - a.score)
126+
.slice(0, 3)
127+
.map((e) => e.item),
128+
investments: scored
129+
.filter((e) => e.item.category === 'investment')
130+
.sort((a, b) => b.score - a.score)
131+
.slice(0, 3)
132+
.map((e) => e.item),
133+
};
134+
135+
const sanitized = sanitizeObject(categorized);
136+
const response = withSecurityHeaders(
137+
addHeaders(NextResponse.json(sanitized)),
138+
);
139+
response.headers.set('Cache-Control', CDN_CACHE_HEADERS.private);
140+
return response;
141+
}

src/app/hooks/__tests__/useSearch.test.tsx

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,128 @@
11
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
22
import { renderHook, act } from '@testing-library/react';
33
import { useSearch } from '../useSearch';
4+
import type { CategorizedResults } from '../useSearch';
45

56
vi.mock('next/navigation', () => ({
67
useRouter: () => ({
78
push: vi.fn(),
89
}),
910
}));
1011

12+
const MOCK_API_RESPONSE: CategorizedResults = {
13+
courses: [
14+
{
15+
id: 'FI-101',
16+
title: 'Investment Fundamentals for Creators',
17+
category: 'course',
18+
instructor: 'Dr. Sarah Connor',
19+
rating: 4.8,
20+
price: 59.99,
21+
image:
22+
'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?auto=format&fit=crop&w=1200&q=80',
23+
},
24+
],
25+
instructors: [],
26+
topics: [],
27+
investments: [
28+
{
29+
id: 'INV-101',
30+
title: 'Investment Fundamentals for Creators',
31+
category: 'investment',
32+
description: 'Understand capital allocation, growth opportunities, and risk management.',
33+
},
34+
{
35+
id: 'INV-102',
36+
title: 'Investment Planning for Startups',
37+
category: 'investment',
38+
description: 'Learn how to build investor-ready business plans and pitch decks.',
39+
},
40+
{
41+
id: 'INV-103',
42+
title: 'Strategic Capital Deployment',
43+
category: 'investment',
44+
description: 'Align project goals with effective investment and sourcing strategies.',
45+
},
46+
],
47+
};
48+
1149
describe('useSearch investment feature support', () => {
1250
beforeEach(() => {
1351
vi.useFakeTimers();
1452
localStorage.clear();
53+
54+
// Create fetch mock — assign directly (avoids spyOn issues in jsdom)
55+
globalThis.fetch = vi.fn().mockImplementation(async (url: string | URL | Request) => {
56+
const urlStr = typeof url === 'string' ? url : url.toString();
57+
const queryParam = new URL(urlStr, 'http://localhost').searchParams.get('q') || '';
58+
59+
if (queryParam.toLowerCase().includes('investment')) {
60+
return new Response(JSON.stringify(MOCK_API_RESPONSE), {
61+
status: 200,
62+
headers: { 'Content-Type': 'application/json' },
63+
});
64+
}
65+
66+
if (queryParam.toLowerCase().includes('creators')) {
67+
return new Response(
68+
JSON.stringify({
69+
courses: [],
70+
instructors: [],
71+
topics: [],
72+
investments: MOCK_API_RESPONSE.investments.filter((i) =>
73+
i.title.toLowerCase().includes('creators'),
74+
),
75+
}),
76+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
77+
);
78+
}
79+
80+
return new Response(
81+
JSON.stringify({ courses: [], instructors: [], topics: [], investments: [] }),
82+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
83+
);
84+
});
1585
});
1686

1787
afterEach(() => {
1888
vi.useRealTimers();
19-
vi.clearAllMocks();
89+
vi.restoreAllMocks();
2090
});
2191

22-
it('returns investment results when the query matches investment content', () => {
92+
it('returns investment results when the query matches investment content', async () => {
2393
const { result } = renderHook(() => useSearch());
2494

2595
act(() => {
2696
result.current.search('investment');
97+
});
98+
99+
// Advance past the debounce window
100+
await act(async () => {
27101
vi.advanceTimersByTime(300);
28102
});
29103

104+
// Drain the microtask queue from the async fetch handler
105+
await act(async () => {});
106+
30107
expect(result.current.results.investments).toHaveLength(3);
31108
expect(result.current.results.investments[0].title).toContain('Investment');
32109
expect(result.current.isLoading).toBe(false);
33110
});
34111

35-
it('returns only relevant investment search results for a specific investment query', () => {
112+
it('returns only relevant investment search results for a specific investment query', async () => {
36113
const { result } = renderHook(() => useSearch());
37114

38115
act(() => {
39116
result.current.search('creators');
117+
});
118+
119+
await act(async () => {
40120
vi.advanceTimersByTime(300);
41121
});
42122

123+
// Drain the microtask queue
124+
await act(async () => {});
125+
43126
expect(result.current.results.investments).toHaveLength(1);
44127
expect(result.current.results.investments[0].title).toContain('Creators');
45128
expect(result.current.results.courses).toEqual([]);

0 commit comments

Comments
 (0)