diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..221e0c8 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + ci: + name: Build & Test + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install + + - name: Build + run: pnpm build + + - name: Test + run: pnpm test diff --git a/package.json b/package.json index a0a3b06..77ecff2 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "build": "tsc", "dev": "tsc --watch", "start": "node dist/index.js", + "test": "vitest run", "prepublishOnly": "pnpm build" }, "dependencies": { @@ -19,7 +20,8 @@ }, "devDependencies": { "@types/node": "^22.19.21", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.1.9" }, "engines": { "node": ">=22" diff --git a/src/client.test.ts b/src/client.test.ts new file mode 100644 index 0000000..6ece315 --- /dev/null +++ b/src/client.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { BetterNewsClient } from "./client.js"; + +function makeFetch(status: number, body: unknown) { + return vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + statusText: "OK", + json: () => Promise.resolve(body), + text: () => Promise.resolve(String(body)), + }); +} + +describe("BetterNewsClient", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + }); + + it("sends Authorization header with api key", async () => { + const fetch = makeFetch(200, {}); + vi.stubGlobal("fetch", fetch); + + const client = new BetterNewsClient("bn_sk_test"); + await client.get("/api/test"); + + const [, init] = fetch.mock.calls[0]; + expect(init.headers["Authorization"]).toBe("Bearer bn_sk_test"); + }); + + it("uses the configured baseUrl", async () => { + const fetch = makeFetch(200, {}); + vi.stubGlobal("fetch", fetch); + + const client = new BetterNewsClient("bn_sk_x", "http://localhost:3000"); + await client.get("/api/news"); + + const [url] = fetch.mock.calls[0]; + expect(url).toBe("http://localhost:3000/api/news"); + }); + + it("defaults baseUrl to https://api.betternews.app", async () => { + const fetch = makeFetch(200, {}); + vi.stubGlobal("fetch", fetch); + + const client = new BetterNewsClient("bn_sk_x"); + await client.get("/api/news"); + + const [url] = fetch.mock.calls[0]; + expect(url).toMatch(/^https:\/\/api\.betternews\.app/); + }); + + it("appends query string params", async () => { + const fetch = makeFetch(200, []); + vi.stubGlobal("fetch", fetch); + + const client = new BetterNewsClient("bn_sk_x", "http://localhost"); + await client.get("/api/news", { page: 2, limit: 10 }); + + const [url] = fetch.mock.calls[0]; + expect(url).toContain("page=2"); + expect(url).toContain("limit=10"); + }); + + it("omits undefined query params", async () => { + const fetch = makeFetch(200, []); + vi.stubGlobal("fetch", fetch); + + const client = new BetterNewsClient("bn_sk_x", "http://localhost"); + await client.get("/api/news", { page: 1, limit: undefined }); + + const [url] = fetch.mock.calls[0]; + expect(url).toContain("page=1"); + expect(url).not.toContain("limit"); + }); + + it("does not send Content-Type on GET requests", async () => { + const fetch = makeFetch(200, {}); + vi.stubGlobal("fetch", fetch); + + const client = new BetterNewsClient("bn_sk_x", "http://localhost"); + await client.get("/api/news"); + + const [, init] = fetch.mock.calls[0]; + expect(init.headers["Content-Type"]).toBeUndefined(); + }); + + it("sends Content-Type application/json on POST with body", async () => { + const fetch = makeFetch(200, {}); + vi.stubGlobal("fetch", fetch); + + const client = new BetterNewsClient("bn_sk_x", "http://localhost"); + await client.post("/api/news", { title: "Test" }); + + const [, init] = fetch.mock.calls[0]; + expect(init.headers["Content-Type"]).toBe("application/json"); + expect(init.body).toBe(JSON.stringify({ title: "Test" })); + }); + + it("does not send Content-Type on POST without body", async () => { + const fetch = makeFetch(200, {}); + vi.stubGlobal("fetch", fetch); + + const client = new BetterNewsClient("bn_sk_x", "http://localhost"); + await client.post("/api/news/abc/submit"); + + const [, init] = fetch.mock.calls[0]; + expect(init.headers["Content-Type"]).toBeUndefined(); + }); + + it("throws with API error message on non-ok response", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + json: () => Promise.resolve({ message: "news item not found" }), + }); + vi.stubGlobal("fetch", fetch); + + const client = new BetterNewsClient("bn_sk_x", "http://localhost"); + await expect(client.get("/api/news/bad-id")).rejects.toThrow( + "BetterNews API error 404: news item not found", + ); + }); + + it("falls back to statusText when error body has no message", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: () => Promise.reject(new Error("not json")), + text: () => Promise.resolve(""), + }); + vi.stubGlobal("fetch", fetch); + + const client = new BetterNewsClient("bn_sk_x", "http://localhost"); + await expect(client.get("/api/news")).rejects.toThrow( + "BetterNews API error 500: Internal Server Error", + ); + }); + + it("uses DELETE method", async () => { + const fetch = makeFetch(200, {}); + vi.stubGlobal("fetch", fetch); + + const client = new BetterNewsClient("bn_sk_x", "http://localhost"); + await client.delete("/api/news/abc"); + + const [, init] = fetch.mock.calls[0]; + expect(init.method).toBe("DELETE"); + }); + + it("uses PATCH method with body", async () => { + const fetch = makeFetch(200, {}); + vi.stubGlobal("fetch", fetch); + + const client = new BetterNewsClient("bn_sk_x", "http://localhost"); + await client.patch("/api/news/abc", { title: "Updated" }); + + const [, init] = fetch.mock.calls[0]; + expect(init.method).toBe("PATCH"); + expect(init.body).toBe(JSON.stringify({ title: "Updated" })); + }); +}); diff --git a/src/utils.test.ts b/src/utils.test.ts new file mode 100644 index 0000000..050a4d0 --- /dev/null +++ b/src/utils.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { handleTool } from "./utils.js"; + +describe("handleTool", () => { + it("returns text content on success", async () => { + const tool = handleTool(async () => "hello"); + const result = await tool({}); + expect(result).toEqual({ content: [{ type: "text", text: "hello" }] }); + expect(result.isError).toBeUndefined(); + }); + + it("returns isError:true when handler throws an Error", async () => { + const tool = handleTool(async () => { + throw new Error("something went wrong"); + }); + const result = await tool({}); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("something went wrong"); + }); + + it("returns isError:true when handler throws a non-Error value", async () => { + const tool = handleTool(async () => { + throw "string error"; + }); + const result = await tool({}); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("string error"); + }); + + it("passes args through to the handler", async () => { + const tool = handleTool(async (args: { id: string }) => args.id); + const result = await tool({ id: "abc-123" }); + expect(result.content[0].text).toBe("abc-123"); + }); +});