From a91d26c2a9f1adfb18a36caaa83a4140b39d87a4 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 26 Jan 2026 02:09:34 +0000 Subject: [PATCH 1/3] test: add comprehensive document and collection command tests Adds dedicated test files for: - document.test.ts (21 tests): list, get, open, create, update, delete, move, archive, unarchive - collection.test.ts (18 tests): list, get, create, update, delete Tests cover: - All CLI options (--json, --ndjson, --full, pagination, sorting) - API request parameters validation - Confirmation requirements for destructive operations - Collection/document resolution Addresses #13 --- src/__tests__/collection.test.ts | 532 +++++++++++++++++++++++++ src/__tests__/document.test.ts | 647 +++++++++++++++++++++++++++++++ 2 files changed, 1179 insertions(+) create mode 100644 src/__tests__/collection.test.ts create mode 100644 src/__tests__/document.test.ts diff --git a/src/__tests__/collection.test.ts b/src/__tests__/collection.test.ts new file mode 100644 index 0000000..7dfcf27 --- /dev/null +++ b/src/__tests__/collection.test.ts @@ -0,0 +1,532 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../lib/auth.js", () => ({ + getApiToken: () => "test-token", + getBaseUrl: () => "https://test.outline.com", + getTokenSource: () => "config" as const, +})); + +vi.mock("../lib/api.js", () => ({ + apiRequest: vi.fn(), +})); + +const COL_ID = "660e8400-e29b-41d4-a716-446655440001"; + +const mockCollection = { + id: COL_ID, + name: "Engineering", + description: "Engineering docs", + color: "#4A90E2", + permission: "read_write", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-15T12:00:00Z", + documentCount: 42, +}; + +describe("collection commands", () => { + let logs: string[]; + let errors: string[]; + let apiRequest: ReturnType; + + beforeEach(async () => { + logs = []; + errors = []; + vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { + logs.push(args.join(" ")); + }); + vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + errors.push(args.join(" ")); + }); + const api = await import("../lib/api.js"); + apiRequest = api.apiRequest as ReturnType; + apiRequest.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("collection list", () => { + it("lists collections with default options", async () => { + apiRequest.mockResolvedValue({ + data: [mockCollection], + pagination: { offset: 0, limit: 25 }, + }); + + const { registerCollectionCommand } = await import( + "../commands/collection.js" + ); + const program = new Command(); + program.exitOverride(); + registerCollectionCommand(program); + + await program.parseAsync(["node", "ol", "collection", "list"]); + + expect(apiRequest).toHaveBeenCalledWith("collections.list", { + limit: 25, + offset: 0, + }); + }); + + it("passes pagination options", async () => { + apiRequest.mockResolvedValue({ + data: [], + pagination: { offset: 10, limit: 5 }, + }); + + const { registerCollectionCommand } = await import( + "../commands/collection.js" + ); + const program = new Command(); + program.exitOverride(); + registerCollectionCommand(program); + + await program.parseAsync([ + "node", + "ol", + "collection", + "list", + "--limit", + "5", + "--offset", + "10", + ]); + + expect(apiRequest).toHaveBeenCalledWith("collections.list", { + limit: 5, + offset: 10, + }); + }); + + it("outputs JSON when --json flag used", async () => { + apiRequest.mockResolvedValue({ + data: [mockCollection], + pagination: { offset: 0, limit: 25 }, + }); + + const { registerCollectionCommand } = await import( + "../commands/collection.js" + ); + const program = new Command(); + program.exitOverride(); + registerCollectionCommand(program); + + await program.parseAsync(["node", "ol", "collection", "list", "--json"]); + + const parsed = JSON.parse(logs[0]); + expect(parsed[0].name).toBe("Engineering"); + expect(parsed[0].documentCount).toBe(42); + }); + + it("outputs NDJSON when --ndjson flag used", async () => { + apiRequest.mockResolvedValue({ + data: [ + mockCollection, + { ...mockCollection, id: "col-456", name: "Design" }, + ], + pagination: { offset: 0, limit: 25 }, + }); + + const { registerCollectionCommand } = await import( + "../commands/collection.js" + ); + const program = new Command(); + program.exitOverride(); + registerCollectionCommand(program); + + await program.parseAsync([ + "node", + "ol", + "collection", + "list", + "--ndjson", + ]); + + expect(logs.length).toBe(2); + expect(JSON.parse(logs[0]).name).toBe("Engineering"); + expect(JSON.parse(logs[1]).name).toBe("Design"); + }); + + it("includes all fields with --full flag", async () => { + apiRequest.mockResolvedValue({ + data: [mockCollection], + pagination: { offset: 0, limit: 25 }, + }); + + const { registerCollectionCommand } = await import( + "../commands/collection.js" + ); + const program = new Command(); + program.exitOverride(); + registerCollectionCommand(program); + + await program.parseAsync([ + "node", + "ol", + "collection", + "list", + "--json", + "--full", + ]); + + const parsed = JSON.parse(logs[0]); + expect(parsed[0]).toHaveProperty("createdAt"); + expect(parsed[0]).toHaveProperty("updatedAt"); + expect(parsed[0]).toHaveProperty("permission"); + }); + }); + + describe("collection get", () => { + it("gets collection by ID", async () => { + apiRequest.mockResolvedValue({ + data: mockCollection, + }); + + const { registerCollectionCommand } = await import( + "../commands/collection.js" + ); + const program = new Command(); + program.exitOverride(); + registerCollectionCommand(program); + + await program.parseAsync(["node", "ol", "collection", "get", COL_ID]); + + expect(apiRequest).toHaveBeenCalledWith("collections.info", { + id: COL_ID, + }); + }); + + it("outputs JSON when --json flag used", async () => { + apiRequest.mockResolvedValue({ + data: mockCollection, + }); + + const { registerCollectionCommand } = await import( + "../commands/collection.js" + ); + const program = new Command(); + program.exitOverride(); + registerCollectionCommand(program); + + await program.parseAsync([ + "node", + "ol", + "collection", + "get", + COL_ID, + "--json", + ]); + + const parsed = JSON.parse(logs[0]); + expect(parsed.id).toBe(COL_ID); + expect(parsed.name).toBe("Engineering"); + }); + }); + + describe("collection create", () => { + it("creates collection with name", async () => { + apiRequest.mockResolvedValue({ + data: mockCollection, + }); + + const { registerCollectionCommand } = await import( + "../commands/collection.js" + ); + const program = new Command(); + program.exitOverride(); + registerCollectionCommand(program); + + await program.parseAsync([ + "node", + "ol", + "collection", + "create", + "--name", + "New Collection", + ]); + + expect(apiRequest).toHaveBeenCalledWith("collections.create", { + name: "New Collection", + }); + expect(logs[0]).toContain("Created:"); + }); + + it("creates collection with description", async () => { + apiRequest.mockResolvedValue({ + data: mockCollection, + }); + + const { registerCollectionCommand } = await import( + "../commands/collection.js" + ); + const program = new Command(); + program.exitOverride(); + registerCollectionCommand(program); + + await program.parseAsync([ + "node", + "ol", + "collection", + "create", + "--name", + "New Collection", + "--description", + "A great collection", + ]); + + expect(apiRequest).toHaveBeenCalledWith("collections.create", { + name: "New Collection", + description: "A great collection", + }); + }); + + it("creates collection with color", async () => { + apiRequest.mockResolvedValue({ + data: mockCollection, + }); + + const { registerCollectionCommand } = await import( + "../commands/collection.js" + ); + const program = new Command(); + program.exitOverride(); + registerCollectionCommand(program); + + await program.parseAsync([ + "node", + "ol", + "collection", + "create", + "--name", + "New Collection", + "--color", + "#FF5733", + ]); + + expect(apiRequest).toHaveBeenCalledWith("collections.create", { + name: "New Collection", + color: "#FF5733", + }); + }); + + it("creates private collection with --private flag", async () => { + apiRequest.mockResolvedValue({ + data: mockCollection, + }); + + const { registerCollectionCommand } = await import( + "../commands/collection.js" + ); + const program = new Command(); + program.exitOverride(); + registerCollectionCommand(program); + + await program.parseAsync([ + "node", + "ol", + "collection", + "create", + "--name", + "Private Collection", + "--private", + ]); + + expect(apiRequest).toHaveBeenCalledWith("collections.create", { + name: "Private Collection", + permission: "", + }); + }); + + it("outputs JSON when --json flag used", async () => { + apiRequest.mockResolvedValue({ + data: mockCollection, + }); + + const { registerCollectionCommand } = await import( + "../commands/collection.js" + ); + const program = new Command(); + program.exitOverride(); + registerCollectionCommand(program); + + await program.parseAsync([ + "node", + "ol", + "collection", + "create", + "--name", + "New Collection", + "--json", + ]); + + const parsed = JSON.parse(logs[0]); + expect(parsed.name).toBe("Engineering"); + }); + }); + + describe("collection update", () => { + it("updates collection name", async () => { + apiRequest.mockResolvedValue({ + data: { ...mockCollection, name: "Updated Name" }, + }); + + const { registerCollectionCommand } = await import( + "../commands/collection.js" + ); + const program = new Command(); + program.exitOverride(); + registerCollectionCommand(program); + + await program.parseAsync([ + "node", + "ol", + "collection", + "update", + COL_ID, + "--name", + "Updated Name", + ]); + + expect(apiRequest).toHaveBeenCalledWith("collections.update", { + id: COL_ID, + name: "Updated Name", + }); + expect(logs[0]).toContain("Updated:"); + }); + + it("updates collection description", async () => { + apiRequest.mockResolvedValue({ + data: mockCollection, + }); + + const { registerCollectionCommand } = await import( + "../commands/collection.js" + ); + const program = new Command(); + program.exitOverride(); + registerCollectionCommand(program); + + await program.parseAsync([ + "node", + "ol", + "collection", + "update", + COL_ID, + "--description", + "New description", + ]); + + expect(apiRequest).toHaveBeenCalledWith("collections.update", { + id: COL_ID, + description: "New description", + }); + }); + + it("updates collection color", async () => { + apiRequest.mockResolvedValue({ + data: mockCollection, + }); + + const { registerCollectionCommand } = await import( + "../commands/collection.js" + ); + const program = new Command(); + program.exitOverride(); + registerCollectionCommand(program); + + await program.parseAsync([ + "node", + "ol", + "collection", + "update", + COL_ID, + "--color", + "#00FF00", + ]); + + expect(apiRequest).toHaveBeenCalledWith("collections.update", { + id: COL_ID, + color: "#00FF00", + }); + }); + + it("outputs JSON when --json flag used", async () => { + apiRequest.mockResolvedValue({ + data: { ...mockCollection, name: "Updated" }, + }); + + const { registerCollectionCommand } = await import( + "../commands/collection.js" + ); + const program = new Command(); + program.exitOverride(); + registerCollectionCommand(program); + + await program.parseAsync([ + "node", + "ol", + "collection", + "update", + COL_ID, + "--name", + "Updated", + "--json", + ]); + + const parsed = JSON.parse(logs[0]); + expect(parsed.name).toBe("Updated"); + }); + }); + + describe("collection delete", () => { + it("requires --confirm flag", async () => { + const { registerCollectionCommand } = await import( + "../commands/collection.js" + ); + const program = new Command(); + program.exitOverride(); + registerCollectionCommand(program); + + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync(["node", "ol", "collection", "delete", COL_ID]), + ).rejects.toThrow("process.exit"); + + expect(errors[0]).toContain("CONFIRMATION_REQUIRED"); + exitSpy.mockRestore(); + }); + + it("deletes collection with --confirm flag", async () => { + // First call: resolveCollectionId verifies collection exists + // Second call: collections.delete + apiRequest + .mockResolvedValueOnce({ data: mockCollection }) + .mockResolvedValueOnce({}); + + const { registerCollectionCommand } = await import( + "../commands/collection.js" + ); + const program = new Command(); + program.exitOverride(); + registerCollectionCommand(program); + + await program.parseAsync([ + "node", + "ol", + "collection", + "delete", + COL_ID, + "--confirm", + ]); + + expect(apiRequest).toHaveBeenLastCalledWith("collections.delete", { + id: COL_ID, + }); + expect(logs[0]).toBe("Deleted."); + }); + }); +}); diff --git a/src/__tests__/document.test.ts b/src/__tests__/document.test.ts new file mode 100644 index 0000000..27f3046 --- /dev/null +++ b/src/__tests__/document.test.ts @@ -0,0 +1,647 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../lib/auth.js", () => ({ + getApiToken: () => "test-token", + getBaseUrl: () => "https://test.outline.com", + getTokenSource: () => "config" as const, +})); + +vi.mock("../lib/api.js", () => ({ + apiRequest: vi.fn(), +})); + +vi.mock("open", () => ({ + default: vi.fn(), +})); + +const DOC_ID = "550e8400-e29b-41d4-a716-446655440000"; +const COL_ID = "660e8400-e29b-41d4-a716-446655440001"; + +const mockDocument = { + id: DOC_ID, + title: "Test Document", + url: "/doc/test-document-abc123", + urlId: "test-document-abc123", + text: "# Test Document\n\nThis is the content.", + collectionId: COL_ID, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-15T12:00:00Z", + publishedAt: "2024-01-01T00:00:00Z", + archivedAt: null, + parentDocumentId: null, + revision: 5, +}; + +describe("document commands", () => { + let logs: string[]; + let errors: string[]; + let apiRequest: ReturnType; + + beforeEach(async () => { + logs = []; + errors = []; + vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { + logs.push(args.join(" ")); + }); + vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + errors.push(args.join(" ")); + }); + const api = await import("../lib/api.js"); + apiRequest = api.apiRequest as ReturnType; + apiRequest.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("document list", () => { + it("lists documents with default options", async () => { + apiRequest.mockResolvedValue({ + data: [mockDocument], + pagination: { offset: 0, limit: 25 }, + }); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync(["node", "ol", "document", "list"]); + + expect(apiRequest).toHaveBeenCalledWith("documents.list", { + limit: 25, + offset: 0, + sort: "updatedAt", + direction: "DESC", + }); + }); + + it("passes pagination options", async () => { + apiRequest.mockResolvedValue({ + data: [], + pagination: { offset: 10, limit: 5 }, + }); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync([ + "node", + "ol", + "document", + "list", + "--limit", + "5", + "--offset", + "10", + ]); + + expect(apiRequest).toHaveBeenCalledWith("documents.list", { + limit: 5, + offset: 10, + sort: "updatedAt", + direction: "DESC", + }); + }); + + it("passes sort options", async () => { + apiRequest.mockResolvedValue({ + data: [], + pagination: { offset: 0, limit: 25 }, + }); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync([ + "node", + "ol", + "document", + "list", + "--sort", + "title", + "--direction", + "ASC", + ]); + + expect(apiRequest).toHaveBeenCalledWith("documents.list", { + limit: 25, + offset: 0, + sort: "title", + direction: "ASC", + }); + }); + + it("outputs JSON when --json flag used", async () => { + apiRequest.mockResolvedValue({ + data: [mockDocument], + pagination: { offset: 0, limit: 25 }, + }); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync(["node", "ol", "document", "list", "--json"]); + + const parsed = JSON.parse(logs[0]); + expect(parsed[0].title).toBe("Test Document"); + }); + + it("outputs NDJSON when --ndjson flag used", async () => { + apiRequest.mockResolvedValue({ + data: [ + mockDocument, + { ...mockDocument, id: "doc-456", title: "Second" }, + ], + pagination: { offset: 0, limit: 25 }, + }); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync(["node", "ol", "document", "list", "--ndjson"]); + + expect(logs.length).toBe(2); + expect(JSON.parse(logs[0]).title).toBe("Test Document"); + expect(JSON.parse(logs[1]).title).toBe("Second"); + }); + }); + + describe("document get", () => { + it("gets document by URL ID", async () => { + apiRequest.mockResolvedValue({ + data: mockDocument, + }); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync([ + "node", + "ol", + "document", + "get", + "test-document-abc123", + ]); + + expect(apiRequest).toHaveBeenCalledWith("documents.info", { + id: "abc123", + }); + expect(logs[0]).toContain("Test Document"); + }); + + it("outputs raw markdown with --raw flag", async () => { + apiRequest.mockResolvedValue({ + data: mockDocument, + }); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync([ + "node", + "ol", + "document", + "get", + "test-document-abc123", + "--raw", + ]); + + expect(logs[0]).toContain("# Test Document"); + expect(logs[0]).toContain("This is the content"); + }); + + it("outputs JSON with --json flag", async () => { + apiRequest.mockResolvedValue({ + data: mockDocument, + }); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync([ + "node", + "ol", + "document", + "get", + "test-document-abc123", + "--json", + ]); + + const parsed = JSON.parse(logs[0]); + expect(parsed.id).toBe(DOC_ID); + expect(parsed.title).toBe("Test Document"); + }); + }); + + describe("document open", () => { + it("opens document in browser", async () => { + apiRequest.mockResolvedValue({ + data: mockDocument, + }); + const open = (await import("open")).default as ReturnType; + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync([ + "node", + "ol", + "document", + "open", + "test-document-abc123", + ]); + + expect(open).toHaveBeenCalledWith( + "https://test.outline.com/doc/test-document-abc123", + ); + expect(logs[0]).toContain("Opened:"); + }); + }); + + describe("document create", () => { + it("creates document with title and collection ID", async () => { + // First call: resolveCollectionId verifies collection exists + // Second call: documents.create + apiRequest + .mockResolvedValueOnce({ + data: { id: COL_ID, name: "Test Collection" }, + }) + .mockResolvedValueOnce({ data: mockDocument }); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync([ + "node", + "ol", + "document", + "create", + "--title", + "New Doc", + "--collection", + COL_ID, + ]); + + expect(apiRequest).toHaveBeenLastCalledWith("documents.create", { + title: "New Doc", + collectionId: COL_ID, + }); + expect(logs[0]).toContain("Created:"); + }); + + it("creates document with text content", async () => { + apiRequest + .mockResolvedValueOnce({ + data: { id: COL_ID, name: "Test Collection" }, + }) + .mockResolvedValueOnce({ data: mockDocument }); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync([ + "node", + "ol", + "document", + "create", + "--title", + "New Doc", + "--collection", + COL_ID, + "--text", + "Hello world", + ]); + + expect(apiRequest).toHaveBeenLastCalledWith("documents.create", { + title: "New Doc", + collectionId: COL_ID, + text: "Hello world", + }); + }); + + it("creates document with --publish flag", async () => { + apiRequest + .mockResolvedValueOnce({ + data: { id: COL_ID, name: "Test Collection" }, + }) + .mockResolvedValueOnce({ data: mockDocument }); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync([ + "node", + "ol", + "document", + "create", + "--title", + "New Doc", + "--collection", + COL_ID, + "--publish", + ]); + + expect(apiRequest).toHaveBeenLastCalledWith("documents.create", { + title: "New Doc", + collectionId: COL_ID, + publish: true, + }); + }); + + it("outputs JSON when --json flag used", async () => { + apiRequest.mockResolvedValue({ + data: mockDocument, + }); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync([ + "node", + "ol", + "document", + "create", + "--title", + "New Doc", + "--collection", + COL_ID, + "--json", + ]); + + const parsed = JSON.parse(logs[0]); + expect(parsed.title).toBe("Test Document"); + }); + }); + + describe("document update", () => { + it("updates document title", async () => { + apiRequest.mockResolvedValue({ + data: { ...mockDocument, title: "Updated Title" }, + }); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync([ + "node", + "ol", + "document", + "update", + DOC_ID, + "--title", + "Updated Title", + ]); + + expect(apiRequest).toHaveBeenCalledWith("documents.update", { + id: DOC_ID, + title: "Updated Title", + }); + expect(logs[0]).toContain("Updated:"); + }); + + it("updates document text", async () => { + apiRequest.mockResolvedValue({ + data: mockDocument, + }); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync([ + "node", + "ol", + "document", + "update", + DOC_ID, + "--text", + "New content", + ]); + + expect(apiRequest).toHaveBeenCalledWith("documents.update", { + id: DOC_ID, + text: "New content", + }); + }); + + it("extracts title from markdown heading", async () => { + apiRequest.mockResolvedValue({ + data: mockDocument, + }); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync([ + "node", + "ol", + "document", + "update", + DOC_ID, + "--text", + "# My Title\n\nBody content", + ]); + + expect(apiRequest).toHaveBeenCalledWith("documents.update", { + id: DOC_ID, + title: "My Title", + text: "Body content", + }); + }); + }); + + describe("document delete", () => { + it("requires --confirm flag", async () => { + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync(["node", "ol", "document", "delete", DOC_ID]), + ).rejects.toThrow("process.exit"); + + expect(errors[0]).toContain("CONFIRMATION_REQUIRED"); + exitSpy.mockRestore(); + }); + + it("deletes document with --confirm flag", async () => { + // First call: resolveDocumentId verifies doc exists + // Second call: documents.delete + apiRequest + .mockResolvedValueOnce({ data: mockDocument }) + .mockResolvedValueOnce({}); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync([ + "node", + "ol", + "document", + "delete", + DOC_ID, + "--confirm", + ]); + + expect(apiRequest).toHaveBeenLastCalledWith("documents.delete", { + id: DOC_ID, + }); + expect(logs[0]).toBe("Deleted."); + }); + }); + + describe("document move", () => { + it("moves document to another collection", async () => { + const TARGET_COL = "770e8400-e29b-41d4-a716-446655440002"; + // First call: resolveDocumentId + // Second call: resolveCollectionId + // Third call: documents.move + apiRequest + .mockResolvedValueOnce({ data: mockDocument }) + .mockResolvedValueOnce({ data: { id: TARGET_COL, name: "Target" } }) + .mockResolvedValueOnce({}); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync([ + "node", + "ol", + "document", + "move", + DOC_ID, + "--collection", + TARGET_COL, + ]); + + expect(apiRequest).toHaveBeenLastCalledWith("documents.move", { + id: DOC_ID, + collectionId: TARGET_COL, + }); + expect(logs[0]).toBe("Moved."); + }); + }); + + describe("document archive", () => { + it("archives a document", async () => { + apiRequest + .mockResolvedValueOnce({ data: mockDocument }) + .mockResolvedValueOnce({}); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync(["node", "ol", "document", "archive", DOC_ID]); + + expect(apiRequest).toHaveBeenLastCalledWith("documents.archive", { + id: DOC_ID, + }); + expect(logs[0]).toBe("Archived."); + }); + }); + + describe("document unarchive", () => { + it("unarchives a document", async () => { + apiRequest + .mockResolvedValueOnce({ data: mockDocument }) + .mockResolvedValueOnce({}); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync(["node", "ol", "document", "unarchive", DOC_ID]); + + expect(apiRequest).toHaveBeenLastCalledWith("documents.unarchive", { + id: DOC_ID, + }); + expect(logs[0]).toBe("Unarchived."); + }); + }); +}); From 2021fee6d91f940876111f96243aeb21a566c4a4 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 26 Jan 2026 02:13:16 +0000 Subject: [PATCH 2/3] Add search and skill command tests --- src/__tests__/search.test.ts | 146 +++++++++++++++++++++++++++++ src/__tests__/skill.test.ts | 174 +++++++++++++++++++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 src/__tests__/search.test.ts create mode 100644 src/__tests__/skill.test.ts diff --git a/src/__tests__/search.test.ts b/src/__tests__/search.test.ts new file mode 100644 index 0000000..322c7f9 --- /dev/null +++ b/src/__tests__/search.test.ts @@ -0,0 +1,146 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../lib/auth.js", () => ({ + getApiToken: () => "test-token", + getBaseUrl: () => "https://test.outline.com", + getTokenSource: () => "config" as const, +})); + +vi.mock("../lib/api.js", () => ({ + apiRequest: vi.fn(), +})); + +const DOC_ID = "550e8400-e29b-41d4-a716-446655440000"; +const COL_ID = "660e8400-e29b-41d4-a716-446655440001"; + +const mockResult = { + document: { + id: DOC_ID, + title: "Search Doc", + url: "/doc/search-doc-abc123", + urlId: "search-doc-abc123", + collectionId: COL_ID, + }, + context: "Match text here", + ranking: 0.9, +}; + +describe("search command", () => { + let logs: string[]; + let errors: string[]; + let apiRequest: ReturnType; + + beforeEach(async () => { + logs = []; + errors = []; + vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { + logs.push(args.join(" ")); + }); + vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + errors.push(args.join(" ")); + }); + const api = await import("../lib/api.js"); + apiRequest = api.apiRequest as ReturnType; + apiRequest.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("calls documents.search with default limit and filters", async () => { + apiRequest.mockResolvedValue({ + data: [mockResult], + pagination: { offset: 0, limit: 25 }, + }); + + const { registerSearchCommand } = await import("../commands/search.js"); + const program = new Command(); + program.exitOverride(); + registerSearchCommand(program); + + await program.parseAsync([ + "node", + "ol", + "search", + "urgent query", + "--collection", + COL_ID, + "--status", + "published", + ]); + + expect(apiRequest).toHaveBeenCalledWith("documents.search", { + query: "urgent query", + limit: 25, + collectionId: COL_ID, + statusFilter: ["published"], + }); + }); + + it("outputs JSON with essential keys", async () => { + apiRequest.mockResolvedValue({ + data: [mockResult], + }); + + const { registerSearchCommand } = await import("../commands/search.js"); + const program = new Command(); + program.exitOverride(); + registerSearchCommand(program); + + await program.parseAsync(["node", "ol", "search", "test", "--json"]); + + const parsed = JSON.parse(logs[0]); + expect(parsed[0]).toHaveProperty("document"); + expect(parsed[0]).toHaveProperty("context"); + expect(parsed[0]).not.toHaveProperty("ranking"); + }); + + it("outputs NDJSON with full fields", async () => { + apiRequest.mockResolvedValue({ + data: [mockResult, { ...mockResult, ranking: 0.4 }], + }); + + const { registerSearchCommand } = await import("../commands/search.js"); + const program = new Command(); + program.exitOverride(); + registerSearchCommand(program); + + await program.parseAsync([ + "node", + "ol", + "search", + "test", + "--ndjson", + "--full", + ]); + + expect(logs.length).toBe(2); + const parsed = JSON.parse(logs[0]); + expect(parsed.ranking).toBe(0.9); + }); + + it("prints formatted output and pagination hints", async () => { + apiRequest.mockResolvedValue({ + data: [mockResult], + pagination: { + offset: 0, + limit: 25, + nextPath: "/api/documents.search?offset=25", + }, + }); + + const { registerSearchCommand } = await import("../commands/search.js"); + const program = new Command(); + program.exitOverride(); + registerSearchCommand(program); + + await program.parseAsync(["node", "ol", "search", "test"]); + + expect(logs[0]).toContain("https://test.outline.com/doc/search-doc-abc123"); + expect(logs.some((log) => log.includes("more results available"))).toBe( + true, + ); + }); +}); diff --git a/src/__tests__/skill.test.ts b/src/__tests__/skill.test.ts new file mode 100644 index 0000000..d03c414 --- /dev/null +++ b/src/__tests__/skill.test.ts @@ -0,0 +1,174 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + skillInstallers, + listAgents, + getInstaller, + codexInstaller, + cursorInstaller, +} = vi.hoisted(() => { + const codexInstaller = { + name: "codex", + description: "Codex skill for Outline CLI", + getInstallPath: vi.fn(() => "/mock/codex/SKILL.md"), + generateContent: vi.fn(() => "content"), + isInstalled: vi.fn(), + install: vi.fn(), + uninstall: vi.fn(), + }; + const cursorInstaller = { + name: "cursor", + description: "Cursor skill for Outline CLI", + getInstallPath: vi.fn(() => "/mock/cursor/SKILL.md"), + generateContent: vi.fn(() => "content"), + isInstalled: vi.fn(), + install: vi.fn(), + uninstall: vi.fn(), + }; + const skillInstallers = { + codex: codexInstaller, + cursor: cursorInstaller, + }; + const listAgents = vi.fn(() => Object.keys(skillInstallers)); + const getInstaller = vi.fn( + (agent: string) => skillInstallers[agent as keyof typeof skillInstallers], + ); + return { + skillInstallers, + listAgents, + getInstaller, + codexInstaller, + cursorInstaller, + }; +}); + +vi.mock("../lib/skills/index.js", () => ({ + skillInstallers, + listAgents, + getInstaller, +})); + +describe("skill command", () => { + let logs: string[]; + let errors: string[]; + + beforeEach(() => { + logs = []; + errors = []; + vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { + logs.push(args.join(" ")); + }); + vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + errors.push(args.join(" ")); + }); + + listAgents.mockImplementation(() => Object.keys(skillInstallers)); + getInstaller.mockImplementation( + (agent: string) => skillInstallers[agent as keyof typeof skillInstallers], + ); + + codexInstaller.install.mockResolvedValue(undefined); + codexInstaller.uninstall.mockResolvedValue(undefined); + codexInstaller.isInstalled.mockResolvedValue(false); + cursorInstaller.isInstalled.mockResolvedValue(false); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("lists install status for agents", async () => { + codexInstaller.isInstalled + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + cursorInstaller.isInstalled + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + + const { registerSkillCommand } = await import("../commands/skill.js"); + const program = new Command(); + program.exitOverride(); + registerSkillCommand(program); + + await program.parseAsync(["node", "ol", "skill", "list"]); + + const output = logs.join("\n"); + expect(output).toContain("codex"); + expect(output).toContain("[global]"); + expect(output).toContain("cursor"); + expect(output).toContain("not installed"); + }); + + it("installs a skill with default options", async () => { + const { registerSkillCommand } = await import("../commands/skill.js"); + const program = new Command(); + program.exitOverride(); + registerSkillCommand(program); + + await program.parseAsync(["node", "ol", "skill", "install", "codex"]); + + expect(codexInstaller.install).toHaveBeenCalledWith(false, false); + expect(logs.join("\n")).toContain("Installed codex skill"); + expect(logs.join("\n")).toContain("/mock/codex/SKILL.md"); + }); + + it("passes local and force options to install", async () => { + const { registerSkillCommand } = await import("../commands/skill.js"); + const program = new Command(); + program.exitOverride(); + registerSkillCommand(program); + + await program.parseAsync([ + "node", + "ol", + "skill", + "install", + "codex", + "--local", + "--force", + ]); + + expect(codexInstaller.install).toHaveBeenCalledWith(true, true); + }); + + it("uninstalls a skill", async () => { + const { registerSkillCommand } = await import("../commands/skill.js"); + const program = new Command(); + program.exitOverride(); + registerSkillCommand(program); + + await program.parseAsync([ + "node", + "ol", + "skill", + "uninstall", + "codex", + "--local", + ]); + + expect(codexInstaller.uninstall).toHaveBeenCalledWith(true); + expect(logs.join("\n")).toContain("Uninstalled codex skill"); + }); + + it("errors when agent is unknown", async () => { + getInstaller.mockReturnValue(undefined); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((( + code?: number, + ) => { + throw new Error(`process.exit:${code}`); + }) as never); + + const { registerSkillCommand } = await import("../commands/skill.js"); + const program = new Command(); + program.exitOverride(); + registerSkillCommand(program); + + await expect( + program.parseAsync(["node", "ol", "skill", "install", "unknown"]), + ).rejects.toThrow("process.exit:1"); + + expect(errors.join("\n")).toContain("Unknown agent"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); From e058370fd0295f2aa5e3a8be37b231eaf71d907f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Jan 2026 22:03:02 +0000 Subject: [PATCH 3/3] test: cover document --file and collection filter --- src/__tests__/document.test.ts | 118 +++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/src/__tests__/document.test.ts b/src/__tests__/document.test.ts index 27f3046..bd1d3e4 100644 --- a/src/__tests__/document.test.ts +++ b/src/__tests__/document.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -185,6 +188,46 @@ describe("document commands", () => { expect(JSON.parse(logs[0]).title).toBe("Test Document"); expect(JSON.parse(logs[1]).title).toBe("Second"); }); + + it("passes collection filter to API", async () => { + apiRequest + .mockResolvedValueOnce({ + data: [{ id: COL_ID, name: "Test Collection" }], + }) + .mockResolvedValueOnce({ + data: [], + pagination: { offset: 0, limit: 25 }, + }); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync([ + "node", + "ol", + "document", + "list", + "--collection", + "Test Collection", + ]); + + expect(apiRequest).toHaveBeenNthCalledWith( + 1, + "collections.list", + { limit: 100, offset: 0 }, + ); + expect(apiRequest).toHaveBeenNthCalledWith(2, "documents.list", { + limit: 25, + offset: 0, + sort: "updatedAt", + direction: "DESC", + collectionId: COL_ID, + }); + }); }); describe("document get", () => { @@ -424,6 +467,46 @@ describe("document commands", () => { const parsed = JSON.parse(logs[0]); expect(parsed.title).toBe("Test Document"); }); + + it("creates document from file contents", async () => { + const dir = mkdtempSync(join(tmpdir(), "ol-doc-create-")); + const filePath = join(dir, "doc.md"); + writeFileSync(filePath, "# File Title\n\nFrom file."); + + apiRequest + .mockResolvedValueOnce({ + data: { id: COL_ID, name: "Test Collection" }, + }) + .mockResolvedValueOnce({ data: mockDocument }); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync([ + "node", + "ol", + "document", + "create", + "--title", + "New Doc", + "--collection", + COL_ID, + "--file", + filePath, + ]); + + expect(apiRequest).toHaveBeenLastCalledWith("documents.create", { + title: "New Doc", + collectionId: COL_ID, + text: "# File Title\n\nFrom file.", + }); + + rmSync(dir, { recursive: true, force: true }); + }); }); describe("document update", () => { @@ -512,6 +595,41 @@ describe("document commands", () => { text: "Body content", }); }); + + it("updates document from file and extracts title", async () => { + const dir = mkdtempSync(join(tmpdir(), "ol-doc-update-")); + const filePath = join(dir, "doc.md"); + writeFileSync(filePath, "# File Heading\n\nFile body"); + + apiRequest.mockResolvedValue({ + data: mockDocument, + }); + + const { registerDocumentCommand } = await import( + "../commands/document.js" + ); + const program = new Command(); + program.exitOverride(); + registerDocumentCommand(program); + + await program.parseAsync([ + "node", + "ol", + "document", + "update", + DOC_ID, + "--file", + filePath, + ]); + + expect(apiRequest).toHaveBeenCalledWith("documents.update", { + id: DOC_ID, + title: "File Heading", + text: "File body", + }); + + rmSync(dir, { recursive: true, force: true }); + }); }); describe("document delete", () => {