ci: add Gitea Actions workflow and unit tests
CI / Build & Test (push) Failing after 5s

- 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:
Space-Banane
2026-06-17 22:09:54 +02:00
parent a1d47d4819
commit 75ecb6232c
4 changed files with 235 additions and 1 deletions
+34
View File
@@ -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
View File
@@ -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"
+163
View File
@@ -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" }));
});
});
+35
View File
@@ -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");
});
});