From 14717f1340d01c355722816f4c604767df5d6938 Mon Sep 17 00:00:00 2001 From: zetaloop Date: Wed, 25 Feb 2026 04:29:17 +0800 Subject: [PATCH] feat(search): add api-backed filtering and sorting --- app/(main)/search/page.tsx | 405 +++++++++++++++++++---------------- app/api/search/route.ts | 62 ++++++ lib/api/search.ts | 44 ++++ lib/search/search-catalog.ts | 189 ++++++++++++++++ lib/search/types.ts | 28 +++ tests/search-catalog.test.ts | 217 +++++++++++++++++++ tests/search-route.test.ts | 51 +++++ 7 files changed, 806 insertions(+), 190 deletions(-) create mode 100644 app/api/search/route.ts create mode 100644 lib/api/search.ts create mode 100644 lib/search/search-catalog.ts create mode 100644 lib/search/types.ts create mode 100644 tests/search-catalog.test.ts create mode 100644 tests/search-route.test.ts diff --git a/app/(main)/search/page.tsx b/app/(main)/search/page.tsx index ba4b704..0b8eedc 100644 --- a/app/(main)/search/page.tsx +++ b/app/(main)/search/page.tsx @@ -14,7 +14,7 @@ import { } from "lucide-react" import Link from "next/link" import { useRouter, useSearchParams } from "next/navigation" -import { Suspense, useEffect, useMemo, useState, useDeferredValue } from "react" +import { Suspense, useCallback, useEffect, useState } from "react" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -41,11 +41,34 @@ import { SheetTrigger, } from "@/components/ui/sheet" import { Switch } from "@/components/ui/switch" -import { listGames, listPlayers, listServices, listShops } from "@/lib/api" +import { listGames } from "@/lib/api" +import { searchCatalog } from "@/lib/api/search" import { GameIcon } from "@/lib/game-icons" -import type { Game, Player, Shop } from "@/lib/types" +import type { SearchResponse, SearchResultItem, SearchSort } from "@/lib/search/types" +import type { Game, Player } from "@/lib/types" import { cn } from "@/lib/utils" +const SEARCH_SORTS: ReadonlySet = new Set([ + "composite", + "rating", + "orders", + "price_asc", + "price_desc", +]) + +const DEFAULT_LIMIT = 12 + +function numberParam(value: string | null, fallback: number) { + if (value === null) return fallback + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +function resetPagination(params: URLSearchParams) { + params.set("limit", String(DEFAULT_LIMIT)) + params.set("offset", "0") +} + function StatusBadge({ status }: { status: Player["status"] }) { switch (status) { case "available": @@ -165,15 +188,9 @@ function PlayerCard({ player }: { player: Player }) { ) } -interface ShopResultItem { - shop: Shop - minPrice: number - unit: string - games: string[] - hasAvailable: boolean -} +type ShopSearchItem = Extract -function ShopCard({ item }: { item: ShopResultItem }) { +function ShopCard({ item }: { item: ShopSearchItem }) { return ( @@ -236,24 +253,6 @@ function ShopCard({ item }: { item: ShopResultItem }) { ) } -type SearchResult = - | { - type: "player" - id: string - rating: number - orders: number - minPrice: number - item: Player - } - | { - type: "shop" - id: string - rating: number - orders: number - minPrice: number - item: ShopResultItem - } - interface FilterProps { games: Game[] selectedGames: string[] @@ -375,166 +374,183 @@ function SearchPageContent() { const searchParams = useSearchParams() const router = useRouter() const games = listGames() - const players = listPlayers() - const services = listServices() - const shops = listShops() const [searchQuery, setSearchQuery] = useState(searchParams.get("q") || "") const [selectedGames, setSelectedGames] = useState(() => { - const game = searchParams.get("game") - return game ? [game] : [] + const selected = searchParams.getAll("game") + return [...new Set(selected)] }) const [priceRange, setPriceRange] = useState<{ min: string; max: string }>({ - min: "", - max: "", + min: searchParams.get("min") || "", + max: searchParams.get("max") || "", }) - const deferredPriceRange = useDeferredValue(priceRange) - const [onlyOnline, setOnlyOnline] = useState(false) - const [minRating, setMinRating] = useState("0") - const [sortBy, setSortBy] = useState("composite") - const [visibleCount, setVisibleCount] = useState(12) + const [onlyOnline, setOnlyOnline] = useState((searchParams.get("online") ?? "0") === "1") + const [minRating, setMinRating] = useState(searchParams.get("minRating") ?? "0") + const [sortBy, setSortBy] = useState(() => { + const sortParam = (searchParams.get("sort") ?? "composite") as SearchSort + return SEARCH_SORTS.has(sortParam) ? sortParam : "composite" + }) + + const [response, setResponse] = useState(null) + const [hasLoaded, setHasLoaded] = useState(false) + const [error, setError] = useState(null) + + const replaceUrl = useCallback( + (updater: (params: URLSearchParams) => void) => { + const params = new URLSearchParams(searchParams.toString()) + updater(params) + router.replace(`/search?${params.toString()}`, { scroll: false }) + }, + [router, searchParams], + ) + useEffect(() => { const timer = setTimeout(() => { - const params = new URLSearchParams(searchParams.toString()) - if (searchQuery) { - params.set("q", searchQuery) - } else { - params.delete("q") - } - router.replace(`/search?${params.toString()}`, { scroll: false }) + const urlQ = searchParams.get("q") ?? "" + if (searchQuery === urlQ) return + + replaceUrl((params) => { + if (searchQuery) params.set("q", searchQuery) + else params.delete("q") + resetPagination(params) + }) }, 500) + return () => clearTimeout(timer) - }, [searchQuery, router, searchParams]) + }, [replaceUrl, searchParams, searchQuery]) + + useEffect(() => { + const controller = new AbortController() + + const q = searchParams.get("q") || undefined + const selected = searchParams.getAll("game") + const min = searchParams.get("min") || undefined + const max = searchParams.get("max") || undefined + const onlyOnlineParam = (searchParams.get("online") ?? "0") === "1" + const minRatingParam = searchParams.get("minRating") ?? "0" + + const sortParam = (searchParams.get("sort") ?? "composite") as SearchSort + const sort: SearchSort = SEARCH_SORTS.has(sortParam) ? sortParam : "composite" + + const limit = numberParam(searchParams.get("limit"), 12) + const offset = numberParam(searchParams.get("offset"), 0) + + searchCatalog({ + q, + selectedGames: selected, + min, + max, + onlyOnline: onlyOnlineParam, + minRating: minRatingParam, + sort, + limit, + offset, + signal: controller.signal, + }) + .then((res) => { + if (controller.signal.aborted) return + setResponse(res) + setHasLoaded(true) + setError(null) + }) + .catch((err: unknown) => { + if (controller.signal.aborted) return + setError(err instanceof Error ? err.message : "Search request failed") + setHasLoaded(true) + }) + + return () => controller.abort() + }, [searchParams]) const handleGameChange = (game: string, checked: boolean) => { - setSelectedGames((prev) => (checked ? [...prev, game] : prev.filter((g) => g !== game))) + setSelectedGames((prev) => { + const next = checked ? [...new Set([...prev, game])] : prev.filter((g) => g !== game) + replaceUrl((params) => { + params.delete("game") + for (const g of next) params.append("game", g) + resetPagination(params) + }) + return next + }) } const handlePriceChange = (type: "min" | "max", value: string) => { - setPriceRange((prev) => ({ ...prev, [type]: value })) + setPriceRange((prev) => { + const next = { ...prev, [type]: value } + replaceUrl((params) => { + if (next.min) params.set("min", next.min) + else params.delete("min") + if (next.max) params.set("max", next.max) + else params.delete("max") + resetPagination(params) + }) + return next + }) } - const shopResultItems = useMemo(() => { - return shops.map((shop) => { - const shopPlayers = players.filter((player) => player.shopId === shop.id) - const playerIds = new Set(shopPlayers.map((player) => player.id)) - const shopServices = services.filter((service) => playerIds.has(service.playerId)) - const minPrice = - shopServices.length > 0 ? Math.min(...shopServices.map((service) => service.price)) : 0 - const unit = - shopServices.length > 0 - ? shopServices.reduce((prev, curr) => (prev.price < curr.price ? prev : curr)).unit - : "局" - const shopGames = [...new Set(shopServices.map((service) => service.gameName))] - const hasAvailable = shopPlayers.some((player) => player.status === "available") - return { - shop, - minPrice, - unit, - games: shopGames, - hasAvailable, - } + const handleOnlineChange = (checked: boolean) => { + setOnlyOnline(checked) + replaceUrl((params) => { + if (checked) params.set("online", "1") + else params.delete("online") + resetPagination(params) }) - }, [players, services, shops]) + } - const filteredPlayers = useMemo(() => { - return players.filter((player) => { - if (searchQuery) { - const query = searchQuery.toLowerCase() - const matchName = player.user.nickname.toLowerCase().includes(query) - const matchTags = player.tags.some((tag) => tag.toLowerCase().includes(query)) - const matchGames = player.games.some((game) => game.toLowerCase().includes(query)) - if (!matchName && !matchTags && !matchGames) return false - } - - if (selectedGames.length > 0) { - const hasGame = player.games.some((game) => selectedGames.includes(game)) - if (!hasGame) return false - } - - const minP = deferredPriceRange.min ? Number(deferredPriceRange.min) : 0 - const maxP = deferredPriceRange.max ? Number(deferredPriceRange.max) : Infinity - const playerMinPrice = Math.min(...player.services.map((s) => s.price)) - - if (playerMinPrice < minP) return false - if (deferredPriceRange.max && playerMinPrice > maxP) return false - - if (onlyOnline && player.status !== "available") return false - - if (player.rating < Number(minRating)) return false - - return true + const handleRatingChange = (value: string) => { + setMinRating(value) + replaceUrl((params) => { + if (value && value !== "0") params.set("minRating", value) + else params.delete("minRating") + resetPagination(params) }) - }, [minRating, onlyOnline, players, deferredPriceRange, searchQuery, selectedGames]) + } - const filteredShops = useMemo(() => { - return shopResultItems.filter((item) => { - if (searchQuery) { - const query = searchQuery.toLowerCase() - const matchName = item.shop.name.toLowerCase().includes(query) - const matchDescription = item.shop.description.toLowerCase().includes(query) - const matchGames = item.games.some((game) => game.toLowerCase().includes(query)) - if (!matchName && !matchDescription && !matchGames) return false - } - - if (selectedGames.length > 0) { - const hasGame = item.games.some((game) => selectedGames.includes(game)) - if (!hasGame) return false - } - - const minP = deferredPriceRange.min ? Number(deferredPriceRange.min) : 0 - const maxP = deferredPriceRange.max ? Number(deferredPriceRange.max) : Infinity - if (item.minPrice < minP) return false - if (deferredPriceRange.max && item.minPrice > maxP) return false - - if (onlyOnline && !item.hasAvailable) return false - if (item.shop.rating < Number(minRating)) return false - - return true + const setSortAndSync = (next: SearchSort) => { + setSortBy(next) + replaceUrl((params) => { + if (next === "composite") params.delete("sort") + else params.set("sort", next) + resetPagination(params) }) - }, [shopResultItems, searchQuery, selectedGames, deferredPriceRange, onlyOnline, minRating]) + } - const sortedResults = useMemo(() => { - const playerResults: SearchResult[] = filteredPlayers.map((player) => ({ - type: "player", - id: player.id, - rating: player.rating, - orders: player.totalOrders, - minPrice: Math.min(...player.services.map((service) => service.price)), - item: player, - })) - const shopResults: SearchResult[] = filteredShops.map((item) => ({ - type: "shop", - id: item.shop.id, - rating: item.shop.rating, - orders: item.shop.totalOrders, - minPrice: item.minPrice, - item, - })) - - return [...playerResults, ...shopResults].sort((a, b) => { - switch (sortBy) { - case "rating": - return b.rating - a.rating - case "price_asc": - return a.minPrice - b.minPrice - case "price_desc": - return b.minPrice - a.minPrice - case "orders": - return b.orders - a.orders - case "composite": { - const scoreA = a.rating * Math.log10(a.orders + 1) - const scoreB = b.rating * Math.log10(b.orders + 1) - return scoreB - scoreA - } - default: - return 0 - } + const resetFilters = () => { + setSelectedGames([]) + setPriceRange({ min: "", max: "" }) + setOnlyOnline(false) + setMinRating("0") + replaceUrl((params) => { + params.delete("game") + params.delete("min") + params.delete("max") + params.delete("online") + params.delete("minRating") + resetPagination(params) }) - }, [filteredPlayers, filteredShops, sortBy]) + } - const displayedResults = sortedResults.slice(0, visibleCount) + const clearAllFilters = () => { + setSearchQuery("") + setSelectedGames([]) + setPriceRange({ min: "", max: "" }) + setOnlyOnline(false) + setMinRating("0") + replaceUrl((params) => { + params.delete("q") + params.delete("game") + params.delete("min") + params.delete("max") + params.delete("online") + params.delete("minRating") + resetPagination(params) + }) + } + + const items = response?.items ?? [] + const total = response?.meta.total ?? 0 + const canLoadMore = response ? response.items.length < response.meta.total : false return (
@@ -575,14 +591,14 @@ function SearchPageContent() { priceRange={priceRange} onPriceChange={handlePriceChange} onlyOnline={onlyOnline} - onOnlineChange={setOnlyOnline} + onOnlineChange={handleOnlineChange} minRating={minRating} - onRatingChange={setMinRating} + onRatingChange={handleRatingChange} />
- + @@ -606,10 +622,7 @@ function SearchPageContent() { size="sm" className="h-auto p-0 text-xs text-muted-foreground hover:text-primary" onClick={() => { - setSelectedGames([]) - setPriceRange({ min: "", max: "" }) - setOnlyOnline(false) - setMinRating("0") + resetFilters() }} > 重置 @@ -625,9 +638,9 @@ function SearchPageContent() { priceRange={priceRange} onPriceChange={handlePriceChange} onlyOnline={onlyOnline} - onOnlineChange={setOnlyOnline} + onOnlineChange={handleOnlineChange} minRating={minRating} - onRatingChange={setMinRating} + onRatingChange={handleRatingChange} />
@@ -639,7 +652,7 @@ function SearchPageContent() {
- 共找到 {sortedResults.length} 位陪玩 + 共找到 {total} 位陪玩
- {displayedResults.length > 0 ? ( + {!hasLoaded ? ( +
+
+
+ ) : items.length > 0 ? (
- {displayedResults.map((result) => ( -
+ {items.map((result) => ( +
{result.type === "player" ? ( - + ) : ( - + )}
))} @@ -694,17 +717,13 @@ function SearchPageContent() {

未找到相关陪玩

- 尝试调整筛选条件或更换搜索关键词 + {error ? "请求失败,请稍后重试" : "尝试调整筛选条件或更换搜索关键词"}

)} - {sortedResults.length > visibleCount && ( + {canLoadMore && (
diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 0000000..3b33dcb --- /dev/null +++ b/app/api/search/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server" +import { mockPlayers, mockServices, mockShops } from "@/lib/mock" +import { searchCatalog } from "@/lib/search/search-catalog" +import type { SearchSort } from "@/lib/search/types" + +export const dynamic = "force-dynamic" + +const SEARCH_SORTS: ReadonlySet = new Set([ + "composite", + "rating", + "orders", + "price_asc", + "price_desc", +]) + +function numberParam(value: string | null, fallback: number) { + if (value === null) return fallback + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + + const q = searchParams.get("q") ?? undefined + const selectedGames = searchParams.getAll("game") + const min = searchParams.get("min") ?? "" + const max = searchParams.get("max") ?? "" + const onlyOnline = (searchParams.get("online") ?? "0") === "1" + const minRating = searchParams.get("minRating") ?? "0" + + const sortParam = (searchParams.get("sort") ?? "composite") as SearchSort + const sort: SearchSort = SEARCH_SORTS.has(sortParam) ? sortParam : "composite" + + const limit = numberParam(searchParams.get("limit"), 12) + const offset = numberParam(searchParams.get("offset"), 0) + + const response = searchCatalog( + { + q, + selectedGames, + min, + max, + onlyOnline, + minRating, + sort, + limit, + offset, + }, + { + players: mockPlayers, + shops: mockShops, + services: mockServices, + }, + ) + + return NextResponse.json(response, { + headers: { + "Cache-Control": "no-store", + }, + }) +} diff --git a/lib/api/search.ts b/lib/api/search.ts new file mode 100644 index 0000000..4c9b4d9 --- /dev/null +++ b/lib/api/search.ts @@ -0,0 +1,44 @@ +import type { SearchResponse, SearchSort } from "@/lib/search/types" + +export interface SearchCatalogParams { + q?: string + selectedGames?: string[] + min?: string + max?: string + onlyOnline?: boolean + minRating?: string + sort?: SearchSort + limit?: number + offset?: number + signal?: AbortSignal +} + +export async function searchCatalog(params: SearchCatalogParams): Promise { + const searchParams = new URLSearchParams() + + if (params.q) searchParams.set("q", params.q) + + for (const game of params.selectedGames ?? []) { + searchParams.append("game", game) + } + + if (params.min) searchParams.set("min", params.min) + if (params.max) searchParams.set("max", params.max) + if (params.onlyOnline) searchParams.set("online", "1") + if (params.minRating && params.minRating !== "0") searchParams.set("minRating", params.minRating) + if (params.sort && params.sort !== "composite") searchParams.set("sort", params.sort) + + if (params.limit !== undefined) searchParams.set("limit", String(params.limit)) + if (params.offset !== undefined) searchParams.set("offset", String(params.offset)) + + const res = await fetch(`/api/search?${searchParams.toString()}`, { + cache: "no-store", + signal: params.signal, + }) + + if (!res.ok) { + throw new Error(`Search API request failed: ${res.status} ${res.statusText}`) + } + + return (await res.json()) as SearchResponse +} diff --git a/lib/search/search-catalog.ts b/lib/search/search-catalog.ts new file mode 100644 index 0000000..ebb5f1c --- /dev/null +++ b/lib/search/search-catalog.ts @@ -0,0 +1,189 @@ +import type { Player, PlayerService, Shop } from "@/lib/types" +import type { SearchResponse, SearchResultItem, SearchSort } from "@/lib/search/types" + +export interface SearchCatalogParams { + q?: string + selectedGames?: string[] + min?: string + max?: string + onlyOnline?: boolean + minRating?: string + sort?: SearchSort + limit?: number + offset?: number +} + +export interface SearchCatalogData { + players: Player[] + shops: Shop[] + services: PlayerService[] +} + +type InternalResultItem = SearchResultItem & { __index: number } + +function minPriceFromServices(services: PlayerService[]) { + return services.length === 0 ? 0 : Math.min(...services.map((s) => s.price)) +} + +function unitFromCheapestService(services: PlayerService[]): PlayerService["unit"] { + if (services.length === 0) return "局" + return services.reduce((prev, curr) => (prev.price < curr.price ? prev : curr)).unit +} + +export function searchCatalog( + params: SearchCatalogParams, + data: SearchCatalogData, +): SearchResponse { + const q = params.q ?? "" + const query = q ? q.toLowerCase() : "" + const selectedGames = params.selectedGames ?? [] + + const min = params.min ?? "" + const max = params.max ?? "" + const minP = min ? Number(min) : 0 + const maxP = max ? Number(max) : Infinity + + const onlyOnline = params.onlyOnline ?? false + const minRating = Number(params.minRating ?? "0") + const sort = params.sort ?? "composite" + + const offset = Math.max(0, params.offset ?? 0) + const limit = Math.max(0, params.limit ?? 12) + + const shopDerivedById = new Map< + string, + { + minPrice: number + unit: PlayerService["unit"] + games: string[] + hasAvailable: boolean + } + >() + + for (const shop of data.shops) { + const shopPlayers = data.players.filter((player) => player.shopId === shop.id) + const playerIds = new Set(shopPlayers.map((player) => player.id)) + const shopServices = data.services.filter((service) => playerIds.has(service.playerId)) + const shopMinPrice = shopServices.length > 0 ? Math.min(...shopServices.map((s) => s.price)) : 0 + const shopUnit = shopServices.length > 0 ? unitFromCheapestService(shopServices) : "局" + const shopGames = [...new Set(shopServices.map((service) => service.gameName))] + const hasAvailable = shopPlayers.some((player) => player.status === "available") + + shopDerivedById.set(shop.id, { + minPrice: shopMinPrice, + unit: shopUnit, + games: shopGames, + hasAvailable, + }) + } + + let nextIndex = 0 + const results: InternalResultItem[] = [] + + for (const player of data.players) { + results.push({ + __index: nextIndex++, + type: "player", + player, + rating: player.rating, + orders: player.totalOrders, + minPrice: minPriceFromServices(player.services), + unit: unitFromCheapestService(player.services), + }) + } + + for (const shop of data.shops) { + const derived = shopDerivedById.get(shop.id) + if (!derived) continue + + results.push({ + __index: nextIndex++, + type: "shop", + shop, + rating: shop.rating, + orders: shop.totalOrders, + minPrice: derived.minPrice, + unit: derived.unit, + games: derived.games, + hasAvailable: derived.hasAvailable, + }) + } + + const filtered = results.filter((item) => { + if (query) { + if (item.type === "player") { + const matchName = item.player.user.nickname.toLowerCase().includes(query) + const matchTags = item.player.tags.some((tag) => tag.toLowerCase().includes(query)) + const matchGames = item.player.games.some((game) => game.toLowerCase().includes(query)) + if (!matchName && !matchTags && !matchGames) return false + } else { + const matchName = item.shop.name.toLowerCase().includes(query) + const matchDescription = item.shop.description.toLowerCase().includes(query) + const matchGames = item.games.some((game) => game.toLowerCase().includes(query)) + if (!matchName && !matchDescription && !matchGames) return false + } + } + + if (selectedGames.length > 0) { + if (item.type === "player") { + const hasGame = item.player.games.some((game) => selectedGames.includes(game)) + if (!hasGame) return false + } else { + const hasGame = item.games.some((game) => selectedGames.includes(game)) + if (!hasGame) return false + } + } + + if (item.minPrice < minP) return false + if (max && item.minPrice > maxP) return false + + if (onlyOnline) { + if (item.type === "player") { + if (item.player.status !== "available") return false + } else { + if (!item.hasAvailable) return false + } + } + + if (item.type === "player") { + if (item.player.rating < minRating) return false + } else { + if (item.shop.rating < minRating) return false + } + + return true + }) + + filtered.sort((a, b) => { + let diff = 0 + switch (sort) { + case "rating": + diff = b.rating - a.rating + break + case "price_asc": + diff = a.minPrice - b.minPrice + break + case "price_desc": + diff = b.minPrice - a.minPrice + break + case "orders": + diff = b.orders - a.orders + break + case "composite": { + const scoreA = a.rating * Math.log10(a.orders + 1) + const scoreB = b.rating * Math.log10(b.orders + 1) + diff = scoreB - scoreA + break + } + default: + diff = 0 + } + + if (diff !== 0) return diff + return a.__index - b.__index + }) + + const total = filtered.length + const items = filtered.slice(offset, offset + limit).map(({ __index, ...item }) => item) + return { items, meta: { total, offset, limit } } +} diff --git a/lib/search/types.ts b/lib/search/types.ts new file mode 100644 index 0000000..4226ed0 --- /dev/null +++ b/lib/search/types.ts @@ -0,0 +1,28 @@ +import type { Player, PlayerService, Shop } from "@/lib/types" + +export type SearchSort = "composite" | "rating" | "orders" | "price_asc" | "price_desc" + +export type SearchResultItem = + | { + type: "player" + player: Player + minPrice: number + unit: PlayerService["unit"] + rating: number + orders: number + } + | { + type: "shop" + shop: Shop + minPrice: number + unit: PlayerService["unit"] + rating: number + orders: number + games: string[] + hasAvailable: boolean + } + +export interface SearchResponse { + items: SearchResultItem[] + meta: { total: number; offset: number; limit: number } +} diff --git a/tests/search-catalog.test.ts b/tests/search-catalog.test.ts new file mode 100644 index 0000000..7f6f00f --- /dev/null +++ b/tests/search-catalog.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest" +import { searchCatalog } from "@/lib/search/search-catalog" +import { mockPlayers } from "@/lib/mock/players" +import { mockShops } from "@/lib/mock/shops" +import { mockServices } from "@/lib/mock/services" +import type { SearchCatalogData } from "@/lib/search/search-catalog" + +const data: SearchCatalogData = { + players: mockPlayers, + shops: mockShops, + services: mockServices, +} + +describe("searchCatalog", () => { + describe("q matching (case-insensitive)", () => { + it("matches player nickname", () => { + const res = searchCatalog({ q: "winter", limit: 50 }, data) + const playerItems = res.items.filter((i) => i.type === "player") + expect(playerItems.some((i) => i.type === "player" && i.player.id === "u6")).toBe(true) + }) + + it("matches player nickname case-insensitively", () => { + const res = searchCatalog({ q: "WINTER", limit: 50 }, data) + const playerItems = res.items.filter((i) => i.type === "player") + expect(playerItems.some((i) => i.type === "player" && i.player.id === "u6")).toBe(true) + }) + + it("matches shop name", () => { + const res = searchCatalog({ q: "yuki", limit: 50 }, data) + const shopItems = res.items.filter((i) => i.type === "shop") + expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "shop2")).toBe(true) + }) + + it("matches player game name", () => { + const res = searchCatalog({ q: "cs2", limit: 50 }, data) + const playerItems = res.items.filter((i) => i.type === "player") + // u8 has CS2 in games + expect(playerItems.some((i) => i.type === "player" && i.player.id === "u8")).toBe(true) + }) + + it("matches shop derived games", () => { + const res = searchCatalog({ q: "cs2", limit: 50 }, data) + const shopItems = res.items.filter((i) => i.type === "shop") + // shop1 has CS2 via u5's service s3 + expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "shop1")).toBe(true) + }) + + it("returns all items when q is empty", () => { + const res = searchCatalog({ limit: 50 }, data) + expect(res.meta.total).toBe(mockPlayers.length + mockShops.length) + }) + }) + + describe("selectedGames ANY-match", () => { + it("filters players by game", () => { + const res = searchCatalog({ selectedGames: ["CS2"], limit: 50 }, data) + const playerItems = res.items.filter((i) => i.type === "player") + expect(playerItems.every((i) => i.type === "player" && i.player.games.includes("CS2"))).toBe( + true, + ) + expect(playerItems.length).toBeGreaterThan(0) + }) + + it("filters shops by derived games from services", () => { + const res = searchCatalog({ selectedGames: ["CS2"], limit: 50 }, data) + const shopItems = res.items.filter((i) => i.type === "shop") + // shop1 has CS2 via services + expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "shop1")).toBe(true) + }) + + it("uses ANY-match (OR) for multiple games", () => { + const res = searchCatalog({ selectedGames: ["CS2", "王者荣耀"], limit: 50 }, data) + // Should include players with either game + expect(res.meta.total).toBeGreaterThan(0) + const playerItems = res.items.filter((i) => i.type === "player") + for (const item of playerItems) { + if (item.type === "player") { + expect(item.player.games.includes("CS2") || item.player.games.includes("王者荣耀")).toBe( + true, + ) + } + } + }) + }) + + describe("onlyOnline semantics", () => { + it("filters players by status === available", () => { + const res = searchCatalog({ onlyOnline: true, limit: 50 }, data) + const playerItems = res.items.filter((i) => i.type === "player") + for (const item of playerItems) { + if (item.type === "player") { + expect(item.player.status).toBe("available") + } + } + // u7 is busy, u9 is offline — they should be excluded + expect(playerItems.some((i) => i.type === "player" && i.player.id === "u7")).toBe(false) + expect(playerItems.some((i) => i.type === "player" && i.player.id === "u9")).toBe(false) + }) + + it("filters shops by hasAvailable (any player available)", () => { + const res = searchCatalog({ onlyOnline: true, limit: 50 }, data) + const shopItems = res.items.filter((i) => i.type === "shop") + for (const item of shopItems) { + if (item.type === "shop") { + expect(item.hasAvailable).toBe(true) + } + } + // shop3 has only u9 (offline) → excluded + expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "shop3")).toBe(false) + // shop1 has u5 (available) → included + expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "shop1")).toBe(true) + }) + }) + + describe("price min/max parsing", () => { + it("filters by min price", () => { + const res = searchCatalog({ min: "25", limit: 50 }, data) + for (const item of res.items) { + expect(item.minPrice).toBeGreaterThanOrEqual(25) + } + }) + + it("filters by max price", () => { + const res = searchCatalog({ max: "20", limit: 50 }, data) + for (const item of res.items) { + expect(item.minPrice).toBeLessThanOrEqual(20) + } + }) + + it("NaN min does not filter (min='abc' → minP=NaN)", () => { + const resAll = searchCatalog({ limit: 50 }, data) + const resNaN = searchCatalog({ min: "abc", limit: 50 }, data) + // NaN comparison: item.minPrice < NaN is always false, so nothing is excluded by min + // But items with minPrice < 0 would pass too — effectively no min filter + expect(resNaN.meta.total).toBe(resAll.meta.total) + }) + + it("NaN max does not filter (max='abc' → maxP=NaN)", () => { + const resAll = searchCatalog({ limit: 50 }, data) + const resNaN = searchCatalog({ max: "abc", limit: 50 }, data) + // max is truthy ("abc") so the max filter runs, but item.minPrice > NaN is always false + expect(resNaN.meta.total).toBe(resAll.meta.total) + }) + }) + + describe("sort options", () => { + it("sorts by rating descending", () => { + const res = searchCatalog({ sort: "rating", limit: 50 }, data) + for (let i = 1; i < res.items.length; i++) { + expect(res.items[i - 1].rating).toBeGreaterThanOrEqual(res.items[i].rating) + } + }) + + it("sorts by orders descending", () => { + const res = searchCatalog({ sort: "orders", limit: 50 }, data) + for (let i = 1; i < res.items.length; i++) { + expect(res.items[i - 1].orders).toBeGreaterThanOrEqual(res.items[i].orders) + } + }) + + it("sorts by price ascending", () => { + const res = searchCatalog({ sort: "price_asc", limit: 50 }, data) + for (let i = 1; i < res.items.length; i++) { + expect(res.items[i - 1].minPrice).toBeLessThanOrEqual(res.items[i].minPrice) + } + }) + + it("sorts by price descending", () => { + const res = searchCatalog({ sort: "price_desc", limit: 50 }, data) + for (let i = 1; i < res.items.length; i++) { + expect(res.items[i - 1].minPrice).toBeGreaterThanOrEqual(res.items[i].minPrice) + } + }) + + it("sorts by composite formula descending", () => { + const res = searchCatalog({ sort: "composite", limit: 50 }, data) + const scores = res.items.map((i) => i.rating * Math.log10(i.orders + 1)) + for (let i = 1; i < scores.length; i++) { + expect(scores[i - 1]).toBeGreaterThanOrEqual(scores[i]) + } + }) + + it("uses stable tie-breaker by insertion order", () => { + // Create data with identical ratings to test tie-breaking + const res = searchCatalog({ sort: "rating", limit: 50 }, data) + // Items with same rating should preserve insertion order (players before shops) + const sameRating = res.items.filter((i) => i.rating === res.items[0].rating) + if (sameRating.length > 1) { + // They should be in original insertion order + expect(sameRating.length).toBeGreaterThan(0) + } + }) + }) + + describe("pagination offset/limit", () => { + it("returns correct meta", () => { + const res = searchCatalog({ limit: 2, offset: 0 }, data) + expect(res.meta.limit).toBe(2) + expect(res.meta.offset).toBe(0) + expect(res.items.length).toBe(2) + expect(res.meta.total).toBe(mockPlayers.length + mockShops.length) + }) + + it("offset skips items", () => { + const all = searchCatalog({ limit: 50 }, data) + const page2 = searchCatalog({ limit: 2, offset: 2 }, data) + expect(page2.items[0]).toEqual(all.items[2]) + expect(page2.items[1]).toEqual(all.items[3]) + }) + + it("offset beyond total returns empty items", () => { + const res = searchCatalog({ limit: 10, offset: 100 }, data) + expect(res.items.length).toBe(0) + expect(res.meta.total).toBeGreaterThan(0) + }) + }) +}) diff --git a/tests/search-route.test.ts b/tests/search-route.test.ts new file mode 100644 index 0000000..ddb70be --- /dev/null +++ b/tests/search-route.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest" +import { GET } from "@/app/api/search/route" + +describe("GET /api/search", () => { + it("returns 200 with items and meta", async () => { + const request = new Request("http://x/api/search") + const response = await GET(request) + + expect(response.status).toBe(200) + const json = await response.json() + expect(Array.isArray(json.items)).toBe(true) + expect(json.meta).toHaveProperty("total") + expect(json.meta).toHaveProperty("offset") + expect(json.meta).toHaveProperty("limit") + }) + + it("sets Cache-Control to no-store", async () => { + const request = new Request("http://x/api/search") + const response = await GET(request) + + expect(response.headers.get("Cache-Control")).toBe("no-store") + }) + + it("filters by q param", async () => { + const request = new Request("http://x/api/search?q=winter") + const response = await GET(request) + const json = await response.json() + + expect(response.status).toBe(200) + expect(json.items.length).toBeGreaterThan(0) + }) + + it("respects limit and offset", async () => { + const request = new Request("http://x/api/search?limit=2&offset=1") + const response = await GET(request) + const json = await response.json() + + expect(json.items.length).toBeLessThanOrEqual(2) + expect(json.meta.limit).toBe(2) + expect(json.meta.offset).toBe(1) + }) + + it("handles game filter", async () => { + const request = new Request("http://x/api/search?game=CS2") + const response = await GET(request) + const json = await response.json() + + expect(response.status).toBe(200) + expect(json.items.length).toBeGreaterThan(0) + }) +})