- Add .gitea/workflows/ci.yml: runs build + tests on push/PR to main - Add vitest as dev dependency - Add src/utils.test.ts: 4 tests covering handleTool success, Error throw, non-Error throw, and arg passthrough - Add src/client.test.ts: 12 tests covering URL building, Authorization header, query string params, Content-Type presence/absence, error handling with message extraction and statusText fallback, and HTTP method correctness Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
+3
-1
@@ -11,6 +11,7 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "tsc --watch",
|
"dev": "tsc --watch",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
|
"test": "vitest run",
|
||||||
"prepublishOnly": "pnpm build"
|
"prepublishOnly": "pnpm build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -19,7 +20,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.19.21",
|
"@types/node": "^22.19.21",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
|
|||||||
@@ -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" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user