Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/api.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
234 changes: 234 additions & 0 deletions src/commands/list.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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");
});
});
Loading