diff --git a/src/commands/documents.ts b/src/commands/documents.ts index 55f8bd43..ae544a21 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -80,8 +80,8 @@ export const DOCUMENTS_META: DomainMeta = { name: "documents", summary: "long-form markdown docs attached to projects or issues", context: [ - "a document is a markdown page. it can belong to a project and/or be", - "attached to an issue. documents support icons and colors.", + "a document is a markdown page. it must belong to exactly one project,", + "team, or issue. documents support icons and colors.", ].join("\n"), arguments: { document: "document identifier (UUID)", @@ -190,8 +190,17 @@ export function setupDocumentsCommands(program: Command): void { "cannot be combined with --issue", ); } - const issueIdentifier = options.issue ?? options.attachTo; + const scopes = [options.project, options.team, issueIdentifier].filter( + Boolean, + ); + if (scopes.length !== 1) { + throw invalidParameterError( + "--project|--team|--issue", + "a document needs exactly one scope: --project, --team, or --issue", + ); + } + const rootOpts = getRootOpts(command); const ctx = createContext(rootOpts); diff --git a/tests/unit/commands/documents.test.ts b/tests/unit/commands/documents.test.ts index 06508f32..63b16520 100644 --- a/tests/unit/commands/documents.test.ts +++ b/tests/unit/commands/documents.test.ts @@ -59,7 +59,6 @@ vi.mock("../../../src/services/document-service.js", async (importOriginal) => { import { setupDocumentsCommands } from "../../../src/commands/documents.js"; import { resolveIssueId } from "../../../src/resolvers/issue-resolver.js"; -import { resolveTeamId } from "../../../src/resolvers/team-resolver.js"; import { listAttachments } from "../../../src/services/attachment-service.js"; import { createDocument, @@ -167,6 +166,33 @@ describe("documents create", () => { ); }); + it("rejects creating without a document scope", async () => { + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + vi.spyOn(console, "error").mockImplementation(() => {}); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "documents", + "create", + "--title", + "Runbook", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining( + "Invalid --project|--team|--issue: a document needs exactly one scope: --project, --team, or --issue", + ), + ); + expect(createDocument).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + + exitSpy.mockRestore(); + }); + it("accepts --attach-to as an alias for --issue", async () => { const program = createProgram(); await program.parseAsync([ @@ -176,23 +202,55 @@ describe("documents create", () => { "create", "--title", "Runbook", - "--team", - "ENG", "--attach-to", "ENG-42", ]); - expect(resolveTeamId).toHaveBeenCalledWith(expect.anything(), "ENG"); expect(resolveIssueId).toHaveBeenCalledWith(expect.anything(), "ENG-42"); expect(createDocument).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ - teamId: "resolved-team-uuid", issueId: "resolved-issue-uuid", }), ); }); + it.each([ + ["--project", "PROJ", "--team", "ENG"], + ["--project", "PROJ", "--issue", "ENG-42"], + ["--team", "ENG", "--issue", "ENG-42"], + ])( + "rejects combining %s and %s", + async (firstFlag, firstValue, secondFlag, secondValue) => { + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + vi.spyOn(console, "error").mockImplementation(() => {}); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "documents", + "create", + "--title", + "Runbook", + firstFlag, + firstValue, + secondFlag, + secondValue, + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("a document needs exactly one scope"), + ); + expect(createDocument).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + + exitSpy.mockRestore(); + }, + ); + it("rejects combining --issue and --attach-to before creating", async () => { const exitSpy = vi .spyOn(process, "exit")