From 3173b0c8d4dbff9fdb74f830b7061bfda3d4ecdd Mon Sep 17 00:00:00 2001 From: Sid Date: Tue, 16 Dec 2025 01:26:09 +0000 Subject: [PATCH 1/2] feat: Add initiatives command with list, read, and update subcommands - Add initiatives command with list/read/update operations - Add TypeScript interfaces for initiatives (Initiative, InitiativeConnection) - Add initiative service methods to LinearService - Include content field for full body markdown support --- src/commands/initiatives.ts | 130 +++++++++++++++++++++ src/main.ts | 2 + src/utils/linear-service.ts | 217 ++++++++++++++++++++++++++++++++++++ src/utils/linear-types.d.ts | 50 +++++++++ 4 files changed, 399 insertions(+) create mode 100644 src/commands/initiatives.ts diff --git a/src/commands/initiatives.ts b/src/commands/initiatives.ts new file mode 100644 index 0000000..616754d --- /dev/null +++ b/src/commands/initiatives.ts @@ -0,0 +1,130 @@ +import { Command } from "commander"; +import { createLinearService } from "../utils/linear-service.js"; +import { handleAsyncCommand, outputSuccess } from "../utils/output.js"; +import type { + InitiativeListOptions, + InitiativeReadOptions, + InitiativeUpdateOptions, +} from "../utils/linear-types.js"; + +export function setupInitiativesCommands(program: Command): void { + const initiatives = program + .command("initiatives") + .description("Initiative operations"); + + initiatives.action(() => initiatives.help()); + + initiatives + .command("list") + .description("List initiatives") + .option("--status ", "filter by status (Planned/Active/Completed)") + .option("--owner ", "filter by owner ID") + .option("-l, --limit ", "limit results", "50") + .action( + handleAsyncCommand( + async (options: InitiativeListOptions, command: Command) => { + const linearService = await createLinearService( + command.parent!.parent!.opts(), + ); + + const initiatives = await linearService.getInitiatives( + options.status, + options.owner, + parseInt(options.limit || "50"), + ); + + outputSuccess(initiatives); + }, + ), + ); + + initiatives + .command("read ") + .description( + "Get initiative details including projects and sub-initiatives. Accepts UUID or initiative name.", + ) + .option( + "--projects-first ", + "how many projects to fetch (default 50)", + "50", + ) + .action( + handleAsyncCommand( + async ( + initiativeIdOrName: string, + options: InitiativeReadOptions, + command: Command, + ) => { + const linearService = await createLinearService( + command.parent!.parent!.opts(), + ); + + // Resolve initiative ID (handles both UUID and name-based lookup) + const initiativeId = await linearService.resolveInitiativeId( + initiativeIdOrName, + ); + + // Fetch initiative with projects and sub-initiatives + const initiative = await linearService.getInitiativeById( + initiativeId, + parseInt(options.projectsFirst || "50"), + ); + + outputSuccess(initiative); + }, + ), + ); + + initiatives + .command("update ") + .description("Update an initiative. Accepts UUID or initiative name.") + .option("-n, --name ", "new initiative name") + .option("-d, --description ", "new short description") + .option("--content ", "new body content (markdown)") + .option("--status ", "new status (Planned/Active/Completed)") + .option("--owner ", "new owner user ID") + .option("--target-date ", "new target date (YYYY-MM-DD)") + .action( + handleAsyncCommand( + async ( + initiativeIdOrName: string, + options: InitiativeUpdateOptions, + command: Command, + ) => { + const linearService = await createLinearService( + command.parent!.parent!.opts(), + ); + + // Resolve initiative ID + const initiativeId = await linearService.resolveInitiativeId( + initiativeIdOrName, + ); + + // Build update object with only provided fields + const updates: Record = {}; + if (options.name !== undefined) updates.name = options.name; + if (options.description !== undefined) + updates.description = options.description; + if (options.content !== undefined) updates.content = options.content; + if (options.status !== undefined) updates.status = options.status; + if (options.owner !== undefined) updates.ownerId = options.owner; + if (options.targetDate !== undefined) + updates.targetDate = options.targetDate; + + // Require at least one field to update + if (Object.keys(updates).length === 0) { + throw new Error( + "At least one update option is required (--name, --description, --content, --status, --owner, or --target-date)", + ); + } + + const updated = await linearService.updateInitiative( + initiativeId, + updates, + ); + + outputSuccess(updated); + }, + ), + ); +} diff --git a/src/main.ts b/src/main.ts index 538dbdc..b1e5725 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,6 +23,7 @@ import { setupLabelsCommands } from "./commands/labels.js"; import { setupProjectsCommands } from "./commands/projects.js"; import { setupCyclesCommands } from "./commands/cycles.js"; import { setupProjectMilestonesCommands } from "./commands/project-milestones.js"; +import { setupInitiativesCommands } from "./commands/initiatives.js"; import { setupTeamsCommands } from "./commands/teams.js"; import { setupUsersCommands } from "./commands/users.js"; import { setupDocumentsCommands } from "./commands/documents.js"; @@ -47,6 +48,7 @@ setupLabelsCommands(program); setupProjectsCommands(program); setupCyclesCommands(program); setupProjectMilestonesCommands(program); +setupInitiativesCommands(program); setupEmbedsCommands(program); setupTeamsCommands(program); setupUsersCommands(program); diff --git a/src/utils/linear-service.ts b/src/utils/linear-service.ts index 654f2ac..341dbba 100644 --- a/src/utils/linear-service.ts +++ b/src/utils/linear-service.ts @@ -3,6 +3,7 @@ import { CommandOptions, getApiToken } from "./auth.js"; import { CreateCommentArgs, LinearComment, + LinearInitiative, LinearIssue, LinearLabel, LinearProject, @@ -687,6 +688,222 @@ export class LinearService { return projectsConnection.nodes[0].id; } + + /** + * Get all initiatives + * + * @param statusFilter - Optional status filter (Planned/Active/Completed) + * @param ownerFilter - Optional owner ID filter + * @param limit - Maximum initiatives to fetch (default 50) + * @returns Array of initiatives with owner information + */ + async getInitiatives( + statusFilter?: string, + ownerFilter?: string, + limit: number = 50, + ): Promise { + const filter: any = {}; + + if (statusFilter) { + filter.status = { eq: statusFilter }; + } + + if (ownerFilter) { + filter.owner = { id: { eq: ownerFilter } }; + } + + const initiativesConnection = await this.client.initiatives({ + filter: Object.keys(filter).length > 0 ? filter : undefined, + first: limit, + }); + + // Fetch owner relationship in parallel for all initiatives + const initiativesWithData = await Promise.all( + initiativesConnection.nodes.map(async (initiative) => { + const owner = await initiative.owner; + return { + id: initiative.id, + name: initiative.name, + description: initiative.description || undefined, + content: initiative.content || undefined, + status: initiative.status as "Planned" | "Active" | "Completed", + health: initiative.health as + | "onTrack" + | "atRisk" + | "offTrack" + | undefined, + targetDate: initiative.targetDate + ? new Date(initiative.targetDate).toISOString() + : undefined, + owner: owner + ? { + id: owner.id, + name: owner.name, + } + : undefined, + createdAt: initiative.createdAt + ? new Date(initiative.createdAt).toISOString() + : new Date().toISOString(), + updatedAt: initiative.updatedAt + ? new Date(initiative.updatedAt).toISOString() + : new Date().toISOString(), + }; + }), + ); + + return initiativesWithData; + } + + /** + * Get single initiative by ID with projects and sub-initiatives + * + * @param initiativeId - Initiative UUID + * @param projectsLimit - Maximum projects to fetch (default 50) + * @returns Initiative with projects and sub-initiatives + */ + async getInitiativeById( + initiativeId: string, + projectsLimit: number = 50, + ): Promise { + const initiative = await this.client.initiative(initiativeId); + + const [ + owner, + projectsConnection, + parentInitiative, + subInitiativesConnection, + ] = await Promise.all([ + initiative.owner, + initiative.projects({ first: projectsLimit }), + initiative.parentInitiative, + initiative.subInitiatives({ first: 50 }), + ]); + + // Map projects with basic info + const projects = projectsConnection.nodes.map((project) => ({ + id: project.id, + name: project.name, + state: project.state, + progress: project.progress, + })); + + // Map sub-initiatives + const subInitiatives = subInitiativesConnection.nodes.map((sub) => ({ + id: sub.id, + name: sub.name, + status: sub.status as "Planned" | "Active" | "Completed", + })); + + return { + id: initiative.id, + name: initiative.name, + description: initiative.description || undefined, + content: initiative.content || undefined, + status: initiative.status as "Planned" | "Active" | "Completed", + health: initiative.health as + | "onTrack" + | "atRisk" + | "offTrack" + | undefined, + targetDate: initiative.targetDate + ? new Date(initiative.targetDate).toISOString() + : undefined, + owner: owner + ? { + id: owner.id, + name: owner.name, + } + : undefined, + createdAt: initiative.createdAt + ? new Date(initiative.createdAt).toISOString() + : new Date().toISOString(), + updatedAt: initiative.updatedAt + ? new Date(initiative.updatedAt).toISOString() + : new Date().toISOString(), + projects: projects.length > 0 ? projects : undefined, + parentInitiative: parentInitiative + ? { + id: parentInitiative.id, + name: parentInitiative.name, + } + : undefined, + subInitiatives: subInitiatives.length > 0 ? subInitiatives : undefined, + }; + } + + /** + * Resolve initiative by name or ID + * + * @param initiativeNameOrId - Initiative name or UUID + * @returns Initiative UUID + * @throws Error if initiative not found or multiple matches + */ + async resolveInitiativeId(initiativeNameOrId: string): Promise { + // Return UUID as-is + if (isUuid(initiativeNameOrId)) { + return initiativeNameOrId; + } + + // Search by name (case-insensitive) + const initiativesConnection = await this.client.initiatives({ + filter: { name: { eqIgnoreCase: initiativeNameOrId } }, + first: 10, + }); + + const nodes = initiativesConnection.nodes; + + if (nodes.length === 0) { + throw notFoundError("Initiative", initiativeNameOrId); + } + + if (nodes.length === 1) { + return nodes[0].id; + } + + // Multiple matches - prefer Active, then Planned + let chosen = nodes.find((n) => n.status === "Active"); + if (!chosen) chosen = nodes.find((n) => n.status === "Planned"); + if (!chosen) chosen = nodes[0]; + + return chosen.id; + } + + /** + * Update an initiative + * + * @param initiativeId - Initiative UUID + * @param updates - Fields to update + * @returns Updated initiative + */ + async updateInitiative( + initiativeId: string, + updates: { + name?: string; + description?: string; + content?: string; + status?: "Planned" | "Active" | "Completed"; + ownerId?: string; + targetDate?: string; + }, + ): Promise { + // Build update input with only provided fields + const input: Record = {}; + if (updates.name !== undefined) input.name = updates.name; + if (updates.description !== undefined) input.description = updates.description; + if (updates.content !== undefined) input.content = updates.content; + if (updates.status !== undefined) input.status = updates.status; + if (updates.ownerId !== undefined) input.ownerId = updates.ownerId; + if (updates.targetDate !== undefined) input.targetDate = updates.targetDate; + + const payload = await this.client.updateInitiative(initiativeId, input); + + if (!payload.success) { + throw new Error("Failed to update initiative"); + } + + // Re-fetch to get complete data + return this.getInitiativeById(initiativeId); + } } /** diff --git a/src/utils/linear-types.d.ts b/src/utils/linear-types.d.ts index ec24d51..2cd484d 100644 --- a/src/utils/linear-types.d.ts +++ b/src/utils/linear-types.d.ts @@ -328,3 +328,53 @@ export interface AttachmentCreateInput { commentBody?: string; iconUrl?: string; } + +export interface LinearInitiative { + id: string; + name: string; + description?: string; + content?: string; // Full body content in markdown format + status: "Planned" | "Active" | "Completed"; + health?: "onTrack" | "atRisk" | "offTrack"; + targetDate?: string; + owner?: { + id: string; + name: string; + }; + createdAt: string; + updatedAt: string; + projects?: Array<{ + id: string; + name: string; + state: string; + progress: number; + }>; + parentInitiative?: { + id: string; + name: string; + }; + subInitiatives?: Array<{ + id: string; + name: string; + status: "Planned" | "Active" | "Completed"; + }>; +} + +export interface InitiativeListOptions { + status?: string; + owner?: string; + limit?: string; +} + +export interface InitiativeReadOptions { + projectsFirst?: string; +} + +export interface InitiativeUpdateOptions { + name?: string; + description?: string; + content?: string; + status?: string; + owner?: string; + targetDate?: string; +} From 257327e2589b456c74ffc928896aed638d144be1 Mon Sep 17 00:00:00 2001 From: Sid Date: Tue, 16 Dec 2025 01:59:40 +0000 Subject: [PATCH 2/2] Unit and Integration tests --- tests/integration/initiatives-cli.test.ts | 281 ++++++++++++++++ tests/unit/linear-service-initiatives.test.ts | 310 ++++++++++++++++++ 2 files changed, 591 insertions(+) create mode 100644 tests/integration/initiatives-cli.test.ts create mode 100644 tests/unit/linear-service-initiatives.test.ts diff --git a/tests/integration/initiatives-cli.test.ts b/tests/integration/initiatives-cli.test.ts new file mode 100644 index 0000000..46898dd --- /dev/null +++ b/tests/integration/initiatives-cli.test.ts @@ -0,0 +1,281 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { exec } from "child_process"; +import { promisify } from "util"; + +const execAsync = promisify(exec); + +/** + * Integration tests for initiatives CLI commands + * + * These tests verify the initiatives command works end-to-end with the compiled CLI. + * They test: + * - Help output for all subcommands + * - JSON output structure and validity + * - Filter options work correctly + * - Error handling for invalid inputs + * + * Note: These tests require LINEAR_API_TOKEN to be set in environment. + * If not set, tests will be skipped. + * + * Linear.app Setup Requirements: + * - At least one initiative must exist in your workspace + * - No specific naming or configuration required + */ + +const CLI_PATH = "./dist/main.js"; +const hasApiToken = !!process.env.LINEAR_API_TOKEN; + +describe("Initiatives CLI Commands", () => { + beforeAll(async () => { + if (!hasApiToken) { + console.warn( + "\n⚠️ LINEAR_API_TOKEN not set - skipping integration tests\n" + + " To run these tests, set LINEAR_API_TOKEN in your environment\n" + + " Your Linear workspace must have at least one initiative\n", + ); + } + }); + + describe("initiatives --help", () => { + it("should display help text", async () => { + const { stdout } = await execAsync(`node ${CLI_PATH} initiatives --help`); + + expect(stdout).toContain("Usage: linearis initiatives"); + expect(stdout).toContain("Initiative operations"); + expect(stdout).toContain("list"); + expect(stdout).toContain("read"); + expect(stdout).toContain("update"); + }); + }); + + describe("initiatives list --help", () => { + it("should display list help text", async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} initiatives list --help`, + ); + + expect(stdout).toContain("List initiatives"); + expect(stdout).toContain("--status"); + expect(stdout).toContain("--owner"); + expect(stdout).toContain("--limit"); + }); + }); + + describe("initiatives read --help", () => { + it("should display read help text", async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} initiatives read --help`, + ); + + expect(stdout).toContain("Get initiative details"); + expect(stdout).toContain("--projects-first"); + }); + }); + + describe("initiatives update --help", () => { + it("should display update help text", async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} initiatives update --help`, + ); + + expect(stdout).toContain("Update an initiative"); + expect(stdout).toContain("--name"); + expect(stdout).toContain("--description"); + expect(stdout).toContain("--content"); + expect(stdout).toContain("--status"); + expect(stdout).toContain("--owner"); + expect(stdout).toContain("--target-date"); + }); + }); + + describe("initiatives list", () => { + it.skipIf(!hasApiToken)("should list initiatives without error", async () => { + const { stdout, stderr } = await execAsync( + `node ${CLI_PATH} initiatives list --limit 5`, + ); + + // Should not have errors + expect(stderr).not.toContain("error"); + + // Should return valid JSON + const initiatives = JSON.parse(stdout); + expect(Array.isArray(initiatives)).toBe(true); + }); + + it.skipIf(!hasApiToken)( + "should return valid initiative structure", + async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} initiatives list --limit 1`, + ); + const initiatives = JSON.parse(stdout); + + if (initiatives.length > 0) { + const initiative = initiatives[0]; + + // Verify initiative has expected fields + expect(initiative).toHaveProperty("id"); + expect(initiative).toHaveProperty("name"); + expect(initiative).toHaveProperty("status"); + expect(initiative).toHaveProperty("createdAt"); + expect(initiative).toHaveProperty("updatedAt"); + + // Verify status is one of the valid values + expect(["Planned", "Active", "Completed"]).toContain( + initiative.status, + ); + } + }, + ); + + it.skipIf(!hasApiToken)( + "should filter by status", + async () => { + // This test verifies the filter is applied - even if no results, + // the query should succeed + const { stdout } = await execAsync( + `node ${CLI_PATH} initiatives list --status Active --limit 5`, + ); + + const initiatives = JSON.parse(stdout); + expect(Array.isArray(initiatives)).toBe(true); + + // If there are results, verify they match the filter + initiatives.forEach((init: any) => { + expect(init.status).toBe("Active"); + }); + }, + ); + + it.skipIf(!hasApiToken)( + "should respect limit parameter", + async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} initiatives list --limit 2`, + ); + + const initiatives = JSON.parse(stdout); + expect(initiatives.length).toBeLessThanOrEqual(2); + }, + ); + }); + + describe("initiatives read", () => { + it.skipIf(!hasApiToken)("should read initiative by ID", async () => { + // First get an initiative ID + const { stdout: listOutput } = await execAsync( + `node ${CLI_PATH} initiatives list --limit 1`, + ); + const initiatives = JSON.parse(listOutput); + + if (initiatives.length > 0) { + const initiativeId = initiatives[0].id; + + const { stdout, stderr } = await execAsync( + `node ${CLI_PATH} initiatives read ${initiativeId}`, + ); + + // Should not have errors + expect(stderr).not.toContain("error"); + + const initiative = JSON.parse(stdout); + + // Verify initiative details structure + expect(initiative).toHaveProperty("id"); + expect(initiative).toHaveProperty("name"); + expect(initiative).toHaveProperty("status"); + expect(initiative.id).toBe(initiativeId); + } + }); + + it.skipIf(!hasApiToken)("should read initiative by name", async () => { + // First get an initiative name + const { stdout: listOutput } = await execAsync( + `node ${CLI_PATH} initiatives list --limit 1`, + ); + const initiatives = JSON.parse(listOutput); + + if (initiatives.length > 0) { + const initiativeName = initiatives[0].name; + + const { stdout } = await execAsync( + `node ${CLI_PATH} initiatives read "${initiativeName}"`, + ); + + const initiative = JSON.parse(stdout); + expect(initiative.name).toBe(initiativeName); + } + }); + + it.skipIf(!hasApiToken)( + "should include projects when present", + async () => { + const { stdout: listOutput } = await execAsync( + `node ${CLI_PATH} initiatives list --limit 1`, + ); + const initiatives = JSON.parse(listOutput); + + if (initiatives.length > 0) { + const initiativeId = initiatives[0].id; + + const { stdout } = await execAsync( + `node ${CLI_PATH} initiatives read ${initiativeId} --projects-first 5`, + ); + + const initiative = JSON.parse(stdout); + + // projects field should exist (may be undefined if none) + // Just verify the command works with the flag + expect(initiative).toHaveProperty("id"); + } + }, + ); + }); + + describe("initiatives update", () => { + it.skipIf(!hasApiToken)( + "should reject update with no options provided", + async () => { + // First get a real initiative name to test with + const { stdout: listOutput } = await execAsync( + `node ${CLI_PATH} initiatives list --limit 1`, + ); + const initiatives = JSON.parse(listOutput); + + if (initiatives.length > 0) { + const initiativeName = initiatives[0].name; + + try { + await execAsync( + `node ${CLI_PATH} initiatives update "${initiativeName}"`, + ); + expect.fail("Should have thrown an error"); + } catch (error: any) { + expect(error.stderr).toContain( + "At least one update option is required", + ); + } + } + }, + ); + + // Note: We don't test actual updates in integration tests to avoid + // modifying real data. The unit tests cover update functionality. + }); + + describe("initiatives - error handling", () => { + it.skipIf(!hasApiToken)( + "should return error for non-existent initiative", + async () => { + try { + await execAsync( + `node ${CLI_PATH} initiatives read "Nonexistent Initiative Name 12345"`, + ); + expect.fail("Should have thrown an error"); + } catch (error: any) { + expect(error.stderr).toContain("not found"); + } + }, + ); + }); +}); diff --git a/tests/unit/linear-service-initiatives.test.ts b/tests/unit/linear-service-initiatives.test.ts new file mode 100644 index 0000000..9691232 --- /dev/null +++ b/tests/unit/linear-service-initiatives.test.ts @@ -0,0 +1,310 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { LinearService } from "../../src/utils/linear-service.js"; + +/** + * Unit tests for LinearService initiative methods + * + * These tests verify the initiative-related methods: + * - getInitiatives() - Fetch initiatives with filters + * - getInitiativeById() - Fetch single initiative with projects/sub-initiatives + * - resolveInitiativeId() - Resolve initiative by name or ID + * - updateInitiative() - Update initiative fields + * + * Note: These tests use mocks to avoid hitting the real Linear API. + * For integration tests with real API, see tests/integration/ + */ + +describe("LinearService - Initiative Methods", () => { + let mockClient: any; + let service: LinearService; + + beforeEach(() => { + // Create mock Linear client + mockClient = { + initiatives: vi.fn(), + initiative: vi.fn(), + updateInitiative: vi.fn(), + }; + + // Create service with mock client + service = new LinearService("fake-token"); + // @ts-ignore - Replace internal client with mock + service.client = mockClient; + }); + + describe("getInitiatives()", () => { + it("should fetch initiatives without filters", async () => { + const mockInitiatives = [ + { + id: "init-1", + name: "Q1 Goals", + description: "First quarter objectives", + status: "Active", + health: "onTrack", + targetDate: new Date("2025-03-31"), + createdAt: new Date("2025-01-01"), + updatedAt: new Date("2025-01-15"), + owner: Promise.resolve({ + id: "user-1", + name: "John Doe", + }), + }, + ]; + + mockClient.initiatives.mockResolvedValue({ + nodes: mockInitiatives, + }); + + const result = await service.getInitiatives(); + + expect(mockClient.initiatives).toHaveBeenCalledWith({ + filter: undefined, + first: 50, + }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("init-1"); + expect(result[0].name).toBe("Q1 Goals"); + expect(result[0].status).toBe("Active"); + expect(result[0].owner?.name).toBe("John Doe"); + }); + + it("should fetch initiatives with status filter", async () => { + mockClient.initiatives.mockResolvedValue({ nodes: [] }); + + await service.getInitiatives("Active"); + + expect(mockClient.initiatives).toHaveBeenCalledWith({ + filter: { status: { eq: "Active" } }, + first: 50, + }); + }); + + it("should fetch initiatives with owner filter", async () => { + mockClient.initiatives.mockResolvedValue({ nodes: [] }); + + await service.getInitiatives(undefined, "user-123"); + + expect(mockClient.initiatives).toHaveBeenCalledWith({ + filter: { owner: { id: { eq: "user-123" } } }, + first: 50, + }); + }); + + it("should respect limit parameter", async () => { + mockClient.initiatives.mockResolvedValue({ nodes: [] }); + + await service.getInitiatives(undefined, undefined, 10); + + expect(mockClient.initiatives).toHaveBeenCalledWith({ + filter: undefined, + first: 10, + }); + }); + + it("should convert dates to ISO 8601 strings", async () => { + const mockInitiatives = [ + { + id: "init-1", + name: "Test", + status: "Planned", + targetDate: new Date("2025-06-30T00:00:00Z"), + createdAt: new Date("2025-01-01T00:00:00Z"), + updatedAt: new Date("2025-01-15T12:00:00Z"), + owner: Promise.resolve(null), + }, + ]; + + mockClient.initiatives.mockResolvedValue({ nodes: mockInitiatives }); + + const result = await service.getInitiatives(); + + expect(typeof result[0].targetDate).toBe("string"); + expect(typeof result[0].createdAt).toBe("string"); + expect(typeof result[0].updatedAt).toBe("string"); + expect(result[0].targetDate).toBe("2025-06-30T00:00:00.000Z"); + }); + }); + + describe("getInitiativeById()", () => { + it("should fetch initiative with projects and sub-initiatives", async () => { + const mockOwner = { id: "user-1", name: "Jane Doe" }; + const mockProjects = [ + { id: "proj-1", name: "Project A", state: "started", progress: 0.5 }, + ]; + const mockSubInitiatives = [ + { id: "sub-1", name: "Sub Initiative", status: "Planned" }, + ]; + const mockParent = { id: "parent-1", name: "Parent Initiative" }; + + const mockInitiative = { + id: "init-1", + name: "Main Initiative", + description: "Description here", + status: "Active", + health: "atRisk", + targetDate: new Date("2025-12-31"), + createdAt: new Date("2025-01-01"), + updatedAt: new Date("2025-01-15"), + owner: Promise.resolve(mockOwner), + projects: vi.fn().mockResolvedValue({ nodes: mockProjects }), + parentInitiative: Promise.resolve(mockParent), + subInitiatives: vi.fn().mockResolvedValue({ nodes: mockSubInitiatives }), + }; + + mockClient.initiative.mockResolvedValue(mockInitiative); + + const result = await service.getInitiativeById("init-1", 25); + + expect(mockClient.initiative).toHaveBeenCalledWith("init-1"); + expect(mockInitiative.projects).toHaveBeenCalledWith({ first: 25 }); + expect(mockInitiative.subInitiatives).toHaveBeenCalledWith({ first: 50 }); + expect(result.id).toBe("init-1"); + expect(result.owner?.name).toBe("Jane Doe"); + expect(result.projects).toHaveLength(1); + expect(result.projects![0].name).toBe("Project A"); + expect(result.subInitiatives).toHaveLength(1); + expect(result.parentInitiative?.name).toBe("Parent Initiative"); + }); + + it("should use default projects limit of 50", async () => { + const mockInitiative = { + id: "init-1", + name: "Test", + status: "Planned", + createdAt: new Date(), + updatedAt: new Date(), + owner: Promise.resolve(null), + projects: vi.fn().mockResolvedValue({ nodes: [] }), + parentInitiative: Promise.resolve(null), + subInitiatives: vi.fn().mockResolvedValue({ nodes: [] }), + }; + + mockClient.initiative.mockResolvedValue(mockInitiative); + + await service.getInitiativeById("init-1"); + + expect(mockInitiative.projects).toHaveBeenCalledWith({ first: 50 }); + }); + }); + + describe("resolveInitiativeId()", () => { + it("should return UUID as-is", async () => { + const uuid = "550e8400-e29b-41d4-a716-446655440000"; + const result = await service.resolveInitiativeId(uuid); + expect(result).toBe(uuid); + }); + + it("should resolve initiative by name", async () => { + const mockInitiatives = [ + { id: "init-1", name: "Q1 Goals", status: "Active" }, + ]; + + mockClient.initiatives.mockResolvedValue({ nodes: mockInitiatives }); + + const result = await service.resolveInitiativeId("Q1 Goals"); + + expect(mockClient.initiatives).toHaveBeenCalledWith({ + filter: { name: { eqIgnoreCase: "Q1 Goals" } }, + first: 10, + }); + expect(result).toBe("init-1"); + }); + + it("should throw error when initiative not found", async () => { + mockClient.initiatives.mockResolvedValue({ nodes: [] }); + + await expect(service.resolveInitiativeId("Nonexistent")).rejects.toThrow( + 'Initiative "Nonexistent" not found', + ); + }); + + it("should disambiguate by preferring Active status", async () => { + const mockInitiatives = [ + { id: "init-planned", name: "Q1 Goals", status: "Planned" }, + { id: "init-active", name: "Q1 Goals", status: "Active" }, + { id: "init-completed", name: "Q1 Goals", status: "Completed" }, + ]; + + mockClient.initiatives.mockResolvedValue({ nodes: mockInitiatives }); + + const result = await service.resolveInitiativeId("Q1 Goals"); + + expect(result).toBe("init-active"); + }); + + it("should prefer Planned when no Active exists", async () => { + const mockInitiatives = [ + { id: "init-completed", name: "Q1 Goals", status: "Completed" }, + { id: "init-planned", name: "Q1 Goals", status: "Planned" }, + ]; + + mockClient.initiatives.mockResolvedValue({ nodes: mockInitiatives }); + + const result = await service.resolveInitiativeId("Q1 Goals"); + + expect(result).toBe("init-planned"); + }); + }); + + describe("updateInitiative()", () => { + it("should update initiative and return updated data", async () => { + mockClient.updateInitiative.mockResolvedValue({ success: true }); + + // Mock getInitiativeById for the re-fetch + const mockInitiative = { + id: "init-1", + name: "Updated Name", + description: "New description", + status: "Active", + createdAt: new Date(), + updatedAt: new Date(), + owner: Promise.resolve(null), + projects: vi.fn().mockResolvedValue({ nodes: [] }), + parentInitiative: Promise.resolve(null), + subInitiatives: vi.fn().mockResolvedValue({ nodes: [] }), + }; + mockClient.initiative.mockResolvedValue(mockInitiative); + + const result = await service.updateInitiative("init-1", { + name: "Updated Name", + description: "New description", + }); + + expect(mockClient.updateInitiative).toHaveBeenCalledWith("init-1", { + name: "Updated Name", + description: "New description", + }); + expect(result.name).toBe("Updated Name"); + }); + + it("should throw error when update fails", async () => { + mockClient.updateInitiative.mockResolvedValue({ success: false }); + + await expect( + service.updateInitiative("init-1", { name: "New Name" }), + ).rejects.toThrow("Failed to update initiative"); + }); + + it("should only include provided fields in update", async () => { + mockClient.updateInitiative.mockResolvedValue({ success: true }); + const mockInitiative = { + id: "init-1", + name: "Test", + status: "Active", + createdAt: new Date(), + updatedAt: new Date(), + owner: Promise.resolve(null), + projects: vi.fn().mockResolvedValue({ nodes: [] }), + parentInitiative: Promise.resolve(null), + subInitiatives: vi.fn().mockResolvedValue({ nodes: [] }), + }; + mockClient.initiative.mockResolvedValue(mockInitiative); + + await service.updateInitiative("init-1", { status: "Completed" }); + + expect(mockClient.updateInitiative).toHaveBeenCalledWith("init-1", { + status: "Completed", + }); + }); + }); +});