feat(search): add api-backed filtering and sorting

This commit is contained in:
zetaloop
2026-02-25 04:29:17 +08:00
parent a1f3ea3914
commit 14717f1340
7 changed files with 806 additions and 190 deletions
+217
View File
@@ -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)
})
})
})
+51
View File
@@ -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)
})
})