/**
* Integration Tests: search-posts tool
*
* Tests the search-posts tool with real HackerNews API calls.
* Verifies correct behavior for searches, filters, pagination, and error handling.
*/
import { describe, expect, it } from "vitest";
import { searchPostsTool } from "../../../src/tools/search-posts.js";
describe("search-posts integration", () => {
describe("Basic Search", () => {
it("should search for posts with keyword", async () => {
const result = await searchPostsTool({ query: "Python" });
expect(result.isError).toBe(false);
expect(result.content).toHaveLength(1);
const content = result.content[0];
expect(content.type).toBe("text");
if (content.type === "text") {
const data = JSON.parse(content.text);
expect(data).toHaveProperty("hits");
expect(data).toHaveProperty("nbHits");
expect(data).toHaveProperty("page");
expect(data).toHaveProperty("nbPages");
expect(data).toHaveProperty("hitsPerPage");
expect(data).toHaveProperty("query");
expect(Array.isArray(data.hits)).toBe(true);
expect(data.query).toBe("Python");
expect(data.page).toBe(0);
}
});
it("should return results containing search keyword", async () => {
const result = await searchPostsTool({ query: "JavaScript" });
expect(result.isError).toBe(false);
const content = result.content[0];
if (content.type === "text") {
const data = JSON.parse(content.text);
expect(data.hits.length).toBeGreaterThan(0);
// At least some results should contain the keyword
const hasKeyword = data.hits.some(
(hit: { title?: string; story_text?: string; comment_text?: string }) =>
hit.title?.toLowerCase().includes("javascript") ||
hit.story_text?.toLowerCase().includes("javascript") ||
hit.comment_text?.toLowerCase().includes("javascript")
);
expect(hasKeyword).toBe(true);
}
});
});
describe("Search with Filters", () => {
it("should filter by story tag", async () => {
const result = await searchPostsTool({
query: "TypeScript",
tags: ["story"],
});
expect(result.isError).toBe(false);
const content = result.content[0];
if (content.type === "text") {
const data = JSON.parse(content.text);
expect(data.hits.length).toBeGreaterThan(0);
// All results should be stories
for (const hit of data.hits as { _tags: string[] }[]) {
expect(hit._tags).toContain("story");
}
}
});
it("should apply numeric filter for points", async () => {
const result = await searchPostsTool({
query: "startup",
numericFilters: ["points>=100"],
});
expect(result.isError).toBe(false);
const content = result.content[0];
if (content.type === "text") {
const data = JSON.parse(content.text);
if (data.hits.length > 0) {
// All results should have 100+ points
for (const hit of data.hits as { points: number | null }[]) {
if (hit.points !== null) {
expect(hit.points).toBeGreaterThanOrEqual(100);
}
}
}
}
});
it("should combine tags and numeric filters", async () => {
const result = await searchPostsTool({
query: "AI",
tags: ["story"],
numericFilters: ["points>=50"],
});
expect(result.isError).toBe(false);
const content = result.content[0];
if (content.type === "text") {
const data = JSON.parse(content.text);
if (data.hits.length > 0) {
for (const hit of data.hits as { _tags: string[]; points: number | null }[]) {
expect(hit._tags).toContain("story");
if (hit.points !== null) {
expect(hit.points).toBeGreaterThanOrEqual(50);
}
}
}
}
});
it("should filter by multiple numeric conditions", async () => {
const result = await searchPostsTool({
query: "programming",
numericFilters: ["points>=100", "num_comments>=20"],
});
expect(result.isError).toBe(false);
const content = result.content[0];
if (content.type === "text") {
const data = JSON.parse(content.text);
if (data.hits.length > 0) {
for (const hit of data.hits as { points: number | null; num_comments: number | null }[]) {
if (hit.points !== null) {
expect(hit.points).toBeGreaterThanOrEqual(100);
}
if (hit.num_comments !== null) {
expect(hit.num_comments).toBeGreaterThanOrEqual(20);
}
}
}
}
});
});
describe("Pagination", () => {
it("should return first page by default", async () => {
const result = await searchPostsTool({ query: "React" });
expect(result.isError).toBe(false);
const content = result.content[0];
if (content.type === "text") {
const data = JSON.parse(content.text);
expect(data.page).toBe(0);
}
});
it("should return different results for different pages", async () => {
const page0 = await searchPostsTool({ query: "Node.js", page: 0 });
const page1 = await searchPostsTool({ query: "Node.js", page: 1 });
expect(page0.isError).toBe(false);
expect(page1.isError).toBe(false);
const content0 = page0.content[0];
const content1 = page1.content[0];
if (content0.type === "text" && content1.type === "text") {
const data0 = JSON.parse(content0.text);
const data1 = JSON.parse(content1.text);
// If there are results on both pages, they should be different
if (data0.hits.length > 0 && data1.hits.length > 0) {
const firstId0 = data0.hits[0].objectID;
const firstId1 = data1.hits[0].objectID;
expect(firstId0).not.toBe(firstId1);
}
expect(data0.page).toBe(0);
expect(data1.page).toBe(1);
}
});
it("should respect custom hitsPerPage", async () => {
const result = await searchPostsTool({
query: "Docker",
hitsPerPage: 5,
});
expect(result.isError).toBe(false);
const content = result.content[0];
if (content.type === "text") {
const data = JSON.parse(content.text);
expect(data.hitsPerPage).toBe(5);
if (data.hits.length > 0) {
expect(data.hits.length).toBeLessThanOrEqual(5);
}
}
});
it("should provide pagination metadata", async () => {
const result = await searchPostsTool({ query: "web development" });
expect(result.isError).toBe(false);
const content = result.content[0];
if (content.type === "text") {
const data = JSON.parse(content.text);
expect(typeof data.nbHits).toBe("number");
expect(typeof data.nbPages).toBe("number");
expect(typeof data.page).toBe("number");
expect(typeof data.hitsPerPage).toBe("number");
// Verify pagination metadata is present and logical
if (data.nbHits > 0) {
expect(data.nbPages).toBeGreaterThan(0);
// HackerNews API may cap the number of pages, so just verify it's reasonable
expect(data.nbPages).toBeLessThanOrEqual(Math.ceil(data.nbHits / data.hitsPerPage));
}
}
});
});
describe("Error Handling", () => {
it("should return validation error for empty query", async () => {
const result = await searchPostsTool({ query: "" });
expect(result.isError).toBe(true);
const content = result.content[0];
if (content.type === "text") {
expect(content.text).toContain("Validation error");
expect(content.text).toContain("query");
}
});
it("should return validation error for invalid page number", async () => {
const result = await searchPostsTool({ query: "test", page: -1 });
expect(result.isError).toBe(true);
const content = result.content[0];
if (content.type === "text") {
expect(content.text).toContain("Validation error");
}
});
it("should return validation error for invalid hitsPerPage", async () => {
const result = await searchPostsTool({
query: "test",
hitsPerPage: 2000,
});
expect(result.isError).toBe(true);
const content = result.content[0];
if (content.type === "text") {
expect(content.text).toContain("Validation error");
}
});
it("should handle missing query parameter", async () => {
const result = await searchPostsTool({});
expect(result.isError).toBe(true);
const content = result.content[0];
if (content.type === "text") {
expect(content.text).toContain("Validation error");
expect(content.text).toContain("query");
}
});
});
describe("Advanced Filtering (User Story 6)", () => {
it("should filter by points threshold", async () => {
const result = await searchPostsTool({
query: "AI",
numericFilters: ["points>=100"],
});
expect(result.isError).toBe(false);
const content = result.content[0];
if (content.type === "text") {
const data = JSON.parse(content.text);
if (data.hits.length > 0) {
// All results should have 100+ points
for (const hit of data.hits as { points: number | null }[]) {
if (hit.points !== null) {
expect(hit.points).toBeGreaterThanOrEqual(100);
}
}
}
}
});
it("should filter by comment count threshold", async () => {
const result = await searchPostsTool({
query: "startup",
numericFilters: ["num_comments>=50"],
});
expect(result.isError).toBe(false);
const content = result.content[0];
if (content.type === "text") {
const data = JSON.parse(content.text);
if (data.hits.length > 0) {
// All results should have 50+ comments
for (const hit of data.hits as { num_comments: number | null }[]) {
if (hit.num_comments !== null) {
expect(hit.num_comments).toBeGreaterThanOrEqual(50);
}
}
}
}
});
it("should combine multiple numeric filters (AND logic)", async () => {
const result = await searchPostsTool({
query: "technology",
numericFilters: ["points>=100", "num_comments>=20"],
});
expect(result.isError).toBe(false);
const content = result.content[0];
if (content.type === "text") {
const data = JSON.parse(content.text);
if (data.hits.length > 0) {
// All results should match both conditions
for (const hit of data.hits as { points: number | null; num_comments: number | null }[]) {
if (hit.points !== null) {
expect(hit.points).toBeGreaterThanOrEqual(100);
}
if (hit.num_comments !== null) {
expect(hit.num_comments).toBeGreaterThanOrEqual(20);
}
}
}
}
});
it("should filter by author using tags", async () => {
const result = await searchPostsTool({
query: "startup",
tags: ["author_pg"],
});
expect(result.isError).toBe(false);
const content = result.content[0];
if (content.type === "text") {
const data = JSON.parse(content.text);
if (data.hits.length > 0) {
// All results should be by author "pg"
for (const hit of data.hits as { author: string }[]) {
expect(hit.author).toBe("pg");
}
}
}
});
it("should support OR logic with tags (story or poll)", async () => {
const result = await searchPostsTool({
query: "software",
tags: ["(story,poll)"],
});
expect(result.isError).toBe(false);
const content = result.content[0];
if (content.type === "text") {
const data = JSON.parse(content.text);
if (data.hits.length > 0) {
// All results should be either stories or polls
for (const hit of data.hits as { _tags: string[] }[]) {
const isStoryOrPoll = hit._tags.includes("story") || hit._tags.includes("poll");
expect(isStoryOrPoll).toBe(true);
}
}
}
});
});
});