From ee1730b31f8837fbe51752b2002b78f7e2947c88 Mon Sep 17 00:00:00 2001 From: Rolf Koenders Date: Thu, 23 Apr 2026 19:32:30 +0200 Subject: [PATCH] test: add command and API unit tests to prevent broken CLI regressions Adds tests for `listBookmarks` (ensures it unwraps `.data` from the paginated response) and the `list` command (default view, --urls, --json, --archived, --folder, --tag filters). The missing test coverage allowed a stale dist to ship a broken `keeply list` silently. --- src/api.spec.ts | 82 +++++++++++++ src/commands/list.spec.ts | 234 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 src/api.spec.ts create mode 100644 src/commands/list.spec.ts diff --git a/src/api.spec.ts b/src/api.spec.ts new file mode 100644 index 0000000..7445a76 --- /dev/null +++ b/src/api.spec.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { KeeplyApi } from "./api.js"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +function mockResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + }; +} + +describe("KeeplyApi", () => { + let api: KeeplyApi; + + beforeEach(() => { + mockFetch.mockReset(); + api = new KeeplyApi("test-key", "https://api.test.com"); + }); + + describe("listBookmarks", () => { + it("returns the data array from the paginated response", async () => { + const bookmarks = [{ id: "1", url: "https://example.com" }]; + mockFetch.mockResolvedValue( + mockResponse({ data: bookmarks, total: 1, page: 1, limit: 500 }), + ); + + const result = await api.listBookmarks(); + + expect(Array.isArray(result)).toBe(true); + expect(result).toEqual(bookmarks); + }); + + it("requests /bookmarks with limit=500", async () => { + mockFetch.mockResolvedValue( + mockResponse({ data: [], total: 0, page: 1, limit: 500 }), + ); + + await api.listBookmarks(); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.test.com/bookmarks?limit=500", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "ApiKey test-key", + }), + }), + ); + }); + + it("throws on non-ok response", async () => { + mockFetch.mockResolvedValue( + mockResponse({ message: "Unauthorized" }, 401), + ); + + await expect(api.listBookmarks()).rejects.toThrow("Unauthorized"); + }); + + it("joins array error messages with comma", async () => { + mockFetch.mockResolvedValue( + mockResponse({ message: ["field required", "too short"] }, 400), + ); + + await expect(api.listBookmarks()).rejects.toThrow( + "field required, too short", + ); + }); + }); + + describe("searchBookmarks", () => { + it("returns the hits array from the search response", async () => { + const hits = [{ id: "1", url: "https://example.com" }]; + mockFetch.mockResolvedValue(mockResponse({ hits })); + + const result = await api.searchBookmarks("test"); + + expect(result).toEqual(hits); + }); + }); +}); diff --git a/src/commands/list.spec.ts b/src/commands/list.spec.ts new file mode 100644 index 0000000..ae1c90c --- /dev/null +++ b/src/commands/list.spec.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Command } from "commander"; +import type { Bookmark } from "../types.js"; + +vi.mock("../config.js", () => ({ + requireConfig: () => ({ apiKey: "test-key", apiUrl: "https://api.test.com" }), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +import { registerListCommand } from "./list.js"; + +function makeBookmark(overrides: Partial = {}): Bookmark { + return { + id: "bm-1", + url: "https://example.com", + title: "Example", + archived: false, + deletedAt: null, + folderId: null, + folder: null, + tags: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +function mockBookmarksResponse(bookmarks: Bookmark[]) { + return { + ok: true, + status: 200, + json: () => + Promise.resolve({ + data: bookmarks, + total: bookmarks.length, + page: 1, + limit: 500, + }), + }; +} + +function mockFoldersResponse(folders: { id: string; name: string }[]) { + return { + ok: true, + status: 200, + json: () => Promise.resolve(folders), + }; +} + +describe("list command", () => { + let program: Command; + let stdout: string[]; + let consoleLogs: string[]; + + beforeEach(() => { + mockFetch.mockReset(); + program = new Command(); + program.exitOverride(); + stdout = []; + consoleLogs = []; + + vi.spyOn(process.stdout, "write").mockImplementation((data) => { + stdout.push(String(data)); + return true; + }); + vi.spyOn(console, "log").mockImplementation((...args) => { + consoleLogs.push(args.map(String).join(" ")); + }); + vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit"); + }) as () => never); + + registerListCommand(program); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("displays bookmarks returned by the API", async () => { + mockFetch.mockResolvedValue(mockBookmarksResponse([makeBookmark()])); + + await program.parseAsync(["node", "keeply", "list"]); + + expect(consoleLogs.join("\n")).toContain("https://example.com"); + }); + + it('shows "No bookmarks found" when the API returns an empty list', async () => { + mockFetch.mockResolvedValue(mockBookmarksResponse([])); + + await program.parseAsync(["node", "keeply", "list"]); + + expect(consoleLogs.join("\n")).toContain("No bookmarks found"); + }); + + it("outputs one URL per line with --urls", async () => { + mockFetch.mockResolvedValue( + mockBookmarksResponse([ + makeBookmark({ id: "bm-1", url: "https://a.com" }), + makeBookmark({ id: "bm-2", url: "https://b.com" }), + ]), + ); + + await program.parseAsync(["node", "keeply", "list", "--urls"]); + + expect(stdout).toContain("https://a.com\n"); + expect(stdout).toContain("https://b.com\n"); + }); + + it("outputs a valid JSON array with --json", async () => { + mockFetch.mockResolvedValue(mockBookmarksResponse([makeBookmark()])); + + await program.parseAsync(["node", "keeply", "list", "--json"]); + + const parsed = JSON.parse(stdout.join("")); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].url).toBe("https://example.com"); + }); + + it("excludes archived and soft-deleted bookmarks by default", async () => { + mockFetch.mockResolvedValue( + mockBookmarksResponse([ + makeBookmark({ id: "bm-1", url: "https://active.com" }), + makeBookmark({ + id: "bm-2", + url: "https://archived.com", + archived: true, + }), + makeBookmark({ + id: "bm-3", + url: "https://deleted.com", + deletedAt: "2026-01-01T00:00:00.000Z", + }), + ]), + ); + + await program.parseAsync(["node", "keeply", "list", "--urls"]); + + expect(stdout).toContain("https://active.com\n"); + expect(stdout).not.toContain("https://archived.com\n"); + expect(stdout).not.toContain("https://deleted.com\n"); + }); + + it("shows only archived bookmarks with --archived", async () => { + mockFetch.mockResolvedValue( + mockBookmarksResponse([ + makeBookmark({ id: "bm-1", url: "https://active.com" }), + makeBookmark({ + id: "bm-2", + url: "https://archived.com", + archived: true, + }), + ]), + ); + + await program.parseAsync([ + "node", + "keeply", + "list", + "--archived", + "--urls", + ]); + + expect(stdout).not.toContain("https://active.com\n"); + expect(stdout).toContain("https://archived.com\n"); + }); + + it("filters by folder name with --folder", async () => { + mockFetch + .mockResolvedValueOnce( + mockBookmarksResponse([ + makeBookmark({ + id: "bm-1", + url: "https://work.com", + folderId: "f-1", + }), + makeBookmark({ + id: "bm-2", + url: "https://personal.com", + folderId: "f-2", + }), + ]), + ) + .mockResolvedValueOnce( + mockFoldersResponse([ + { id: "f-1", name: "Work" }, + { id: "f-2", name: "Personal" }, + ]), + ); + + await program.parseAsync([ + "node", + "keeply", + "list", + "--folder", + "Work", + "--urls", + ]); + + expect(stdout).toContain("https://work.com\n"); + expect(stdout).not.toContain("https://personal.com\n"); + }); + + it("filters by tag name with --tag", async () => { + mockFetch.mockResolvedValue( + mockBookmarksResponse([ + makeBookmark({ + id: "bm-1", + url: "https://tagged.com", + tags: [{ tag: { id: "t-1", name: "react" } }], + }), + makeBookmark({ + id: "bm-2", + url: "https://other.com", + tags: [{ tag: { id: "t-2", name: "css" } }], + }), + ]), + ); + + await program.parseAsync([ + "node", + "keeply", + "list", + "--tag", + "react", + "--urls", + ]); + + expect(stdout).toContain("https://tagged.com\n"); + expect(stdout).not.toContain("https://other.com\n"); + }); +});