diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index f04af33c..5c7c0e7f 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -93,8 +93,11 @@ export function parsePositionalArgs(args: string[]): { return { eventId: second, targetArg: first }; } -/** Resolved target type for internal use */ -type ResolvedEventTarget = { +/** + * Resolved target type for event commands. + * @internal Exported for testing + */ +export type ResolvedEventTarget = { org: string; project: string; orgDisplay: string; @@ -104,8 +107,19 @@ type ResolvedEventTarget = { /** * Resolve target from a project search result. + * + * Searches for a project by slug across all accessible organizations. + * Throws if no project found or if multiple projects found in different orgs. + * + * @param projectSlug - Project slug to search for + * @param eventId - Event ID (used in error messages) + * @returns Resolved target with org and project info + * @throws {ContextError} If no project found + * @throws {ValidationError} If project exists in multiple organizations + * + * @internal Exported for testing */ -async function resolveFromProjectSearch( +export async function resolveFromProjectSearch( projectSlug: string, eventId: string ): Promise { diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index d86e49c4..2ce6a362 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -1,12 +1,19 @@ /** * Event View Command Tests * - * Tests for positional argument parsing in src/commands/event/view.ts + * Tests for positional argument parsing and project resolution + * in src/commands/event/view.ts */ -import { describe, expect, test } from "bun:test"; -import { parsePositionalArgs } from "../../../src/commands/event/view.js"; -import { ContextError } from "../../../src/lib/errors.js"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { + parsePositionalArgs, + resolveFromProjectSearch, +} from "../../../src/commands/event/view.js"; +import type { ProjectWithOrg } from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +import { ContextError, ValidationError } from "../../../src/lib/errors.js"; describe("parsePositionalArgs", () => { describe("single argument (event ID only)", () => { @@ -85,3 +92,135 @@ describe("parsePositionalArgs", () => { }); }); }); + +describe("resolveFromProjectSearch", () => { + let findProjectsBySlugSpy: ReturnType; + + beforeEach(() => { + findProjectsBySlugSpy = spyOn(apiClient, "findProjectsBySlug"); + }); + + afterEach(() => { + findProjectsBySlugSpy.mockRestore(); + }); + + describe("no projects found", () => { + test("throws ContextError when project not found", async () => { + findProjectsBySlugSpy.mockResolvedValue([]); + + await expect( + resolveFromProjectSearch("my-project", "event-123") + ).rejects.toThrow(ContextError); + }); + + test("includes project name in error message", async () => { + findProjectsBySlugSpy.mockResolvedValue([]); + + try { + await resolveFromProjectSearch("frontend", "event-123"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain('Project "frontend"'); + expect((error as ContextError).message).toContain( + "Check that you have access" + ); + } + }); + }); + + describe("multiple projects found", () => { + test("throws ValidationError when project exists in multiple orgs", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "frontend", orgSlug: "org-a", id: "1", name: "Frontend" }, + { slug: "frontend", orgSlug: "org-b", id: "2", name: "Frontend" }, + ] as ProjectWithOrg[]); + + await expect( + resolveFromProjectSearch("frontend", "event-123") + ).rejects.toThrow(ValidationError); + }); + + test("includes all orgs in error message", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "frontend", orgSlug: "acme-corp", id: "1", name: "Frontend" }, + { slug: "frontend", orgSlug: "beta-inc", id: "2", name: "Frontend" }, + ] as ProjectWithOrg[]); + + try { + await resolveFromProjectSearch("frontend", "event-456"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const message = (error as ValidationError).message; + expect(message).toContain("exists in multiple organizations"); + expect(message).toContain("acme-corp/frontend"); + expect(message).toContain("beta-inc/frontend"); + expect(message).toContain("event-456"); // Event ID in example + } + }); + + test("includes usage example in error message", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "api", orgSlug: "org-1", id: "1", name: "API" }, + { slug: "api", orgSlug: "org-2", id: "2", name: "API" }, + { slug: "api", orgSlug: "org-3", id: "3", name: "API" }, + ] as ProjectWithOrg[]); + + try { + await resolveFromProjectSearch("api", "abc123"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const message = (error as ValidationError).message; + expect(message).toContain( + "Example: sentry event view /api abc123" + ); + } + }); + }); + + describe("single project found", () => { + test("returns resolved target for single match", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "backend", orgSlug: "my-company", id: "42", name: "Backend" }, + ] as ProjectWithOrg[]); + + const result = await resolveFromProjectSearch("backend", "event-xyz"); + + expect(result).toEqual({ + org: "my-company", + project: "backend", + orgDisplay: "my-company", + projectDisplay: "backend", + }); + }); + + test("uses orgSlug from project result", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { + slug: "mobile-app", + orgSlug: "acme-industries", + id: "100", + name: "Mobile App", + }, + ] as ProjectWithOrg[]); + + const result = await resolveFromProjectSearch("mobile-app", "evt-001"); + + expect(result.org).toBe("acme-industries"); + expect(result.orgDisplay).toBe("acme-industries"); + }); + + test("preserves project slug in result", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "web-frontend", orgSlug: "org", id: "1", name: "Web Frontend" }, + ] as ProjectWithOrg[]); + + const result = await resolveFromProjectSearch("web-frontend", "e123"); + + expect(result.project).toBe("web-frontend"); + expect(result.projectDisplay).toBe("web-frontend"); + }); + }); +});