diff --git a/src/index.ts b/src/index.ts index b8a32d3..59deedb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -170,6 +170,7 @@ export function createApp(_options: CreateAppOptions = {}): express.Express { app.use("/api/leaderboard", leaderboardRouter); app.use("/api/leaderboard/global", globalLeaderboardRouter); app.use("/api/rate-limit", rateLimitRouter); + app.use("/api/search", searchRouter); app.use("/api/quota/requests", quotaRequestsRouter); app.use("/api/notifications", notificationsRouter); app.use("/api/webhooks", webhooksRouter); diff --git a/src/routes/search.ts b/src/routes/search.ts index 0e151b3..40c14b9 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -2,6 +2,7 @@ import { Router, type Request, type Response, type NextFunction } from "express" import { z } from "zod"; import { logger } from "../config/logger"; import { getRequestId } from "../lib/requestContext"; +import { idempotency } from "../middleware/idempotency"; export const searchRouter = Router(); @@ -42,7 +43,7 @@ const searchSchema = z.object({ .trim() .min(1, "Search query must not be empty") .max(200, "Search query is too long") - .refine((s) => !/[\x00-\x1F]/.test(s), "Control characters are not allowed in query"), + .refine((s) => !s || s.split("").every((c) => c >= " "), "Control characters are not allowed in query"), limit: z.coerce .number() .int() @@ -101,3 +102,172 @@ searchRouter.get("/", async (req: Request, res: Response, next: NextFunction): P inFlightSearchRequests--; } }); + +// ── Mutation endpoints with idempotency-key support ────────────────────── + +const saveSearchBodySchema = z.object({ + query: z + .string() + .trim() + .min(1, "Search query must not be empty") + .max(200, "Search query is too long") + .refine((s) => !s || s.split("").every((c) => c >= " "), "Control characters are not allowed in query"), + label: z + .string() + .trim() + .max(100, "Label is too long") + .optional(), +}).strict(); + +const updateSearchBodySchema = z.object({ + label: z + .string() + .trim() + .min(1, "Label must not be empty when provided") + .max(100, "Label is too long"), +}).strict(); + +/** + * POST /api/search + * + * Save a search query to the user's search history. Idempotency-key header + * enables safe retries — the same request with the same key returns the + * same saved search record. + * + * Request body (JSON): + * ```json + * { + * "query": "prediction markets", + * "label": "My Search" // optional display label + * } + * ``` + * + * Response (201): + * ```json + * { + * "data": { + * "id": "search-uuid", + * "query": "prediction markets", + * "label": "My Search", + * "createdAt": "2026-07-28T12:00:00.000Z" + * } + * } + * ``` + */ +searchRouter.post("/", idempotency, async (req: Request, res: Response, next: NextFunction): Promise => { + inFlightSearchRequests++; + try { + const parseResult = saveSearchBodySchema.safeParse(req.body); + const reqId = getRequestId() ?? (req as { id?: string }).id ?? "unknown"; + + if (!parseResult.success) { + res.status(400).json({ + error: { + code: "validation_error", + message: parseResult.error.issues[0]?.message ?? "Invalid request body", + requestId: reqId, + }, + }); + return; + } + + const { query, label } = parseResult.data; + + logger.info( + { query, label, correlationId: reqId }, + "Saving search query", + ); + + // Mock save operation — in production this would persist to the database + const savedSearch = { + id: `search-${Date.now()}`, + query, + label: label ?? null, + createdAt: new Date().toISOString(), + }; + + res.status(201).json({ + data: savedSearch, + }); + } catch (e) { + next(e); + } finally { + inFlightSearchRequests--; + } +}); + +/** + * PATCH /api/search/:id + * + * Update an existing saved search (e.g., rename the label). Idempotency-key + * header enables safe retries. + * + * Request body (JSON): + * ```json + * { + * "label": "Updated Label" + * } + * ``` + * + * Response (200): + * ```json + * { + * "data": { + * "id": "search-uuid", + * "query": "prediction markets", + * "label": "Updated Label", + * "updatedAt": "2026-07-28T12:00:00.000Z" + * } + * } + * ``` + */ +searchRouter.patch("/:id", idempotency, async (req: Request, res: Response, next: NextFunction): Promise => { + inFlightSearchRequests++; + try { + const parseResult = updateSearchBodySchema.safeParse(req.body); + const reqId = getRequestId() ?? (req as { id?: string }).id ?? "unknown"; + const { id } = req.params; + + if (!parseResult.success) { + res.status(400).json({ + error: { + code: "validation_error", + message: parseResult.error.issues[0]?.message ?? "Invalid request body", + requestId: reqId, + }, + }); + return; + } + + if (!id || typeof id !== "string" || id.length > 128) { + res.status(400).json({ + error: { + code: "validation_error", + message: "Invalid search ID", + requestId: reqId, + }, + }); + return; + } + + const { label } = parseResult.data; + + logger.info( + { searchId: id, label, correlationId: reqId }, + "Updating saved search", + ); + + // Mock update operation — in production this would update the database + res.json({ + data: { + id, + label, + updatedAt: new Date().toISOString(), + }, + }); + } catch (e) { + next(e); + } finally { + inFlightSearchRequests--; + } +}); diff --git a/tests/search.test.ts b/tests/search.test.ts new file mode 100644 index 0000000..c669186 --- /dev/null +++ b/tests/search.test.ts @@ -0,0 +1,225 @@ +/** + * tests/search.test.ts + * + * Tests for /api/search routes including: + * - GET /api/search — read-only search (existing) + * - POST /api/search — save a search query (mutation, idempotent) + * - PATCH /api/search/:id — update a saved search (mutation, idempotent) + */ + +import express from "express"; +import request from "supertest"; +import { searchRouter } from "../src/routes/search"; +import { requestContextStorage } from "../src/lib/requestContext"; + +// Mock the idempotency middleware — the real one requires a database connection. +// We provide a simplified version that validates the key format and passes through. +jest.mock("../src/middleware/idempotency", () => ({ + idempotency: (req: express.Request, res: express.Response, next: express.NextFunction): void => { + const key = req.headers["idempotency-key"]; + if (key && typeof key === "string" && (key.length > 255 || !/^[\x20-\x7E]+$/.test(key))) { + res.status(400).json({ + error: { code: "invalid_idempotency_key", message: "Invalid idempotency key format" }, + }); + return; + } + next(); + }, +})); + +jest.mock("../src/config/logger", () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +function makeApp(): express.Express { + const app = express(); + app.use(express.json()); + app.use( + (req: express.Request, _res: express.Response, next: express.NextFunction) => { + requestContextStorage.run({ requestId: "test-req-id" }, next); + }, + ); + app.use("/api/search", searchRouter); + return app; +} + +describe("GET /api/search", () => { + it("returns search results with default parameters", async () => { + const response = await request(makeApp()) + .get("/api/search") + .query({ q: "test query" }); + + expect(response.status).toBe(200); + expect(response.body.data).toBeDefined(); + expect(response.body.data.meta.query).toBe("test query"); + }); + + it("accepts limit and page parameters", async () => { + const response = await request(makeApp()) + .get("/api/search") + .query({ q: "test", limit: "25", page: "2" }); + + expect(response.status).toBe(200); + expect(response.body.data.meta.limit).toBe(25); + expect(response.body.data.meta.page).toBe(2); + }); + + it("rejects empty search query", async () => { + const response = await request(makeApp()) + .get("/api/search") + .query({ q: "" }); + + expect(response.status).toBe(400); + expect(response.body.error.code).toBe("validation_error"); + }); + + it("rejects query that is too long", async () => { + const response = await request(makeApp()) + .get("/api/search") + .query({ q: "a".repeat(201) }); + + expect(response.status).toBe(400); + expect(response.body.error.code).toBe("validation_error"); + }); + + it("rejects control characters in query", async () => { + const response = await request(makeApp()) + .get("/api/search") + .query({ q: "test\x00query" }); + + expect(response.status).toBe(400); + expect(response.body.error.code).toBe("validation_error"); + }); + + it("rejects limit exceeding 100", async () => { + const response = await request(makeApp()) + .get("/api/search") + .query({ q: "test", limit: "101" }); + + expect(response.status).toBe(400); + expect(response.body.error.code).toBe("validation_error"); + }); + + it("rejects limit below 1", async () => { + const response = await request(makeApp()) + .get("/api/search") + .query({ q: "test", limit: "0" }); + + expect(response.status).toBe(400); + expect(response.body.error.code).toBe("validation_error"); + }); +}); + +describe("POST /api/search (mutation)", () => { + it("saves a search query with valid body", async () => { + const response = await request(makeApp()) + .post("/api/search") + .send({ query: "prediction markets" }) + .set("idempotency-key", "test-key-1"); + + expect(response.status).toBe(201); + expect(response.body.data.query).toBe("prediction markets"); + expect(response.body.data.id).toBeDefined(); + expect(response.body.data.createdAt).toBeDefined(); + }); + + it("saves a search query with optional label", async () => { + const response = await request(makeApp()) + .post("/api/search") + .send({ query: "stellar development", label: "Dev Search" }) + .set("idempotency-key", "test-key-2"); + + expect(response.status).toBe(201); + expect(response.body.data.query).toBe("stellar development"); + expect(response.body.data.label).toBe("Dev Search"); + }); + + it("rejects empty query", async () => { + const response = await request(makeApp()) + .post("/api/search") + .send({ query: "" }) + .set("idempotency-key", "test-key-3"); + + expect(response.status).toBe(400); + expect(response.body.error.code).toBe("validation_error"); + }); + + it("rejects query with control characters", async () => { + const response = await request(makeApp()) + .post("/api/search") + .send({ query: "test\x01query" }) + .set("idempotency-key", "test-key-4"); + + expect(response.status).toBe(400); + expect(response.body.error.code).toBe("validation_error"); + }); + + it("rejects query exceeding max length", async () => { + const response = await request(makeApp()) + .post("/api/search") + .send({ query: "a".repeat(201) }) + .set("idempotency-key", "test-key-5"); + + expect(response.status).toBe(400); + expect(response.body.error.code).toBe("validation_error"); + }); + + it("rejects invalid idempotency-key format", async () => { + const response = await request(makeApp()) + .post("/api/search") + .send({ query: "test" }) + .set("idempotency-key", "a".repeat(256)); + + expect(response.status).toBe(400); + expect(response.body.error.code).toBe("invalid_idempotency_key"); + }); +}); + +describe("PATCH /api/search/:id (mutation)", () => { + it("updates a saved search label", async () => { + const response = await request(makeApp()) + .patch("/api/search/search-123") + .send({ label: "Updated Label" }) + .set("idempotency-key", "test-key-patch-1"); + + expect(response.status).toBe(200); + expect(response.body.data.id).toBe("search-123"); + expect(response.body.data.label).toBe("Updated Label"); + expect(response.body.data.updatedAt).toBeDefined(); + }); + + it("rejects empty label", async () => { + const response = await request(makeApp()) + .patch("/api/search/search-123") + .send({ label: "" }) + .set("idempotency-key", "test-key-patch-2"); + + expect(response.status).toBe(400); + expect(response.body.error.code).toBe("validation_error"); + }); + + it("rejects label exceeding max length", async () => { + const response = await request(makeApp()) + .patch("/api/search/search-123") + .send({ label: "a".repeat(101) }) + .set("idempotency-key", "test-key-patch-3"); + + expect(response.status).toBe(400); + expect(response.body.error.code).toBe("validation_error"); + }); + + it("rejects missing body", async () => { + const response = await request(makeApp()) + .patch("/api/search/search-123") + .send({}) + .set("idempotency-key", "test-key-patch-4"); + + expect(response.status).toBe(400); + expect(response.body.error.code).toBe("validation_error"); + }); +});